diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index f94c345fe..a51042f52 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -106,6 +106,7 @@ import { resolveOpenAICompletionsOutputClamp, resolveOpenAIOutputTokenParam, resolveOpenAIRequestSetup, + shouldDropAutoToolChoiceForReasoning, shouldRetryWithoutStrictTools, } from "./openai-shared"; import { transformMessages } from "./transform-messages"; @@ -1692,6 +1693,10 @@ function buildParams( delete params.tool_choice; } + if (shouldDropAutoToolChoiceForReasoning(model, initialCompat, params.tool_choice, options)) { + delete params.tool_choice; + } + const finalPolicy = resolveOpenAICompatPolicy(model, { endpoint: "chat-completions", reasoning: options?.reasoning, diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 873f3b2ec..99b43c59d 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -99,6 +99,7 @@ import { resolveOpenAIOutputTokenParam, resolveOpenAIRequestSetup, resolveOpenAIResponsesOutputClamp, + shouldDropAutoToolChoiceForReasoning, shouldRetryWithoutStrictTools, } from "./openai-shared"; @@ -1276,6 +1277,10 @@ export function buildParams( } } + if (shouldDropAutoToolChoiceForReasoning(model, model.compat, params.tool_choice, options)) { + delete params.tool_choice; + } + const reasoningPolicy = resolveOpenAICompatPolicy(model, { endpoint: "responses", reasoning: options?.reasoning, diff --git a/packages/ai/src/providers/openai-shared.ts b/packages/ai/src/providers/openai-shared.ts index 5452e1156..590f805b9 100644 --- a/packages/ai/src/providers/openai-shared.ts +++ b/packages/ai/src/providers/openai-shared.ts @@ -844,6 +844,29 @@ function isImplicitDisableWhenNotRequested(disableMode: OpenAIReasoningDisableMo ); } +/** + * Whether a redundant `tool_choice: "auto"` should be dropped to keep + * reasoning alive. Hosts with `disableReasoningOnToolChoice` (DeepSeek family + * on e.g. Fireworks) silently turn reasoning off whenever any `tool_choice` + * is present. "auto" is already the provider default, so omitting it is + * wire-neutral for tool selection; forced and "none" choices are semantic and + * still win over reasoning (#1207). + */ +export function shouldDropAutoToolChoiceForReasoning( + model: Pick, + compat: { disableReasoningOnToolChoice: boolean }, + toolChoice: unknown, + options: { reasoning?: string; disableReasoning?: boolean } | undefined, +): boolean { + return ( + toolChoice === "auto" && + compat.disableReasoningOnToolChoice && + Boolean(model.reasoning) && + options?.reasoning !== undefined && + !options.disableReasoning + ); +} + export function resolveOpenAICompatPolicy( model: Model, options: ResolveOpenAICompatPolicyOptions, diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts index defa14816..bbd42fe36 100644 --- a/packages/ai/test/openai-completions-compat.test.ts +++ b/packages/ai/test/openai-completions-compat.test.ts @@ -1792,7 +1792,9 @@ describe("kimi model detection via detectCompat", () => { // Dropping reasoning_effort does not turn off the gateway's default thinking // mode, so the compat descriptor itself must mark forced tool choice // unsupported (no per-model override) and buildParams must downgrade the - // selector to "auto" while keeping the tool advertised. + // selector while keeping the tool advertised. The downgraded "auto" is then + // dropped as redundant so reasoning survives (#1207) — omission and "auto" + // are wire-equivalent for tool selection. it("scopes the DeepSeek forced tool_choice downgrade to OpenCode gateways", async () => { const todoTool: Tool = { name: "todo", @@ -1839,7 +1841,7 @@ describe("kimi model detection via detectCompat", () => { const openCode = buildModel(deepseekSpec); expect(openCode.compat.supportsForcedToolChoice).toBe(false); const openCodePayload = await captureToolChoice(openCode); - expect(openCodePayload.tool_choice).toBe("auto"); + expect(openCodePayload.tool_choice).toBeUndefined(); expect( Array.isArray(openCodePayload.tools) && openCodePayload.tools.some(tool => getNestedObject(tool, "function")?.name === "todo"), @@ -1853,7 +1855,7 @@ describe("kimi model detection via detectCompat", () => { } satisfies ModelSpec<"openai-completions">); expect(customOpenCode.compat.supportsForcedToolChoice).toBe(false); const customPayload = await captureToolChoice(customOpenCode); - expect(customPayload.tool_choice).toBe("auto"); + expect(customPayload.tool_choice).toBeUndefined(); const nvidia = buildModel({ ...deepseekSpec, diff --git a/packages/ai/test/openai-completions-reasoning-disable-dialects.test.ts b/packages/ai/test/openai-completions-reasoning-disable-dialects.test.ts index 722d69f26..d23cdb54f 100644 --- a/packages/ai/test/openai-completions-reasoning-disable-dialects.test.ts +++ b/packages/ai/test/openai-completions-reasoning-disable-dialects.test.ts @@ -121,9 +121,11 @@ describe("Chat Completions reasoning-disable conflict policy (per dialect)", () }); it("applies the same per-dialect disable on the non-forced disableReasoningOnToolChoice path", async () => { - // Non-forced branch (any tool_choice) shares the dialect-aware helper: - // an OpenRouter reasoning model with disableReasoningOnToolChoice must emit + // Non-forced branch shares the dialect-aware helper: an OpenRouter + // reasoning model with disableReasoningOnToolChoice must emit // reasoning={enabled:false} rather than leaving the effort object in place. + // A semantic "none" selector exercises the path; a bare "auto" no longer + // does — it is dropped as redundant so reasoning survives (#1207). const model = reasoningDialectModel("openrouter", { disableReasoningOnForcedToolChoice: false, disableReasoningOnToolChoice: true, @@ -134,11 +136,11 @@ describe("Chat Completions reasoning-disable conflict policy (per dialect)", () fetch: createMockFetch(), signal: createAbortedSignal(), reasoning: "high", - toolChoice: "auto", + toolChoice: "none", onPayload: payload => resolve(payload), }); const payload = (await promise) as Record; - expect(payload.tool_choice).toBe("auto"); + expect(payload.tool_choice).toBe("none"); expect(payload.reasoning).toEqual({ enabled: false }); }); }); diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 5a9a3b0e4..31ccd6159 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -108,7 +108,7 @@ export { import { ModelsConfigFile, type ProviderValidationModel, validateProviderConfiguration } from "./models-config"; import type { ModelOverride, ModelsConfig, ProviderAuthMode } from "./models-config-schema"; -import { settings, type Settings } from "./settings"; +import { type Settings, settings } from "./settings"; // DeviceCheck attestation (`x-oai-attestation`) for ChatGPT-OAuth Codex // requests; the pi-ai provider resolves it just-in-time per request. diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index a91ef4f76..ac42b7aca 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -1251,9 +1251,13 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro // / session would silently miss credential_disabled events. const modelRegistry = options.modelRegistry ?? - new ModelRegistry(options.authStorage ?? (await logger.time("discoverModels", discoverAuthStorage, agentDir)), undefined, { - settings, - }); + new ModelRegistry( + options.authStorage ?? (await logger.time("discoverModels", discoverAuthStorage, agentDir)), + undefined, + { + settings, + }, + ); // Track whether we internally created the authStorage so we can close it // if construction fails before the session takes ownership. const ownsAuthStorage = !options.authStorage && !options.modelRegistry;