feat(ai/providers): dropped auto tool choice for reasoning models

- Add `shouldDropAutoToolChoiceForReasoning` helper to handle compatibility constraints for models where tool choice disables reasoning.
- Update OpenAI completions and responses parameter building to drop automatic tool choices when reasoning options are active.
This commit is contained in:
can1357
2026-08-20 08:00:57 +02:00
parent 57305b99cd
commit 7cb504cdb3
7 changed files with 52 additions and 11 deletions
@@ -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,
@@ -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,
@@ -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<Model, "reasoning">,
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<TApi extends Api>(
model: Model<TApi>,
options: ResolveOpenAICompatPolicyOptions,
@@ -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,
@@ -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<string, unknown>;
expect(payload.tool_choice).toBe("auto");
expect(payload.tool_choice).toBe("none");
expect(payload.reasoning).toEqual({ enabled: false });
});
});
@@ -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.
+7 -3
View File
@@ -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;