mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user