fix(app): derive model reasoning capability from variants and compatibility (#60457)

This commit is contained in:
Luke Parker
2026-10-30 14:14:04 +10:00
committed by GitHub
Unverified
parent fbd8aecaa1
commit 85699b5ec8
2 changed files with 98 additions and 45 deletions
@@ -4,7 +4,7 @@ import { directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProvid
describe("normalizeAgentList", () => {
test("adapts current agents to the app agent shape", () => {
const result = normalizeAgentList([
const agents: AgentListOutput["data"] = [
{
id: "build",
name: "Build",
@@ -16,7 +16,9 @@ describe("normalizeAgentList", () => {
system: "Build software",
permissions: [{ action: "read", resource: "*", effect: "allow" }],
},
] as AgentListOutput["data"])
]
const result = normalizeAgentList(agents)
expect(result).toEqual([
{
@@ -40,37 +42,40 @@ describe("normalizeAgentList", () => {
describe("normalizeProviderList", () => {
test("groups current models into the app provider catalog", () => {
const result = normalizeProviderList(
[{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] as ProviderListOutput["data"],
[
{
id: "gpt-5",
modelID: "gpt-5",
providerID: "openai",
name: "GPT-5",
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
variants: [{ id: "high" }],
time: { released: 1 },
cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }],
status: "active",
enabled: true,
limit: { context: 128_000, output: 8_192 },
},
{
id: "gpt-old",
modelID: "gpt-old",
providerID: "openai",
name: "GPT Old",
capabilities: { tools: false, input: ["text"], output: ["text"] },
variants: [],
time: { released: 0 },
cost: [],
status: "deprecated",
enabled: true,
limit: { context: 1, output: 1 },
},
] as ModelListOutput["data"],
)
const providers: ProviderListOutput["data"] = [
{ id: "openai", name: "OpenAI", activation: "auto", package: "@ai-sdk/openai" },
]
const models: ModelListOutput["data"] = [
{
id: "gpt-5",
modelID: "gpt-5",
providerID: "openai",
name: "GPT-5",
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
variants: [{ id: "high" }],
time: { released: 1 },
cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }],
status: "active",
enabled: true,
limit: { context: 128_000, output: 8_192 },
},
{
id: "gpt-old",
modelID: "gpt-old",
providerID: "openai",
name: "GPT Old",
capabilities: { tools: false, input: ["text"], output: ["text"] },
variants: [],
time: { released: 0 },
cost: [],
status: "deprecated",
enabled: true,
limit: { context: 1, output: 1 },
},
]
const result = normalizeProviderList(providers, models)
expect(result.connected).toEqual(["openai"])
expect(result.default).toEqual({ openai: "gpt-5" })
@@ -78,19 +83,58 @@ describe("normalizeProviderList", () => {
expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({
id: "gpt-5",
providerID: "openai",
capabilities: { toolcall: true, attachment: true },
capabilities: { toolcall: true, attachment: true, reasoning: true },
cost: { input: 1, output: 2 },
variants: { high: {} },
})
})
test.each([
["effort variant", [{ id: "low" }], undefined, true],
["toggle variant", [{ id: "none" }, { id: "thinking" }], undefined, true],
["reasoningField without variants", [], { reasoningField: "reasoning_content" }, true],
["no variants or reasoningField", [], undefined, false],
["disabled-only none variant", [{ id: "none" }], undefined, false],
] as const)("derives reasoning capability from %s", (_, variants, compatibility, expected) => {
const providers: ProviderListOutput["data"] = [
{ id: "opencode", name: "OpenCode", activation: "auto", package: "@opencode/ai/providers/openai-compatible" },
]
const models: ModelListOutput["data"] = [
{
id: "model",
modelID: "model",
providerID: "opencode",
name: "Model",
compatibility,
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [...variants],
time: { released: 1 },
cost: [],
status: "active",
enabled: true,
limit: { context: 128_000, output: 8_192 },
},
]
const result = normalizeProviderList(providers, models)
expect(result.all.get("opencode")?.models.model?.capabilities.reasoning).toBe(expected)
})
})
describe("normalizeProjectInfo", () => {
test("keeps the project VCS backend", () => {
const project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1, active: 1 }, sandboxes: [] }
expect(normalizeProjectInfo({ ...project, vcs: "git" } as Project).vcs).toBe("git")
expect(normalizeProjectInfo({ ...project, vcs: "hg" } as Project).vcs).toBe("hg")
expect(normalizeProjectInfo(project as Project).vcs).toBeUndefined()
const project: Project = {
id: "prj",
canonical: "/repo",
time: { created: 1, updated: 1, active: 1 },
sandboxes: [],
}
expect(normalizeProjectInfo({ ...project, vcs: "git" }).vcs).toBe("git")
expect(normalizeProjectInfo({ ...project, vcs: "hg" }).vcs).toBe("hg")
expect(normalizeProjectInfo(project).vcs).toBeUndefined()
})
})
@@ -10,17 +10,22 @@ const providerCatalogs = new WeakMap<
WeakMap<ModelListOutput["data"], ProviderListResponse>
>()
export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] {
if (input.every((agent) => !("request" in agent))) return input as Agent[]
function isLegacyAgentList(input: AgentListOutput["data"] | Agent[]): input is Agent[] {
return input.every((agent) => !("request" in agent))
}
return (input as AgentListOutput["data"]).map((agent) => ({
export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] {
if (isLegacyAgentList(input)) return input
return input.map((agent) => ({
name: agent.id,
description: agent.description,
mode: agent.mode,
hidden: agent.hidden,
temperature:
typeof agent.request.settings.temperature === "number" ? agent.request.settings.temperature : undefined,
topP: typeof agent.request.settings.topP === "number" ? agent.request.settings.topP : undefined,
temperature: Number.isFinite(agent.request.settings.temperature)
? Number(agent.request.settings.temperature)
: undefined,
topP: Number.isFinite(agent.request.settings.topP) ? Number(agent.request.settings.topP) : undefined,
color: agent.color,
permission: agent.permissions.map((rule) => ({
permission: rule.action,
@@ -79,7 +84,11 @@ export function normalizeProviderList(
family: model.family,
capabilities: {
temperature: false,
reasoning: false,
// Always-on reasoning models may expose no selectable variants yet still stream reasoning via reasoningField;
// a disabled-only `none` variant does not count as reasoning support.
reasoning:
model.compatibility?.reasoningField !== undefined ||
model.variants.some((variant) => variant.id !== "none"),
attachment: model.capabilities.input.some((item) => item !== "text"),
toolcall: model.capabilities.tools,
input: {