Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/gateway/src/chat-helpers.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,16 @@ export const reasoningModels = testModels.filter((m) =>
m.providers.some((p: ProviderModelMapping) => p.reasoning === true),
);

export const thinkingDisabledForcedToolChoiceModels = testModels.filter((m) =>
m.providers.some(
(p: ProviderModelMapping) =>
p.reasoningEfforts?.includes("none") &&
p.supportedToolChoicesWithThinkingDisabled?.some(
(mode) => mode === "required" || mode === "function",
),
),
);

// Efforts are forwarded to providers as-is and rejected when unsupported, so
// tests must send an effort the mapping declares. Prefers "medium" and falls
// back to the strongest declared tier.
Expand Down
74 changes: 74 additions & 0 deletions apps/gateway/src/chat-toolcalls.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getTestOptions,
logMode,
streamingToolCallModels,
thinkingDisabledForcedToolChoiceModels,
toolCallModels,
validateLogByRequestId,
} from "@/chat-helpers.e2e.js";
Expand Down Expand Up @@ -132,6 +133,79 @@ describe("e2e", getConcurrentTestOptions(), () => {
},
);

test.each(
thinkingDisabledForcedToolChoiceModels.flatMap(({ model }) => [
{
model,
toolChoice: "required" as const,
toolChoiceLabel: "required",
},
{
model,
toolChoice: {
type: "function" as const,
function: { name: "get_weather" },
},
toolChoiceLabel: "named function",
},
]),
)(
"thinking disabled supports $toolChoiceLabel tool choice $model",
getTestOptions(),
async ({ model, toolChoice }) => {
const res = await app.request("/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-no-fallback": "true",
Authorization: `Bearer real-token`,
},
body: JSON.stringify({
model,
messages: [
{
role: "user",
content: "What's the weather like in San Francisco?",
},
],
tools: [
{
type: "function",
function: {
name: "get_weather",
description: "Get the current weather for a given city",
parameters: {
type: "object",
properties: {
city: { type: "string" },
},
required: ["city"],
},
},
},
],
tool_choice: toolChoice,
reasoning_effort: "none",
}),
});

const json = await res.json();
if (logMode || res.status !== 200) {
console.log(
"thinking-disabled tool choice response:",
JSON.stringify(json, null, 2),
);
}

expect(res.status).toBe(200);
expect(json.choices[0].message.tool_calls[0]).toMatchObject({
type: "function",
function: { name: "get_weather" },
});
expect(json.choices[0].finish_reason).toBe("tool_calls");
},
);

test.each(streamingToolCallModels)(
"streaming tool calls $model",
getTestOptions(),
Expand Down
34 changes: 31 additions & 3 deletions apps/gateway/src/chat/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ import {
type ProviderRequestBody,
providers,
resolveVertexTokenType,
type ToolChoiceType,
type VertexTokenType,
type WebSearchTool,
expandAllProviderRegions,
Expand Down Expand Up @@ -269,6 +270,7 @@ import {
exclusionReason,
getProviderFilterReasons,
mergeFilteredProvider,
preferToolChoiceCapableProviders,
recordFilteredProvider,
} from "./tools/provider-filter-reasons.js";
import {
Expand Down Expand Up @@ -692,14 +694,15 @@ function filterEligibleModelProviders(
audioFormats?: string[];
hasDocuments: boolean;
hasAssistantPrefill?: boolean;
toolChoice?: ToolChoiceType;
maxTokens?: number;
reasoningEffort?: string;
n?: number;
stream?: boolean;
},
filteredOut?: Array<{ providerId: string; reasons: string[] }>,
filteredOut?: FilteredProvider[],
): ProviderModelMapping[] {
return availableModelProviders.filter((provider) => {
const eligible = availableModelProviders.filter((provider) => {
if (
options.availableProviders &&
!options.availableProviders.includes(provider.providerId)
Expand Down Expand Up @@ -753,6 +756,12 @@ function filterEligibleModelProviders(

return true;
});

// The model is pinned here, so an unsupported tool_choice only narrows the
// candidates when another mapping of the same model can honour it — dropping
// the last candidate would fail a request that works today (downgraded to
// "auto" by prepareRequestBody).
return preferToolChoiceCapableProviders(eligible, options, filteredOut);
}

interface ContentFilterRoutingDecision {
Expand Down Expand Up @@ -3627,6 +3636,11 @@ chat.openapi(completions, async (c) => {
// array; an empty tools list must not require function-tool support.
hasTools:
(tools !== undefined && tools.length > 0) || forcesFunctionTools,
// Auto routing can pick another model entirely, so a mapping that would
// have to downgrade the requested tool_choice is dropped outright rather
// than merely deprioritized.
toolChoice: tool_choice,
strictToolChoice: !dynamicRouteSelection,
reasoningEffort: reasoning_effort,
reasoningMaxTokens: reasoning_max_tokens,
noReasoning: no_reasoning,
Expand Down Expand Up @@ -3890,9 +3904,19 @@ chat.openapi(completions, async (c) => {
return true;
},
);
// A dynamic route has already fixed the model, so treat its providers like
// pinned-model candidates: prefer mappings that honour tool_choice, but keep
// the downgrade-to-auto fallback when none do.
const toolChoiceSuitableProviders = dynamicRouteSelection
? preferToolChoiceCapableProviders(
suitableProviders,
autoFilterOpts,
filteredOutForModel,
)
: suitableProviders;
const deduplicatedSuitableProviders =
await keepCheapestCustomRoutingMapping(
suitableProviders,
toolChoiceSuitableProviders,
modelDef.id,
project.organizationId,
providerDiscountResolver,
Expand Down Expand Up @@ -4237,6 +4261,7 @@ chat.openapi(completions, async (c) => {
audioFormats,
hasDocuments,
hasAssistantPrefill,
toolChoice: tool_choice,
maxTokens: max_tokens,
reasoningEffort: reasoning_effort,
n,
Expand Down Expand Up @@ -4620,6 +4645,7 @@ chat.openapi(completions, async (c) => {
audioFormats,
hasDocuments,
hasAssistantPrefill,
toolChoice: tool_choice,
maxTokens: max_tokens,
reasoningEffort: reasoning_effort,
n,
Expand Down Expand Up @@ -4864,6 +4890,7 @@ chat.openapi(completions, async (c) => {
audioFormats,
hasDocuments,
hasAssistantPrefill,
toolChoice: tool_choice,
maxTokens: max_tokens,
reasoningEffort: reasoning_effort,
};
Expand Down Expand Up @@ -5230,6 +5257,7 @@ chat.openapi(completions, async (c) => {
audioFormats,
hasDocuments,
hasAssistantPrefill,
toolChoice: tool_choice,
maxTokens: max_tokens,
reasoningEffort: reasoning_effort,
n,
Expand Down
Loading
Loading