Repository navigation
fix(combo): reject buffered required-tool false successes #7741
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c5b84dd
597c6f3
0efc9c7
16e3b55
fbc0298
aae094b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| import { REQUIRED_TOOL_CONTRACT_CODE } from "../../lib/errors"; | ||
| import { applyReplayRefusalClientHeaders, isNonReplayableResponse, markResponseNonReplayable } from "../../lib/upstream-retry"; | ||
| import { clientWireOf } from "../inference/client-wire"; | ||
| import { isPreinspectedJsonResponse, markPreinspectedJsonResponse } from "../relay"; | ||
| import { isAuthorizedProviderExecutedCall } from "../responses-undeclared-tool-guard"; | ||
| import { carryResponseToolOwnership, responseToolOwnership } from "./response-tool-ownership"; | ||
|
|
||
| export type RequiredToolContract = | ||
| | { kind: "any"; hostedCallTypes: ReadonlySet<string> } | ||
| | { kind: "named"; 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"], | ||
| ["image_generation", "image_generation_call"], ["image_gen", "image_generation_call"], | ||
| ["mcp", "mcp_call"], ["computer_use", "computer_call"], | ||
| ["computer_use_preview", "computer_call"], | ||
| ]); | ||
|
|
||
| /** Unknown declarations abstain: their successful output spelling is not ours to guess. */ | ||
| export function requiredToolContractOf(rawBody: unknown): RequiredToolContract | undefined { | ||
| if (!rawBody || typeof rawBody !== "object" || Array.isArray(rawBody)) return; | ||
| const { tools, tool_choice: choice } = rawBody as Record<string, unknown>; | ||
| if (!Array.isArray(tools) || tools.length === 0) return; | ||
| if (choice === "required") { | ||
| const hostedCallTypes = new Set<string>(); | ||
| for (const tool of tools) { | ||
| if (!tool || typeof tool !== "object" || Array.isArray(tool)) return; | ||
| const type = (tool as Record<string, unknown>).type; | ||
| if (type === "function" || type === "custom") continue; | ||
| const callType = typeof type === "string" ? hostedCalls.get(type) : undefined; | ||
| if (!callType) return; | ||
| hostedCallTypes.add(callType); | ||
| } | ||
| return { kind: "any", hostedCallTypes }; | ||
| } | ||
| if (choice && typeof choice === "object" && !Array.isArray(choice)) { | ||
| const named = choice as Record<string, unknown>; | ||
| if ((named.type === "function" || named.type === "custom") | ||
| && typeof named.name === "string" && named.name.trim()) { | ||
| return { kind: "named", name: named.name }; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** Only a parsed completed output array can prove a missing required call. */ | ||
| export async function judgeBufferedRequiredTool(response: Response, requirement: RequiredToolContract): Promise<Response> { | ||
| const text = await response.text(); | ||
| let payload: Record<string, unknown> | undefined; | ||
| try { | ||
| const parsed: unknown = JSON.parse(text); | ||
| if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) payload = parsed as Record<string, unknown>; | ||
| } catch { /* An opaque body is not evidence of a required-tool violation. */ } | ||
| const output = payload?.output; | ||
| if (payload?.status === "completed" && Array.isArray(output)) { | ||
| const calls = output.filter((item): item is Record<string, unknown> => | ||
| !!item && typeof item === "object" && !Array.isArray(item)); | ||
| const ownership = responseToolOwnership(response); | ||
| const providerExecuted = (call: Record<string, unknown>) => | ||
| ownership !== undefined && isAuthorizedProviderExecutedCall(call, ownership); | ||
| const satisfied = calls.some(call => { | ||
| if (requirement.kind === "any" && requirement.hostedCallTypes.has(String(call.type))) return true; | ||
| if (providerExecuted(call)) return false; | ||
| if (call.type !== "function_call" && call.type !== "custom_tool_call") return false; | ||
| // Name-less calls cannot prove a selected-name mismatch. | ||
| return requirement.kind === "any" || typeof call.name !== "string" || call.name === requirement.name; | ||
| }); | ||
| if (!satisfied) { | ||
| const hostedEffect = calls.some(call => providerExecuted(call) | ||
| || (typeof call.type === "string" && call.type.endsWith("_call") | ||
| && call.type !== "function_call" && call.type !== "custom_tool_call")); | ||
| const failure = Response.json({ | ||
| error: { | ||
| type: "upstream_error", code: REQUIRED_TOOL_CONTRACT_CODE, | ||
| message: "Target completed the response without the tool call required by tool_choice", | ||
| }, | ||
| ...(payload.usage !== undefined ? { usage: payload.usage } : {}), | ||
| }, { status: hostedEffect ? 400 : 502 }); | ||
| // Hosted/unknown call items can already have executed remotely. Report the violation, | ||
| // but never buy another execution merely because the required client call is missing. | ||
| if (hostedEffect) { | ||
| markResponseNonReplayable(failure); | ||
| applyReplayRefusalClientHeaders(failure.headers); | ||
| } | ||
| return failure; | ||
| } | ||
| } | ||
| const headers = new Headers(response.headers); | ||
| headers.delete("content-encoding"); | ||
| headers.delete("content-length"); | ||
| const rebuilt = new Response(text, { status: response.status, statusText: response.statusText, headers }); | ||
| if (isPreinspectedJsonResponse(response)) markPreinspectedJsonResponse(rebuilt); | ||
| return carryResponseToolOwnership(response, rebuilt); | ||
| } | ||
|
|
||
| /** Native buffered Responses only; translated Chat and runTurn retain their terminal owners. */ | ||
| export async function guardBufferedComboRequiredTool(response: Response, rawBody: unknown, adapter?: string): Promise<Response> { | ||
| if (!response.ok || adapter !== "openai-responses" || clientWireOf(response) !== undefined | ||
| || isNonReplayableResponse(response) | ||
| || !(response.headers.get("content-type") ?? "").includes("application/json")) return response; | ||
| const requirement = requiredToolContractOf(rawBody); | ||
| return requirement ? judgeBufferedRequiredTool(response, requirement) : response; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,7 @@ | ||
| import { wasPermanentAccountRefusal } from "../../oauth/account-auto-pause"; | ||
| import { isNativeControlResponse } from "./native-response-control"; | ||
| import { guardBufferedComboRequiredTool } from "./combo-required-tool-contract"; | ||
| import { attachResponseToolOwnership } from "./response-tool-ownership"; | ||
| import { createOutboundCredentialMask, createTerminalErrorRedactionBlockRewrite } from "./terminal-error-redaction"; | ||
| import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options"; | ||
| import type { PreparedResponsesRequest } from "./request-prepare"; | ||
|
|
@@ -148,6 +150,8 @@ import { | |
| inspectResponseLogJson, | ||
| inspectResponseLogSsePayloadParsed, | ||
| noteUpstreamRequestId, | ||
| recordKeyWireAttemptUsage, | ||
| usageFromResponsesPayload, | ||
| } from "../request-log"; | ||
| import { restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat"; | ||
| import { restoreRoutedToolSearchCallsInJson } from "../../responses/tool-search-compat"; | ||
|
|
@@ -447,6 +451,15 @@ export async function deliverPassthroughResponse( | |
| const { requestBindings } = transportState; | ||
|
|
||
| let upstreamResponse = nativeExchange.upstreamResponse; | ||
| // Judge the restored native payload before display projection can erase hosted-call evidence. | ||
| const requiredToolFailure = async (text: string): Promise<Response | undefined> => { | ||
| if (!options.comboAttempt || clientRequestedStream === true || isNonReplayableResponse(upstreamResponse)) return; | ||
| const candidate = attachResponseToolOwnership(new Response(text, { | ||
| status: upstreamResponse.status, headers: { "content-type": "application/json" }, | ||
| }), providerExecutedCallTypes); | ||
| const verdict = await guardBufferedComboRequiredTool(candidate, parsed._rawBody, route.provider.adapter); | ||
| return verdict.ok ? undefined : verdict; | ||
| }; | ||
| const canonicalBufferedJson = clientRequestedStream !== true | ||
| && isCanonicalOpenAiForwardProvider(route.provider); | ||
| const originalContentType = upstreamResponse.headers.get("content-type"); | ||
|
|
@@ -887,7 +900,7 @@ export async function deliverPassthroughResponse( | |
| createTerminalErrorRedactionBlockRewrite(nativeExchange.request.headers, maskCredential), | ||
| rememberPlaintextBlock, | ||
| // Display-only projection must follow every continuation-cache observer. | ||
| isLocalCodexImageClient(req.headers, options.admission?.kind, options.inboundWire) | ||
| (!canonicalBufferedJson || !options.comboAttempt) && isLocalCodexImageClient(req.headers, options.admission?.kind, options.inboundWire) | ||
| ? createHostedImageDisplayRewrite() | ||
| : undefined, | ||
| ].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined); | ||
|
|
@@ -1030,6 +1043,29 @@ export async function deliverPassthroughResponse( | |
| }; | ||
| if (signal.aborted) return cancelAfterValidation(); | ||
|
|
||
| const requiredFailure = await requiredToolFailure(JSON.stringify(client.terminal.response)); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid the extra full-response Line 1046 evaluates For every canonical buffered request, this serializes the terminal response twice. The terminal response can be as large as the bounded transcript, so the second pass costs CPU and allocates a second large string in the hot path. Non-combo requests get no benefit from it. Serialize once and reuse the string: ⚡ Proposed fix
Also applies to: 1059-1059
|
||
| if (requiredFailure) { | ||
| const usage = usageFromResponsesPayload(client.terminal.response.usage); | ||
| if (usage && !logCtx.usageFromBridge) { | ||
| if (!recordKeyWireAttemptUsage(logCtx, usage)) { | ||
| logCtx.usage = usage; | ||
| if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage; | ||
| } | ||
| logCtx.usageWireParsed = true; | ||
| } | ||
|
Comment on lines
+1048
to
+1055
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔎 Supported by static analysis
Preserve aggregate usage when finalizing required-tool failures. For an API-key-owned buffered folded-SSE attempt with an existing wire-usage baseline, the new branch records baseline-plus-terminal usage in Use the retained aggregate for this specific failure, with the failure usage as a fallback: Suggested fix
|
||
| return requiredFailure; | ||
| } | ||
| if (signal.aborted) return cancelAfterValidation(); | ||
| let clientJson = JSON.stringify(client.terminal.response); | ||
| if (options.comboAttempt && isLocalCodexImageClient(req.headers, options.admission?.kind, options.inboundWire)) { | ||
| const display = createHostedImageDisplayRewrite(); | ||
| try { clientJson = display.json(clientJson); } | ||
| catch (error) { | ||
| if (error instanceof RangeError) return formatErrorResponse(502, "upstream_error", "hosted image result exceeds local display limits"); | ||
| throw error; | ||
| } finally { display.dispose?.(); } | ||
| } | ||
|
|
||
| // Effects intentionally run only after both bounded validations. They inspect the same | ||
| // upstream-facing transcript as the streaming relay and fire once; continuation storage | ||
| // receives the final client-visible response once after all restorations succeeded. | ||
|
|
@@ -1086,7 +1122,7 @@ export async function deliverPassthroughResponse( | |
|
|
||
| const jsonHeaders = sanitizePassthroughHeaders(headers, codexSafetyBufferingOptions); | ||
| jsonHeaders.set("content-type", "application/json"); | ||
| return markPreinspectedJsonResponse(new Response(JSON.stringify(client.terminal.response), { | ||
| return markPreinspectedJsonResponse(new Response(clientJson, { | ||
| status: upstreamResponse.status, | ||
| statusText: upstreamResponse.statusText, | ||
| headers: jsonHeaders, | ||
|
|
@@ -1365,6 +1401,8 @@ export async function deliverPassthroughResponse( | |
| declaredBareWireToolNames, | ||
| ); | ||
| } | ||
| const requiredFailure = await requiredToolFailure(clientJson); | ||
| if (requiredFailure) return requiredFailure; | ||
| commitReasoningReplayServingRoute(nativeExchange.request.headers); | ||
| try { | ||
| rememberPassthroughResponseChecked( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For a buffered native combo request declaring namespace
mcpwith functionpingand selecting{type: "function", name: "mcp__ping"}, a valid response is incorrectly rejected.rewriteRoutedNamespaceToolsForUpstreamaccepts this flattened selector, butrestoreRoutedNamespaceCallsInJsonrestores the returned call to{namespace: "mcp", name: "ping"}before this check runs. The requirement still contains the raw namemcp__ping, so this comparison produces a 502 and can exhaust every combo target despite each returning the requested call. Resolve the selector and restored call into the same namespace-aware identity before comparing them, and add regression coverage for flattened named selectors.Useful? React with 👍 / 👎.