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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user