From c5f44e47ca2f1a2b281e5a03a68002ce26100961 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 20:23:14 +0900 Subject: [PATCH 1/2] fix(combo): skip Meta for mandatory tool choices --- docs-site/src/content/docs/guides/combos.md | 4 + .../responses/combo-required-tool-contract.ts | 21 +++++ src/server/responses/core-combo.ts | 24 ++++++ .../transports/responses-combo-boundaries.md | 6 ++ ...erver-combo-required-tool-contract.test.ts | 83 +++++++++++++++++++ 5 files changed, 138 insertions(+) diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 57dc9ccb196..a0b9de7c718 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -152,6 +152,10 @@ native routing and catalog identity on the next sync. ### Failover: ordered primary and backups +For Responses requests that require a tool, Meta Responses targets are skipped before sending +because Meta does not support mandatory `tool_choice`. An eligible later target receives the +original choice. If every target is incompatible for this reason, the combo reports the constraint. + `failover` selects the first eligible target in configuration order. A target is eligible when its provider exists, is enabled, is not cooling down, and can handle any special request constraint. Weights and `stickyLimit` do not affect this strategy. diff --git a/src/server/responses/combo-required-tool-contract.ts b/src/server/responses/combo-required-tool-contract.ts index 1e23e46873e..da03aecf772 100644 --- a/src/server/responses/combo-required-tool-contract.ts +++ b/src/server/responses/combo-required-tool-contract.ts @@ -10,6 +10,27 @@ export type RequiredToolContract = | { kind: "any"; hostedCallTypes: ReadonlySet } | { kind: "named"; name: string }; +/** Only definite, well-formed mandatory selectors justify excluding a destination. */ +export function hasMandatoryToolChoice(rawBody: unknown): boolean { + if (!rawBody || typeof rawBody !== "object" || Array.isArray(rawBody)) return false; + const choice = (rawBody as Record).tool_choice; + if (choice === "required") return true; + if (!choice || typeof choice !== "object" || Array.isArray(choice)) return false; + const selector = choice as Record; + if (selector.type === "function" || selector.type === "custom") { + return typeof selector.name === "string" && selector.name.trim().length > 0; + } + return selector.type === "allowed_tools" && selector.mode === "required" + && Array.isArray(selector.tools) && selector.tools.length > 0 + && selector.tools.every((tool: unknown) => { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false; + const entry = tool as Record; + return typeof entry.type === "string" && entry.type.trim().length > 0 + && (entry.name === undefined || (typeof entry.name === "string" && entry.name.trim().length > 0)) + && (entry.type !== "function" && entry.type !== "custom" || typeof entry.name === "string"); + }); +} + const hostedCalls = new Map([ ["web_search", "web_search_call"], ["web_search_preview", "web_search_call"], ["file_search", "file_search_call"], ["code_interpreter", "code_interpreter_call"], diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 3b8d0fde10c..063c5dec4b7 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -1,4 +1,7 @@ import { effectiveAzureOutputBudget } from "../../adapters/azure"; +import { buildResponsesTransport } from "../../adapters/openai-responses/transport"; +import { isMetaAiResponsesDestination } from "../../responses/muse-tool-name-alias"; +import { hasMandatoryToolChoice } from "./combo-required-tool-contract"; import { isCodexAccountCooldownResponse } from "../../codex/account-cooldown-response"; import { resolveWireProtocolOverride } from "../adapter-resolve"; import { comboCooldownRetryAfterSeconds, isDefiniteContextOverflow, type CodexAccountModelRefusal } from "../../combos/failover"; @@ -533,10 +536,25 @@ export async function executeComboResponses( const payloadEligible = (target: (typeof combo.targets)[number]): boolean => comboPayloadReadable || !unreadableEncryptedAgentTask || canDecryptUnreadableAgentTask(target); const meteredAllowed = admissionMayUseMeteredComboTargets(config, options.admission, combo); + const museToolEligible = (target: (typeof combo.targets)[number]): boolean => { + if (!hasMandatoryToolChoice(body)) return true; + try { + const route = routeConcreteModel(config, `${target.provider}/${target.model}`); + return route.provider.adapter !== "openai-responses" + || !isMetaAiResponsesDestination(buildResponsesTransport(route.provider, { headers: req.headers }).url); + } catch { + return true; + } + }; + const onlyMuseToolIncompatible = (): boolean => { + const enabled = combo.targets.filter(target => config.providers[target.provider]?.disabled !== true); + return enabled.length > 0 && enabled.every(target => !museToolEligible(target)); + }; // Recovery can replace unreadable encrypted input, but cannot relax other admission checks. const recoveryEligible = (target: (typeof combo.targets)[number]): boolean => (meteredAllowed || !target.metered) && (combo.strategy !== "jev" || target.provider !== JEV_PROVIDER_ID) + && museToolEligible(target) && reasoningReplayEligible(target) && (protocolLanes?.pickable(target) ?? true); const targetEligible = (target: (typeof combo.targets)[number]): boolean => @@ -651,6 +669,9 @@ export async function executeComboResponses( config, { parentThreadId: inboundClientThreadId }, ); + if (!options.abortSignal?.aborted && onlyMuseToolIncompatible()) { + return comboUnavailableResponse(`No available targets for combo: ${comboId}; Meta Responses does not support mandatory tool_choice`); + } return options.abortSignal?.aborted ? clientCancelledResponse() : comboUnavailable(comboId, undefined, @@ -668,6 +689,9 @@ export async function executeComboResponses( const protocolRefusal = protocolLanes?.refusal(); if (protocolRefusal) return protocolRefusal; if (onlyReplayIncompatibleTargetsRemain()) return targetIncompatibleResponse(); + if (!options.abortSignal?.aborted && onlyMuseToolIncompatible()) { + return comboUnavailableResponse(`No available targets for combo: ${comboId}; Meta Responses does not support mandatory tool_choice`); + } return options.abortSignal?.aborted ? clientCancelledResponse() : comboUnavailable(comboId, undefined, comboExclusionReasons(config, comboId, targetEligible)); diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index a3a4d8f0e57..e71085c094b 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -20,6 +20,12 @@ and `tests/responses/responses-combo-exhausted-error.test.ts`. ## Buffered required-tool contract +Before selection, `src/server/responses/core-combo.ts` excludes resolved Meta Responses destinations +for well-formed mandatory `tool_choice` selectors, including required allowed-tools lists. Normal and +recovery picks share the check; malformed selectors retain their ordinary error path. The next target +receives the original selector, and an all-Meta exhaustion names the constraint before any send. +Coverage: `tests/server/server-combo-required-tool-contract.test.ts`. + Before combo success bookkeeping, native delivery in `src/server/responses/passthrough-delivery.ts` uses `src/server/responses/combo-required-tool-contract.ts` to check successful native `openai-responses` JSON children with explicit required or named function/custom choices. diff --git a/tests/server/server-combo-required-tool-contract.test.ts b/tests/server/server-combo-required-tool-contract.test.ts index 517aa17de62..9f57858fd41 100644 --- a/tests/server/server-combo-required-tool-contract.test.ts +++ b/tests/server/server-combo-required-tool-contract.test.ts @@ -164,6 +164,89 @@ function requiredRequest(extra: Record = {}): Request { } describe("combo required-tool contract on buffered children (#7456)", () => { + for (const choice of ["required", { type: "function", name: "shell" }, + { type: "allowed_tools", mode: "required", tools: [{ type: "function", name: "shell" }] }]) { + test(`Meta mandatory tool choice selects the next target before send (#7964): ${JSON.stringify(choice)}`, async () => { + const bodies: unknown[] = []; + let metaSends = 0; + const b = serve(async request => { + bodies.push(await request.json()); + return Response.json(completedWithCall("m2", "shell")); + }); + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-responses", "https://api.meta.ai/v1", "key-a", { fetch: (async () => { metaSends++; throw new Error("unexpected Meta send"); }) as typeof fetch }), + b: provider("openai-responses", baseUrl(b), "key-b"), + }), requiredRequest({ tool_choice: choice })); + expect(response.status).toBe(200); + expect(bodies).toHaveLength(1); + expect(metaSends).toBe(0); + expect(bodies[0]).toMatchObject({ tool_choice: choice }); + }); + } + for (const choice of ["auto", "none"]) { + test(`Meta accepts ${choice} without a hop`, async () => { + let sends = 0; + const meta = provider("openai-responses", "https://api.meta.ai/v1", "key-a", { + fetch: (async () => { sends++; return Response.json(completedNoCall("m1", "meta")); }) as typeof fetch, + }); + const response = await handleResponsesWith(comboConfig({ a: meta }), requiredRequest({ tool_choice: choice })); + expect(response.status).toBe(200); + expect(sends).toBe(1); + }); + } + test("malformed choice remains a local Meta 400", async () => { + let sends = 0; + const meta = provider("openai-responses", "https://api.meta.ai/v1", "key-a", { + fetch: (async () => { sends++; throw new Error("unexpected send"); }) as typeof fetch, + }); + const response = await handleResponsesWith(comboConfig({ a: meta }), requiredRequest({ tool_choice: { type: "function" } })); + expect(response.status).toBe(400); + expect(sends).toBe(0); + }); + test("selector eligibility recognizes custom choices and abstains on malformed allowed lists", async () => { + const { hasMandatoryToolChoice } = await import("../../src/server/responses/combo-required-tool-contract"); + expect(hasMandatoryToolChoice({ tool_choice: { type: "custom", name: "shell" } })).toBe(true); + for (const choice of [ + { type: "function", name: "" }, + { type: "allowed_tools", mode: "required", tools: [] }, + { type: "allowed_tools", mode: "required", tools: [{ type: "function" }] }, + { type: "allowed_tools", mode: "required", tools: [{ type: 5 }] }, + { type: "allowed_tools", mode: "auto", tools: [{ type: "function", name: "shell" }] }, + ]) expect(hasMandatoryToolChoice({ tool_choice: choice })).toBe(false); + }); + test("all Meta targets report the mandatory choice constraint", async () => { + const meta = provider("openai-responses", "https://api.meta.ai/v1", "key-a"); + const response = await handleResponsesWith(comboConfig({ a: meta, b: meta }), requiredRequest({ tool_choice: "required" })); + expect(response.status).toBe(503); + expect(await response.text()).toContain("mandatory tool_choice"); + }); + test("a non-Meta first target remains eligible for mandatory tools", async () => { + let sends = 0; + const upstream = serve(() => { sends++; return Response.json(completedWithCall("m1", "shell")); }); + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-responses", baseUrl(upstream), "key-a"), + b: provider("openai-responses", "https://api.meta.ai/v1", "key-b"), + }), requiredRequest({ tool_choice: "required" })); + expect(response.status).toBe(200); + expect(sends).toBe(1); + }); + test("streaming mandatory choice skips Meta before the first send", async () => { + const bodies: Record[] = []; + const upstream = serve(async request => { + bodies.push(await request.json() as Record); + const payload = completedWithCall("m2", "shell"); + return new Response(`data: ${JSON.stringify({ type: "response.completed", response: payload })}\n\n`, + { headers: { "content-type": "text/event-stream" } }); + }); + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-responses", "https://api.meta.ai/v1", "key-a"), + b: provider("openai-responses", baseUrl(upstream), "key-b"), + }), requiredRequest({ stream: true, tool_choice: "required" })); + expect(response.status).toBe(200); + await response.text(); + expect(bodies).toHaveLength(1); + expect(bodies[0]!.tool_choice).toBe("required"); + }); test("a folded-SSE contract failure settles reported usage without completion effects", async () => { let effects = 0; let settled: unknown; From e06f628fecbd01abab462dcde7cf8bfa31fdaa62 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 30 Oct 2026 20:33:17 +0900 Subject: [PATCH 2/2] fix(combo): resolve effective wire before Meta eligibility --- .../responses/combo-required-tool-contract.ts | 12 +++- src/server/responses/core-combo.ts | 10 +++- .../transports/responses-combo-boundaries.md | 3 +- ...erver-combo-required-tool-contract.test.ts | 58 +++++++++++++++++++ 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/src/server/responses/combo-required-tool-contract.ts b/src/server/responses/combo-required-tool-contract.ts index da03aecf772..2f5e16a22d5 100644 --- a/src/server/responses/combo-required-tool-contract.ts +++ b/src/server/responses/combo-required-tool-contract.ts @@ -10,6 +10,11 @@ export type RequiredToolContract = | { kind: "any"; hostedCallTypes: ReadonlySet } | { kind: "named"; name: string }; +const allowedHostedSelectors = new Set([ + "web_search", "web_search_preview", "file_search", "computer_use_preview", "computer_use", + "code_interpreter", "image_generation", "image_gen", "tool_search", +]); + /** Only definite, well-formed mandatory selectors justify excluding a destination. */ export function hasMandatoryToolChoice(rawBody: unknown): boolean { if (!rawBody || typeof rawBody !== "object" || Array.isArray(rawBody)) return false; @@ -25,9 +30,10 @@ export function hasMandatoryToolChoice(rawBody: unknown): boolean { && selector.tools.every((tool: unknown) => { if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false; const entry = tool as Record; - return typeof entry.type === "string" && entry.type.trim().length > 0 - && (entry.name === undefined || (typeof entry.name === "string" && entry.name.trim().length > 0)) - && (entry.type !== "function" && entry.type !== "custom" || typeof entry.name === "string"); + if (entry.name !== undefined && (typeof entry.name !== "string" || !entry.name.trim())) return false; + if (entry.type === "function" || entry.type === "custom") return typeof entry.name === "string"; + if (entry.type === "mcp") return typeof entry.server_label === "string" && !!entry.server_label.trim(); + return typeof entry.type === "string" && allowedHostedSelectors.has(entry.type); }); } diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 063c5dec4b7..f2de582343d 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -59,7 +59,7 @@ import { previousResponseProviderState, } from "../../responses/state"; import { hasUnreadableEncryptedAgentTask } from "./encrypted-payload"; -import { routeConcreteModel, comboRouteDecisionTrace } from "../../router"; +import { captureRouteStaticPolicy, routeConcreteModel, comboRouteDecisionTrace } from "../../router"; import { memoryModelRouteReason } from "./memory-models"; import { baseProviderLabel, poolAccountProviderLabel } from "../../providers/label"; import { getAccountSet } from "../../oauth/store"; @@ -540,8 +540,12 @@ export async function executeComboResponses( if (!hasMandatoryToolChoice(body)) return true; try { const route = routeConcreteModel(config, `${target.provider}/${target.model}`); - return route.provider.adapter !== "openai-responses" - || !isMetaAiResponsesDestination(buildResponsesTransport(route.provider, { headers: req.headers }).url); + const inboundWire = options.inboundWire ?? "responses"; + const policy = captureRouteStaticPolicy(route.providerName, route.modelId, route.provider, + route.staticPolicy.effectiveAlias, inboundWire); + const provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, policy); + return provider.adapter !== "openai-responses" + || !isMetaAiResponsesDestination(buildResponsesTransport(provider, { headers: req.headers }).url); } catch { return true; } diff --git a/structure/transports/responses-combo-boundaries.md b/structure/transports/responses-combo-boundaries.md index e71085c094b..1c057789d0a 100644 --- a/structure/transports/responses-combo-boundaries.md +++ b/structure/transports/responses-combo-boundaries.md @@ -22,7 +22,8 @@ and `tests/responses/responses-combo-exhausted-error.test.ts`. Before selection, `src/server/responses/core-combo.ts` excludes resolved Meta Responses destinations for well-formed mandatory `tool_choice` selectors, including required allowed-tools lists. Normal and -recovery picks share the check; malformed selectors retain their ordinary error path. The next target +recovery picks share the check, resolving the inbound wire's static model policy and actual Responses +transport URL; malformed and unknown selectors retain their ordinary error path. The next target receives the original selector, and an all-Meta exhaustion names the constraint before any send. Coverage: `tests/server/server-combo-required-tool-contract.test.ts`. diff --git a/tests/server/server-combo-required-tool-contract.test.ts b/tests/server/server-combo-required-tool-contract.test.ts index 9f57858fd41..f25491239a2 100644 --- a/tests/server/server-combo-required-tool-contract.test.ts +++ b/tests/server/server-combo-required-tool-contract.test.ts @@ -21,6 +21,7 @@ import type { OcxConfig } from "../../src/types"; import { item as hostedImage } from "../fixtures/hosted-image-display"; import { createTestTranslatorBudget } from "../helpers/translator-budget"; import { deliverPassthroughResponse } from "../../src/server/responses/passthrough-delivery"; +import { encryptedInput } from "../helpers/agent-task-recovery"; /** * #7456: a Responses combo child that answers a `tool_choice`-required request with a buffered @@ -203,6 +204,63 @@ describe("combo required-tool contract on buffered children (#7456)", () => { expect(response.status).toBe(400); expect(sends).toBe(0); }); + test("#7982 chat configuration with Responses override skips Meta", async () => { + const bodies: unknown[] = []; + const next = serve(async request => { bodies.push(await request.json()); return Response.json(completedWithCall("m2", "shell")); }); + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-chat", "https://api.meta.ai/v1", "key-a", { modelAdapters: { m1: "openai-responses" } }), + b: provider("openai-responses", baseUrl(next), "key-b"), + }), requiredRequest({ tool_choice: "required" })); + expect(response.status).toBe(200); + expect(bodies).toHaveLength(1); + expect(bodies[0]).toMatchObject({ tool_choice: "required" }); + }); + test("#7982 Responses configuration with Chat override sends to Meta Chat", async () => { + let sends = 0; + const response = await handleResponsesWith(comboConfig({ + a: provider("openai-responses", "https://api.meta.ai/v1", "key-a", { + modelAdapters: { m1: "openai-chat" }, + fetch: (async () => { sends++; return Response.json({ id: "chat-1", object: "chat.completion", model: "m1", choices: [{ index: 0, finish_reason: "tool_calls", message: { role: "assistant", content: null, tool_calls: [{ id: "call_a", type: "function", function: { name: "shell", arguments: "{}" } }] } }] }); }) as typeof fetch, + }), + }), requiredRequest({ tool_choice: "required" })); + expect(response.status).toBe(200); + await response.text(); + expect(sends).toBe(1); + }); + test("#7982 unknown and malformed hosted selectors do not exclude Meta", async () => { + const { hasMandatoryToolChoice } = await import("../../src/server/responses/combo-required-tool-contract"); + for (const entry of [{ type: "not_a_tool" }, { type: "web_search", name: 5 }, { type: "mcp" }, { type: "mcp", server_label: 5 }]) { + const choice = { type: "allowed_tools", mode: "required", tools: [entry] }; + expect(hasMandatoryToolChoice({ tool_choice: choice })).toBe(false); + const response = await handleResponsesWith(comboConfig({ a: provider("openai-responses", "https://api.meta.ai/v1", "key-a") }), requiredRequest({ tool_choice: choice })); + expect(response.status).toBe(400); + } + for (const entry of [{ type: "web_search" }, { type: "mcp", server_label: "docs" }]) { + expect(hasMandatoryToolChoice({ tool_choice: { type: "allowed_tools", mode: "required", tools: [entry] } })).toBe(true); + } + }); + test("#7982 encrypted recovery selection cannot pick a Meta target", async () => { + const response = await handleResponsesWith(comboConfig({ a: provider("openai-responses", "https://api.meta.ai/v1", "key-a") }), + requiredRequest({ input: encryptedInput(), tool_choice: "required" })); + expect(response.status).toBe(503); + expect(await response.text()).toContain("mandatory tool_choice"); + }); + for (const [extra, expectedUrl] of [ + [{ responsesPath: "/other/responses" }, "https://api.meta.ai/other/responses"], + [{ authMode: "forward" as const, responsesPath: "/ignored" }, "https://api.meta.ai/responses"], + ] as const) { + test(`#7982 resolved Meta URL excludes mandatory choice: ${JSON.stringify(extra)}`, async () => { + const response = await handleResponsesWith(comboConfig({ a: provider("openai-responses", "https://api.meta.ai", "key-a", extra) }), requiredRequest({ tool_choice: "required" })); + expect(response.status).toBe(503); + expect(await response.text()).toContain("mandatory tool_choice"); + let sentUrl = ""; + const auto = await handleResponsesWith(comboConfig({ a: provider("openai-responses", "https://api.meta.ai", "key-a", { + ...extra, fetch: (async input => { sentUrl = String(input); return Response.json(completedNoCall("m1", "meta")); }) as typeof fetch, + }) }), requiredRequest({ tool_choice: "auto" })); + expect(auto.status).toBe(200); + expect(sentUrl).toBe(expectedUrl); + }); + } test("selector eligibility recognizes custom choices and abstains on malformed allowed lists", async () => { const { hasMandatoryToolChoice } = await import("../../src/server/responses/combo-required-tool-contract"); expect(hasMandatoryToolChoice({ tool_choice: { type: "custom", name: "shell" } })).toBe(true);