fix(core): keep a stored model entry that says nothing about reasoning able to reason

A stored model entry with only boolean overlays (what a settings UI's
Supports Images tick writes) produced a capability list without
"reasoning". The gateway reads such a list as "cannot reason" and
drops an explicit reasoning disable, so a default-on reasoning model
keeps thinking and rejects tool calls on chat completions. Seed
"reasoning" into partial overlay lists the way "tools" is seeded,
unless the entry denies it with supportsReasoning: false. Mirror the rule
in the VS Code host's rebuild of legacy boolean snapshots.
This commit is contained in:
Mikołaj Kondratek
2026-10-20 15:33:00 +02:00
parent 0870796e9e
commit a9f36216cb
4 changed files with 122 additions and 11 deletions
@@ -712,6 +712,48 @@ describe("buildSessionConfig", () => {
expect(onDisk.providers["openai-compatible"].settings.azure).toEqual({ apiVersion: "2025-01-01-preview" })
})
it("keeps an OpenAI Compatible model with saved overrides able to reason, so a disable reaches the wire", async () => {
// A Model Configuration edit (e.g. the Supports Images tick) writes a
// stored entry that says nothing about reasoning. The registry used
// to turn that into a capability list without "reasoning", and the
// gateway then dropped the user's "None": the model applied its
// default effort and rejected tool calls. Goes through the real
// ProviderSettingsManager and the real registry, like production.
const { ProviderSettingsManager } = await import("../../node_modules/@cline/core/dist/index.js")
const realManager = new ProviderSettingsManager({
filePath: path.join(tempDir, "settings", "providers.json"),
})
realManager.saveProviderSettings(
{
provider: "openai-compatible",
apiKey: "azure-key",
model: "gpt-6-sol",
baseUrl: "https://example.openai.azure.com/openai/v1",
reasoning: { enabled: false },
},
{ setLastUsed: false },
)
mocks.getProviderSettingsManager.mockReturnValue(realManager as never)
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "openai",
actModeOpenAiModelId: "gpt-6-sol",
openAiApiKey: "azure-key",
openAiBaseUrl: "https://example.openai.azure.com/openai/v1",
} as any)
const providerId = parseProviderId("openai")
createProviderConfigStore().commitSelection(providerId, "act", {
providerId,
modelId: "gpt-6-sol",
overrides: { supportsVision: true },
})
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
const knownModel = (config.providerConfig as any).knownModels["gpt-6-sol"]
expect(config.thinking).toBe(false)
expect(knownModel.capabilities).toEqual(expect.arrayContaining(["images", "reasoning", "tools"]))
})
it("does not rewrite providers.json when the stored Azure settings already match", () => {
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId?: string) => {
if (providerId !== "openai-compatible") {
@@ -1152,6 +1194,38 @@ describe("buildSessionConfig", () => {
expect(knownModel.modalities).toEqual({ input: ["text", "image"], output: ["text", "image"] })
})
it.each([
["says nothing about reasoning", {}, true],
["denies reasoning explicitly", { supportsReasoning: false }, false],
])("treats a dynamic-list model that %s accordingly", async (_label, reasoningFlag, expectReasoning) => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "openrouter",
actModeOpenRouterModelId: "mock/custom-model",
openRouterApiKey: "openrouter-key",
// Legacy boolean snapshot with no SDK capability list. The
// rebuilt list must not deny reasoning the snapshot never
// mentioned: the gateway would then drop an explicit reasoning
// disable, and a default-on reasoning model keeps thinking.
actModeOpenRouterModelInfo: {
name: "Mock Custom Model",
contextWindow: 16_000,
supportsImages: true,
supportsPromptCache: false,
...reasoningFlag,
},
} as any)
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
const knownModel = (config.providerConfig as any).knownModels["mock/custom-model"]
expect(knownModel.capabilities).toEqual(expect.arrayContaining(["images", "tools"]))
if (expectReasoning) {
expect(knownModel.capabilities).toContain("reasoning")
} else {
expect(knownModel.capabilities).not.toContain("reasoning")
}
})
it("defaults tool-calling on when the preserved capability list is defined but empty", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "openrouter",
@@ -322,6 +322,12 @@ function toSdkModelInfo(selection: ResolvedModelSelection): SdkModelInfo {
// silently disabling tool calling at the runtime gate (#13463).
const supportsTools = (modelInfo as { supportsTools?: boolean }).supportsTools
setCapability("tools", supportsTools !== false)
// Same for reasoning: a rebuilt list that says nothing about it must
// not read as "cannot reason", or the SDK gateway drops an explicit
// reasoning disable and a default-on reasoning model keeps thinking
// (and rejects tool calls). An explicit supportsReasoning: false above
// is the only way to deny it.
if (modelInfo.supportsReasoning === undefined) setCapability("reasoning", true)
}
const maxTokens = positiveFiniteNumber(modelInfo.maxTokens)
@@ -373,22 +373,32 @@ function toStoredModelInfo(
else capabilities.delete("reasoning");
}
// An unspecified capability list fails open for tool calling
// (modelSupportsToolCalling), but any populated list is treated as
// (modelSupportsToolCalling) and for reasoning (the gateway's portable
// reasoning disable), but any populated list is treated as
// authoritative — a partial one without "tools" silently revokes every
// tool definition (#13463). Stored entries and user-authored provider
// metadata cannot declare "cannot call tools" (there is no supportsTools
// field, and no writer intentionally omits "tools"): their lists are
// partial overlays, not authoritative catalogs. Seed "tools" into any
// non-empty list for a language model unless the list is anchored on
// generated catalog metadata, which IS authoritative (a genuine no-tools
// catalog model must stay that way). An empty set stays absent so every
// gate keeps its own fail-open default.
// tool definition (#13463), and one without "reasoning" drops the user's
// explicit reasoning disable, so a default-on reasoning model keeps
// thinking and rejects tool calls. Stored entries and user-authored
// provider metadata cannot declare "cannot call tools" (there is no
// supportsTools field, and no writer intentionally omits "tools"), and a
// stored entry that says nothing about reasoning (no supportsReasoning;
// a settings UI's Supports Images tick writes exactly this shape) does
// not deny it either: their lists are partial overlays, not
// authoritative catalogs. Seed "tools", and "reasoning" unless the entry
// explicitly turns reasoning off, into any non-empty list for a language
// model unless the list is anchored on generated catalog metadata, which
// IS authoritative (a genuine no-tools or no-reasoning catalog model must
// stay that way). An empty set stays absent so every gate keeps its own
// fail-open default.
if (
!capabilitiesAreAuthoritative &&
capabilities.size > 0 &&
(model?.operation === undefined || model.operation === "language")
) {
capabilities.add("tools");
if (model?.supportsReasoning === undefined) {
capabilities.add("reasoning");
}
}
const apiFormat = model?.apiFormat;
@@ -300,7 +300,7 @@ describe("models registry parsing", () => {
expect(model).not.toHaveProperty("temperature");
});
it("seeds tool calling when capabilities are synthesized purely from boolean flags", async () => {
it("seeds tool calling and reasoning when capabilities are synthesized purely from boolean flags", async () => {
const parsed = parseModelsFile({
version: 1,
providers: {
@@ -335,6 +335,19 @@ describe("models registry parsing", () => {
operation: "image-generation",
capabilities: ["images"],
},
// Only a vision flag (what a settings UI's Supports Images
// tick writes): the synthesized list must not read as "this
// model cannot reason", or the gateway drops an explicit
// reasoning disable and a default-on reasoning model keeps
// thinking (and rejects tool calls).
"vision-only": {
supportsVision: true,
},
// An explicit reasoning denial must survive the seeding.
"no-reasoning": {
supportsVision: true,
supportsReasoning: false,
},
},
},
},
@@ -355,9 +368,17 @@ describe("models registry parsing", () => {
);
expect(models.bare).not.toHaveProperty("capabilities");
expect(models["partial-list"]?.capabilities).toEqual(
expect.arrayContaining(["prompt-cache", "tools"]),
expect.arrayContaining(["prompt-cache", "reasoning", "tools"]),
);
expect(models["image-gen"]?.capabilities).not.toContain("tools");
expect(models["image-gen"]?.capabilities).not.toContain("reasoning");
expect(models["vision-only"]?.capabilities).toEqual(
expect.arrayContaining(["images", "reasoning", "tools"]),
);
expect(models["no-reasoning"]?.capabilities).toEqual(
expect.arrayContaining(["images", "tools"]),
);
expect(models["no-reasoning"]?.capabilities).not.toContain("reasoning");
});
it("keeps generated tool support when stale OpenCode Go metadata shadows a catalog model", async () => {