From 1d09f3ecc99d6b3489ae39fd877b7731f8fb2b9c Mon Sep 17 00:00:00 2001 From: Epinephrine <27862058+luvs01@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:09:20 +0000 Subject: [PATCH 1/8] fix(responses): guard undeclared tools on Chat and Messages passthrough Enforce the existing active native tool guard on SSE and buffered JSON for all inbound wires before persisting continuation state. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/server/responses-undeclared-tool-guard.ts | 6 +-- src/server/responses/passthrough-delivery.ts | 11 ++---- structure/transports/responses.md | 2 +- .../responses-undeclared-tool-guard.test.ts | 39 ++++++++++++++++++- 4 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index 905454044af..52cc338e509 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -703,7 +703,6 @@ function failedBlocks(name: string, newline: string): readonly string[] { * @param providerExecutedCallTypes - Call types executed by the provider. * @param declaredBare - Explicitly declared bare tool names without namespace provenance. * @param declaredCustom - Current bare custom declarations eligible for code-mode recovery. - * @param enforceDeclaredToolNames - Refuse undeclared calls; normalization remains active when false. * @returns An SSE block rewrite function. */ export function createUndeclaredToolCallGuardBlockRewrite( @@ -712,7 +711,6 @@ export function createUndeclaredToolCallGuardBlockRewrite( providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, declaredBare?: ReadonlySet, declaredCustom?: ReadonlySet, - enforceDeclaredToolNames = true, ): SseBlockRewrite { let tripped = false; return (block: string) => { @@ -725,9 +723,7 @@ export function createUndeclaredToolCallGuardBlockRewrite( } catch { return [block]; } - const name = enforceDeclaredToolNames - ? undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare, declaredCustom) - : undefined; + const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare, declaredCustom); if (name !== undefined) { tripped = true; return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n"); diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index 184e2b1eabb..5053cd84a9d 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -436,8 +436,7 @@ export async function deliverPassthroughResponse( normalizeFunctionCompletionJson, } = nativeExchange; const { commitReasoningReplayServingRoute, recordTerminalOutcomes } = responseEffects; - const { parsed, subagentQuotaFailureModel, clientRequestedStream, translatorBudget, inboundWire } = requestState; - const enforceDeclaredToolNames = inboundWire !== "chat" && inboundWire !== "anthropic"; + const { parsed, subagentQuotaFailureModel, clientRequestedStream, translatorBudget } = requestState; const { openAiSidecar } = sidecarState; const { requestBindings } = transportState; @@ -861,7 +860,6 @@ export async function deliverPassthroughResponse( providerExecutedCallTypes, declaredBareWireToolNames, recoverableBareCustomWireToolNames, - enforceDeclaredToolNames, ) : undefined, grokUpstreamEchoEnabled @@ -1319,10 +1317,7 @@ export async function deliverPassthroughResponse( if (grokUpstreamEchoEnabled) { clientJson = stripGrokUpstreamEnvelopeEchoFromResponsesJson(clientJson); } - // #1700: same inbound-wire refusal policy as the SSE relay above. Both the plain JSON answer and - // the reframed-SSE branch below are built from this body, so one check covers them. This - // runs BEFORE the continuation cache write below: a refused turn must not become state a - // later `previous_response_id` replay can expand from. + // Both response formats use this body, before a refused turn can enter continuation state. if (nativeExchange.undeclaredToolGuardActive) { const undeclared = (() => { try { @@ -1338,7 +1333,7 @@ export async function deliverPassthroughResponse( return undefined; } })(); - if (enforceDeclaredToolNames && undeclared !== undefined) { + if (undeclared !== undefined) { return formatErrorResponse(502, "upstream_error", undeclaredToolCallMessage(undeclared)); } clientJson = normalizeDefaultNamespaceInJson( diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 0509784d3c4..e7d574ed271 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -14,7 +14,7 @@ When a successful streamed native response has a missing or unrecognized non-JSO HTTP ingress applies the [caller header contract](../data-planes/inbound-compat.md#http-caller-conversation-identity) before admission and dispatch. Optional Codex memory selection enters `src/server/responses/request-prepare.ts` for HTTP and WebSocket frames, as specified in [memory phase routing](responses-failover.md#memory-phase-routing); an unset phase keeps its existing route and starts no background work. Responses request preparation stabilizes incoming `` under `skills.catalog_refresh`: `per_session` (default) reuses the first received catalog for a conversation; `per_turn` leaves the supplied catalog unchanged. Other instruction sections and user/tool content remain untouched. Requests without a reliable conversation identity bypass snapshots; shared prompt-cache cohorts are not conversation identities. Only a body with exactly one catalog block across its instructions and developer/system content takes part; two or more pass through unchanged. A known snapshot is substituted before parsing, but a new catalog is stored only when preparation reaches its success return, so a request rejected by parsing or admission pins nothing. Without a named principal, snapshots are shared by conversation id only on a server that requires no data-plane auth. Snapshots are process-local, expire after four idle hours, and use bounded LRU retention; oversized blocks bypass caching. The dashboard's `src/codex/prompt-layers.ts` and `src/codex/prompt-text-probe.ts` continue observing current files for previews and do not own session snapshots. `/v1/responses` is the main Codex-facing endpoint. The server parses Responses input, routes to a provider, lets the selected adapter speak the upstream protocol, then bridges adapter events back to -Responses-compatible streaming output. The routed provider name follows every adapter build, including retries and continuations; only `github-copilot` injects a configured `modelContextTiers` value as upstream `contextTier`, while unconfigured passthrough retains the caller field. For an opted-in key-auth provider, a hosted-search continuation stays bound to the API-key selection that served the first leg; the contract is the [hosted-search continuation binding](../providers-and-adapters.md#hosted-search-continuation-binding). +Responses-compatible streaming output. The routed provider name follows every adapter build, including retries and continuations; only `github-copilot` injects a configured `modelContextTiers` value as upstream `contextTier`, while unconfigured passthrough retains the caller field. For an opted-in key-auth provider, a hosted-search continuation stays bound to the API-key selection that served the first leg; the contract is the [hosted-search continuation binding](../providers-and-adapters.md#hosted-search-continuation-binding). The active native passthrough undeclared-tool guard covers Chat, Anthropic and Responses inbound wires for both SSE and buffered JSON, refusing undeclared client-executed calls before continuation state is stored. The `openai-responses` adapter preserves caller `User-Agent` unless provider headers own it, in either auth mode. `forwardClientHeaders` additionally opts into `originator`, `x-client-request-id`, `x-codex-app-version`, or `user-agent`. diff --git a/tests/responses/responses-undeclared-tool-guard.test.ts b/tests/responses/responses-undeclared-tool-guard.test.ts index 99a50b2b1a7..dad43811b7a 100644 --- a/tests/responses/responses-undeclared-tool-guard.test.ts +++ b/tests/responses/responses-undeclared-tool-guard.test.ts @@ -1375,6 +1375,7 @@ describe("empty and absent tool catalogs", () => { model = "fixture/deepseek-v4-flash", previousResponseId?: string, toolChoice?: unknown, + inboundWire?: "chat" | "anthropic", ) { const savedFetch = globalThis.fetch; globalThis.fetch = (async () => upstream()) as typeof fetch; @@ -1397,7 +1398,7 @@ describe("empty and absent tool catalogs", () => { ...(tools === undefined ? {} : { tools }), ...(toolChoice === undefined ? {} : { tool_choice: toolChoice }), }), - }), requestConfig, { model: "", provider: "" }); + }), requestConfig, { model: "", provider: "" }, { inboundWire }); } finally { globalThis.fetch = savedFetch; } @@ -1437,6 +1438,42 @@ describe("empty and absent tool catalogs", () => { expect(body).toContain("response.completed"); }); + for (const inboundWire of ["chat", "anthropic"] as const) { + for (const stream of [false, true]) { + test(`${inboundWire} passthrough refuses undeclared Write calls (${stream ? "streaming" : "JSON"})`, async () => { + const writeCall = { ...call, name: "Write" }; + const upstream = stream + ? () => new Response([ + frame("response.output_item.added", { output_index: 0, item: writeCall }), + frame("response.completed", { response: { id: "resp_1", status: "completed", output: [writeCall] } }), + ].join("\n\n") + "\n\n", { headers: { "content-type": "text/event-stream" } }) + : () => new Response( + JSON.stringify({ id: "resp_1", status: "completed", output: [writeCall] }), + { headers: { "content-type": "application/json" } }, + ); + const response = await post( + stream, + [{ type: "function", name: "Read", parameters: { type: "object" } }], + upstream, + undefined, + config, + [], + "fixture/deepseek-v4-flash", + undefined, + undefined, + inboundWire, + ); + + if (stream) { + expect(await response.text()).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + } else { + expect(response.status).toBe(502); + expect(await response.text()).toContain("undeclared"); + } + }); + } + } + test("non-streaming, an unreadable top-level catalog — relayed, not refused", async () => { const response = await post(false, [{ type: "function" }], jsonUpstream); From e63ac22b9a6689bf1c6b36f609b4de8348adb035 Mon Sep 17 00:00:00 2001 From: Epinephrine <27862058+luvs01@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:27:40 +0000 Subject: [PATCH 2/8] test(responses): isolate inbound tool regressions under the file-size cap Move the four new wire regressions unchanged into a sibling file and register its layout without raising the existing file-size limit. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- scripts/test-layout/layout.json | 2 +- tests/fixtures/test-layout-expected.json | 2 +- ...nses-undeclared-tool-guard-inbound.test.ts | 113 ++++++++++++++++++ .../responses-undeclared-tool-guard.test.ts | 39 +----- 4 files changed, 116 insertions(+), 40 deletions(-) create mode 100644 tests/responses/responses-undeclared-tool-guard-inbound.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 3d677b90ea1..276e5c6b135 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,7 @@ "version": 1, "root": "tests", "explicit": { - "messages-request-id-headers.test.ts": "claude-integration", + "messages-request-id-headers.test.ts": "claude-integration", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", "anthropic-revoked-token.test.ts": "adapters/anthropic", "messages-revoked-token.test.ts": "claude-integration", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 207e9e64782..5ea839fe77c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,5 @@ { - "messages-request-id-headers.test.ts": "claude-integration", + "messages-request-id-headers.test.ts": "claude-integration", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", "anthropic-revoked-token.test.ts": "adapters/anthropic", "messages-revoked-token.test.ts": "claude-integration", diff --git a/tests/responses/responses-undeclared-tool-guard-inbound.test.ts b/tests/responses/responses-undeclared-tool-guard-inbound.test.ts new file mode 100644 index 00000000000..d5067386bc5 --- /dev/null +++ b/tests/responses/responses-undeclared-tool-guard-inbound.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { handleResponses } from "../../src/server/responses"; +import { UNDECLARED_TOOL_CALL_ERROR_CODE } from "../../src/server/responses-undeclared-tool-guard"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; + +let releaseSpendHome: (() => void) | undefined; +const takeSpendHome = (): void => { releaseSpendHome ??= acquireOwnedSpendHome(); }; +afterEach(() => { releaseSpendHome?.(); releaseSpendHome = undefined; }); + +function frame(type: string, payload: Record): string { + return `event: ${type}\ndata: ${JSON.stringify({ type, ...payload })}`; +} + +describe("native passthrough inbound tool catalogs", () => { + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + + const call = { + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "apply_patch", + arguments: "{}", + status: "completed", + }; + + async function post( + stream: boolean, + tools: unknown[] | undefined, + upstream: () => Response, + additionalTools?: unknown[], + requestConfig: OcxConfig = config, + history: unknown[] = [], + model = "fixture/deepseek-v4-flash", + previousResponseId?: string, + toolChoice?: unknown, + inboundWire?: "chat" | "anthropic", + ) { + const savedFetch = globalThis.fetch; + globalThis.fetch = (async () => upstream()) as typeof fetch; + try { + takeSpendHome(); + return await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model, + stream, + ...(previousResponseId ? { previous_response_id: previousResponseId } : {}), + input: [ + ...history, + { role: "user", content: [{ type: "input_text", text: "hi" }] }, + ...(additionalTools === undefined + ? [] + : [{ type: "additional_tools", role: "developer", tools: additionalTools }]), + ], + ...(tools === undefined ? {} : { tools }), + ...(toolChoice === undefined ? {} : { tool_choice: toolChoice }), + }), + }), requestConfig, { model: "", provider: "" }, { inboundWire }); + } finally { + globalThis.fetch = savedFetch; + } + } + + for (const inboundWire of ["chat", "anthropic"] as const) { + for (const stream of [false, true]) { + test(`${inboundWire} passthrough refuses undeclared Write calls (${stream ? "streaming" : "JSON"})`, async () => { + const writeCall = { ...call, name: "Write" }; + const upstream = stream + ? () => new Response([ + frame("response.output_item.added", { output_index: 0, item: writeCall }), + frame("response.completed", { response: { id: "resp_1", status: "completed", output: [writeCall] } }), + ].join("\n\n") + "\n\n", { headers: { "content-type": "text/event-stream" } }) + : () => new Response( + JSON.stringify({ id: "resp_1", status: "completed", output: [writeCall] }), + { headers: { "content-type": "application/json" } }, + ); + const response = await post( + stream, + [{ type: "function", name: "Read", parameters: { type: "object" } }], + upstream, + undefined, + config, + [], + "fixture/deepseek-v4-flash", + undefined, + undefined, + inboundWire, + ); + + if (stream) { + expect(await response.text()).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + } else { + expect(response.status).toBe(502); + expect(await response.text()).toContain("undeclared"); + } + }); + } + } + +}); diff --git a/tests/responses/responses-undeclared-tool-guard.test.ts b/tests/responses/responses-undeclared-tool-guard.test.ts index dad43811b7a..99a50b2b1a7 100644 --- a/tests/responses/responses-undeclared-tool-guard.test.ts +++ b/tests/responses/responses-undeclared-tool-guard.test.ts @@ -1375,7 +1375,6 @@ describe("empty and absent tool catalogs", () => { model = "fixture/deepseek-v4-flash", previousResponseId?: string, toolChoice?: unknown, - inboundWire?: "chat" | "anthropic", ) { const savedFetch = globalThis.fetch; globalThis.fetch = (async () => upstream()) as typeof fetch; @@ -1398,7 +1397,7 @@ describe("empty and absent tool catalogs", () => { ...(tools === undefined ? {} : { tools }), ...(toolChoice === undefined ? {} : { tool_choice: toolChoice }), }), - }), requestConfig, { model: "", provider: "" }, { inboundWire }); + }), requestConfig, { model: "", provider: "" }); } finally { globalThis.fetch = savedFetch; } @@ -1438,42 +1437,6 @@ describe("empty and absent tool catalogs", () => { expect(body).toContain("response.completed"); }); - for (const inboundWire of ["chat", "anthropic"] as const) { - for (const stream of [false, true]) { - test(`${inboundWire} passthrough refuses undeclared Write calls (${stream ? "streaming" : "JSON"})`, async () => { - const writeCall = { ...call, name: "Write" }; - const upstream = stream - ? () => new Response([ - frame("response.output_item.added", { output_index: 0, item: writeCall }), - frame("response.completed", { response: { id: "resp_1", status: "completed", output: [writeCall] } }), - ].join("\n\n") + "\n\n", { headers: { "content-type": "text/event-stream" } }) - : () => new Response( - JSON.stringify({ id: "resp_1", status: "completed", output: [writeCall] }), - { headers: { "content-type": "application/json" } }, - ); - const response = await post( - stream, - [{ type: "function", name: "Read", parameters: { type: "object" } }], - upstream, - undefined, - config, - [], - "fixture/deepseek-v4-flash", - undefined, - undefined, - inboundWire, - ); - - if (stream) { - expect(await response.text()).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); - } else { - expect(response.status).toBe(502); - expect(await response.text()).toContain("undeclared"); - } - }); - } - } - test("non-streaming, an unreadable top-level catalog — relayed, not refused", async () => { const response = await post(false, [{ type: "function" }], jsonUpstream); From 521ef2b2da5048c0bab058dbaf2f235b402e44d4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:32:41 +0000 Subject: [PATCH 3/8] docs(responses): document undeclared-tool refusal across inbound wires Devin Review on #720 flagged that the new Chat/Anthropic refusal was only recorded in the maintainer transport doc. The sibling wire-shapes doc still described the removed enforceDeclaredToolNames flag and the responses-only scope, and the public guides never covered the refusal. Update both: passthrough now refuses undeclared client-executed calls on every inbound wire, while the translated delivery paths keep their inboundWire-scoped membership check. Co-Authored-By: Epinephrine --- .../content/docs/reference/proxy-formats.md | 10 ++++++ structure/transports/responses-wire-shapes.md | 34 +++++++++---------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 876ef6f54d7..c67f6c63df9 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -71,6 +71,16 @@ returns 504, and client cancellation returns 499. Bun does not expose the exact cannot currently be forwarded precisely. A successful connection preserves the initial session frames in order. This handshake policy is separate from the Responses WebSocket transport. +## Undeclared tool calls + +When a request declares a tool catalog, a native passthrough reply that calls a client-executed +tool outside that catalog fails the turn instead of relaying the call — on every inbound dialect: +Responses, Chat Completions, and Messages. Streaming replies emit `response.failed` with error +code `undeclared_tool_call`, then `data: [DONE]`; buffered JSON returns HTTP 502 `upstream_error` +naming the tool. The refusal runs before the response can enter continuation state, and it stands +down only for `authMode: "forward"` providers or a request with no declared catalog. An explicitly +empty catalog counts as a declared catalog and therefore authorizes nothing. + ## Endpoint overview | Client surface | Endpoint | Successful non-stream result | Successful stream or socket result | diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 4cc69de837c..779147c2cfe 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -293,34 +293,32 @@ There is no rule that strips whatever precedes the first dot: a legitimate tool one in another provider's vocabulary, and a replayed item names a call that already happened, which is the worst place to guess. -Membership enforcement is that flag, `enforceDeclaredToolNames`, and only the `responses` inbound -wire enforces. Explicit enforcement with no declared catalog also refuses client tool calls rather -than treating the missing set as permission. A routed provider that names a tool the request never declared ends the turn there: +Membership enforcement refuses a routed provider that names a tool the request never declared: `src/bridge/sse.ts` emits `response.failed` and `src/bridge/response-json.ts` returns a failed response, both carrying `undeclared client tool`. That is the #1700 contract and it stands. Codex executes a top-level tool call, so a hallucinated `apply_patch` — which under code mode exists only as a nested `tools.apply_patch(...)` helper inside `exec` — is refused before it reaches the runtime, where it previously surfaced as a bare `aborted` with the file untouched. -The `chat` and `anthropic` inbound wires relay the call instead. This is a deliberate reversal of -#1700's scope for those two wires, not an oversight. Both vendor specs make the client's own runner -responsible for validating a tool call and then executing or denying it, and harnesses on those -endpoints defer part of their catalog to conserve prompt tokens and discover the rest at runtime. -Enforcing membership against a partial catalog killed those streams mid-turn with a 502 and cost the -caller the whole turn. This proxy executes no tool call on any wire, so scoping enforcement off -these two moves the decision to the party that already makes it rather than removing it. - -An explicitly empty catalog still authorizes nothing on the wire that enforces. A request declaring +On the native passthrough lane the refusal covers every inbound wire: `chat`, `anthropic` and +`responses` each reject an undeclared client-executed call, in SSE and buffered JSON alike, before +continuation state is stored. The translated delivery paths keep the narrower scope — +`src/server/responses/run-turn-execution.ts` and `src/server/responses/adapter-delivery.ts` still +derive `enforceDeclaredToolNames` from `inboundWire`, and only `responses` refuses undeclared calls +there; `chat` and `anthropic` translated replies relay the call instead. Harnesses on those +endpoints may defer part of their catalog to conserve prompt tokens and discover the rest at +runtime, and both vendor specs leave the client's own runner responsible for validating a tool +call — enforcing membership against a partial catalog would kill those streams mid-turn for a +call the client could execute. + +An explicitly empty catalog still authorizes nothing wherever enforcement runs. A request declaring an empty tool list is making a statement rather than omitting one, which is how the passthrough guard reads it through `clientExplicitWireToolCatalog` in `src/server/responses/passthrough-dispatch.ts`. -The passthrough SSE/JSON refusal is wire-scoped too: only `responses` rejects undeclared calls. -`undeclaredToolGuardActive` still gates namespace normalization and continuation suppression on every wire, -standing down only for `authMode: "forward"` and requests with no catalog; relayed undeclared calls are not remembered. - -`src/server/responses/run-turn-execution.ts`, `src/server/responses/adapter-delivery.ts`, and -`src/server/responses/passthrough-delivery.ts` scope membership refusal from `inboundWire` for SSE and JSON alike. +`undeclaredToolGuardActive` still gates namespace normalization and continuation suppression on the +passthrough lane, standing down only for `authMode: "forward"` and requests with no catalog; a +refused turn never enters continuation state. ## Selection outlives the declaration check From f58d2351499923632f7c5bfc8d349d0ff58ca7a1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:44:15 +0000 Subject: [PATCH 4/8] docs(responses): clarify translated undeclared-tool errors Co-Authored-By: Epinephrine --- .../content/docs/reference/proxy-formats.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index c67f6c63df9..f6390a1aa4d 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -75,11 +75,19 @@ frames in order. This handshake policy is separate from the Responses WebSocket When a request declares a tool catalog, a native passthrough reply that calls a client-executed tool outside that catalog fails the turn instead of relaying the call — on every inbound dialect: -Responses, Chat Completions, and Messages. Streaming replies emit `response.failed` with error -code `undeclared_tool_call`, then `data: [DONE]`; buffered JSON returns HTTP 502 `upstream_error` -naming the tool. The refusal runs before the response can enter continuation state, and it stands -down only for `authMode: "forward"` providers or a request with no declared catalog. An explicitly -empty catalog counts as a declared catalog and therefore authorizes nothing. +Responses, Chat Completions, and Messages. The client-facing streaming failure depends on the endpoint: + +- **Responses:** a `response.failed` event with error code `undeclared_tool_call`, then `data: [DONE]`. +- **Chat Completions:** a `data:` frame containing an OpenAI-style `error` object, then the stream + closes without a success completion or `[DONE]`. +- **Messages:** an Anthropic `event: error` frame with a `{ "type": "error", "error": ... }` body, + without a successful `message_stop` or `[DONE]`. The Responses error code is not preserved. + +Buffered JSON returns HTTP 502 with an error naming the tool, using the endpoint's error envelope +(the internal `upstream_error` is translated for Chat and Messages clients). The refusal runs +before the response can enter continuation state, and it stands down only for `authMode: "forward"` +providers or a request with no declared catalog. An explicitly empty catalog counts as a declared +catalog and therefore authorizes nothing. ## Endpoint overview From d8935abe47e26c661b89dc46269db743e7e7ec84 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:06:08 +0000 Subject: [PATCH 5/8] docs(responses): translate undeclared-tool guidance Co-Authored-By: Epinephrine --- .../src/content/docs/fr/reference/proxy-formats.md | 10 ++++++++++ .../src/content/docs/ja/reference/proxy-formats.md | 10 ++++++++++ .../src/content/docs/ko/reference/proxy-formats.md | 10 ++++++++++ .../src/content/docs/ru/reference/proxy-formats.md | 10 ++++++++++ .../src/content/docs/tr/reference/proxy-formats.md | 10 ++++++++++ .../src/content/docs/zh-cn/reference/proxy-formats.md | 10 ++++++++++ .../src/content/docs/zh-tw/reference/proxy-formats.md | 10 ++++++++++ 7 files changed, 70 insertions(+) diff --git a/docs-site/src/content/docs/fr/reference/proxy-formats.md b/docs-site/src/content/docs/fr/reference/proxy-formats.md index 3d47bfaeae4..58fa70374e9 100644 --- a/docs-site/src/content/docs/fr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/fr/reference/proxy-formats.md @@ -30,6 +30,16 @@ Certains refus xAI de Chat Completions arrivent en HTTP 403 avec une phrase de r Sur une requête Responses hors combo, OpenCodex réécrit ce 403 de la liste autorisée en réponse Responses HTTP 200 avec `status: "incomplete"` et `incomplete_details.reason: "content_filter"`. La réécriture s'applique au chemin de l'adaptateur openai-chat et au passthrough openai-responses (OAuth grok-4.6 / grok-4.5). Le streaming utilise la même limite incomplete. Un corps 403 vide ou fait d'espaces reste une erreur. Les 403 d'abonnement, de crédits, de droits d'accès et `not allowed to use this model` restent des erreurs. Le basculement de combo voit toujours le HTTP 403 d'origine. +## Appels d’outils non déclarés + +Lorsqu’une requête déclare un catalogue d’outils, une réponse en passthrough natif qui appelle un outil exécuté par le client hors de ce catalogue fait échouer le tour au lieu de relayer l’appel. Cela s’applique à tous les dialectes entrants : Responses, Chat Completions et Messages. En streaming, l’échec présenté au client dépend du point de terminaison : + +- **Responses :** un événement `response.failed` avec le code d’erreur `undeclared_tool_call`, puis `data: [DONE]`. +- **Chat Completions :** une trame `data:` contenant un objet `error` au format OpenAI, puis la fermeture du flux sans fin de succès ni `[DONE]`. +- **Messages :** une trame Anthropic `event: error` avec le corps `{ "type": "error", "error": ... }`, sans `message_stop` réussi ni `[DONE]`. Le code d’erreur Responses n’est pas conservé. + +La réponse JSON mise en mémoire tampon renvoie un HTTP 502 avec une erreur qui nomme l’outil ; le format d’erreur dépend du point de terminaison (l’`upstream_error` interne est traduit pour les clients Chat et Messages). Le refus intervient avant que la réponse puisse entrer dans l’état de continuation. Il n’est désactivé que pour les fournisseurs avec `authMode: "forward"` ou pour une requête sans catalogue déclaré. Un catalogue explicitement vide est considéré comme déclaré et n’autorise donc aucun outil. + ## Présentation du point de terminaison | Espace client | Point de terminaison | Résultat non-stream réussi | Résultat de flux ou de socket réussi | diff --git a/docs-site/src/content/docs/ja/reference/proxy-formats.md b/docs-site/src/content/docs/ja/reference/proxy-formats.md index db698fd90cb..1c97f9bddfc 100644 --- a/docs-site/src/content/docs/ja/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ja/reference/proxy-formats.md @@ -24,6 +24,16 @@ provider events → internal adapter events → client dialect コンボではない Responses リクエストでは、OpenCodex はその allowlist 対象の 403 を HTTP 200 の Responses、`status: "incomplete"`、`incomplete_details.reason: "content_filter"` に書き換えます。openai-chat アダプタ経路と openai-responses パススルー(grok-4.6 / grok-4.5 OAuth)の両方です。ストリーミングも同じ incomplete 境界です。空本文の 403 はエラーのままです。サブスクリプション、クレジット、権限、`not allowed to use this model` の 403 はエラーのままです。コンボのフェイルオーバーは元の HTTP 403 を見ます。 +## 宣言されていないツール呼び出し + +リクエストでツールカタログが宣言されている場合、ネイティブのパススルー応答がそのカタログにないクライアント実行ツールを呼び出すと、その呼び出しを中継せずにターンを失敗させます。この動作はすべての受信ダイアレクト(Responses、Chat Completions、Messages)に適用されます。ストリーミング時にクライアントへ返す失敗形式は、エンドポイントによって異なります。 + +- **Responses:** エラーコード `undeclared_tool_call` を含む `response.failed` イベントを送り、その後に `data: [DONE]` を送ります。 +- **Chat Completions:** OpenAI 形式の `error` オブジェクトを含む `data:` フレームを送り、成功完了や `[DONE]` を送らずにストリームを閉じます。 +- **Messages:** `{ "type": "error", "error": ... }` 本文を含む Anthropic の `event: error` フレームを送り、成功した `message_stop` や `[DONE]` は送りません。Responses のエラーコードは引き継がれません。 + +バッファリングされた JSON 応答は、ツール名を含むエラーとともに HTTP 502 を返します。エラー形式はエンドポイント固有で、内部の `upstream_error` は Chat および Messages クライアント向けに変換されます。この拒否処理は、応答が継続状態に入る前に実行されます。この拒否が行われないのは、`authMode: "forward"` のプロバイダー、またはカタログが宣言されていないリクエストに限られます。明示的に空のカタログも宣言済みとして扱われ、どのツールも許可しません。 + ## エンドポイントの概要 |クライアントサーフェス |エンドポイント |非ストリームの結果が成功 |成功したストリームまたはソケットの結果 | diff --git a/docs-site/src/content/docs/ko/reference/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md index e93eb315644..25102500551 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -29,6 +29,16 @@ Responses 표현이 이 연결의 중심입니다. 네이티브 호환 경로는 콤보가 아닌 Responses 요청에서 OpenCodex는 allowlist에 오른 그 403을 HTTP 200 Responses, `status: "incomplete"`, `incomplete_details.reason: "content_filter"`로 바꿉니다. openai-chat 어댑터 경로와 openai-responses passthrough(grok-4.6 / grok-4.5 OAuth) 모두에서 동작합니다. 스트리밍도 같은 incomplete 경계입니다. 빈 본문 403은 오류로 남습니다. 구독, 크레딧, 권한, `not allowed to use this model` 403은 오류로 남습니다. 콤보 페일오버는 원래 HTTP 403을 그대로 봅니다. +## 선언되지 않은 도구 호출 + +요청에서 도구 카탈로그를 선언한 경우, 네이티브 패스스루 응답이 해당 카탈로그에 없는 클라이언트 실행 도구를 호출하면 그 호출을 전달하는 대신 턴을 실패 처리합니다. 이 동작은 Responses, Chat Completions, Messages를 비롯한 모든 인바운드 방언에 적용됩니다. 스트리밍 실패의 클라이언트 표시 형식은 엔드포인트마다 다릅니다. + +- **Responses:** 오류 코드 `undeclared_tool_call`이 포함된 `response.failed` 이벤트를 보낸 다음 `data: [DONE]`을 보냅니다. +- **Chat Completions:** OpenAI 형식의 `error` 객체가 포함된 `data:` 프레임을 보낸 뒤, 성공 완료나 `[DONE]` 없이 스트림을 닫습니다. +- **Messages:** `{ "type": "error", "error": ... }` 본문이 포함된 Anthropic `event: error` 프레임을 보냅니다. 성공적인 `message_stop`이나 `[DONE]`은 보내지 않으며, Responses 오류 코드는 보존되지 않습니다. + +버퍼링된 JSON 응답은 도구 이름이 포함된 오류와 함께 HTTP 502를 반환합니다. 오류 형식은 엔드포인트별로 다르며, 내부 `upstream_error`는 Chat 및 Messages 클라이언트용으로 변환됩니다. 이 거부 처리는 응답이 후속 처리 상태에 들어가기 전에 실행됩니다. `authMode: "forward"` 공급자이거나 요청에 선언된 카탈로그가 없는 경우에만 이 거부 처리가 적용되지 않습니다. 명시적으로 빈 카탈로그도 선언된 것으로 간주되므로 어떤 도구도 허용하지 않습니다. + ## 엔드포인트 개요 | 클라이언트 표면 | 엔드포인트 | 성공한 비스트리밍 결과 | 성공한 스트리밍 또는 소켓 결과 | diff --git a/docs-site/src/content/docs/ru/reference/proxy-formats.md b/docs-site/src/content/docs/ru/reference/proxy-formats.md index 510b3d6eadf..c87ce3e99a1 100644 --- a/docs-site/src/content/docs/ru/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ru/reference/proxy-formats.md @@ -31,6 +31,16 @@ control и safety ответа всё равно происходят на гр На non-combo запросе Responses OpenCodex переписывает такой allowlist-ованный 403 в HTTP 200 Responses со `status: "incomplete"` и `incomplete_details.reason: "content_filter"`. Это работает и на пути адаптера openai-chat, и на openai-responses passthrough (OAuth grok-4.6 / grok-4.5). Поток использует ту же incomplete-границу. Пустой 403 остаётся ошибкой. 403 подписки, кредитов, прав доступа и `not allowed to use this model` остаются ошибками. Combo failover по-прежнему видит исходный HTTP 403. +## Вызовы незаявленных инструментов + +Если в запросе объявлен каталог инструментов, ответ в режиме native passthrough, вызывающий инструмент, выполняемый клиентом, вне этого каталога, завершает ход ошибкой вместо передачи вызова дальше. Это относится ко всем входящим диалектам: Responses, Chat Completions и Messages. Формат потоковой ошибки для клиента зависит от endpoint: + +- **Responses:** событие `response.failed` с кодом ошибки `undeclared_tool_call`, затем `data: [DONE]`. +- **Chat Completions:** кадр `data:` с объектом `error` в формате OpenAI, после которого поток закрывается без успешного завершения и без `[DONE]`. +- **Messages:** кадр Anthropic `event: error` с телом `{ "type": "error", "error": ... }`, без успешного `message_stop` и без `[DONE]`. Код ошибки Responses не сохраняется. + +Буферизованный JSON возвращает HTTP 502 с ошибкой, содержащей имя инструмента; используется формат ошибки конкретного endpoint (внутренний `upstream_error` преобразуется для клиентов Chat и Messages). Отказ срабатывает до того, как ответ попадёт в состояние продолжения. Проверка отключается только для провайдеров с `authMode: "forward"` или запросов без объявленного каталога. Явно пустой каталог считается объявленным и поэтому не разрешает ни одного инструмента. + ## Обзор endpoint'ов | Клиентская поверхность | Endpoint | Успешный non-stream результат | Успешный результат потока или сокета | diff --git a/docs-site/src/content/docs/tr/reference/proxy-formats.md b/docs-site/src/content/docs/tr/reference/proxy-formats.md index 3178dfae456..8a66d3f3d66 100644 --- a/docs-site/src/content/docs/tr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/tr/reference/proxy-formats.md @@ -33,6 +33,16 @@ Bazı xAI Chat Completions retleri, HTTP 200 ve `finish_reason: content_filter` Kombo olmayan bir Responses isteğinde OpenCodex, izin listesindeki bu 403'ü `status: "incomplete"` ve `incomplete_details.reason: "content_filter"` içeren bir HTTP 200 Responses yanıtına dönüştürür. Dönüştürme openai-chat bağdaştırıcı yolunda ve openai-responses geçişinde (grok-4.6 / grok-4.5 OAuth) çalışır. Akış da aynı incomplete sınırını kullanır. Boş veya yalnızca boşluk içeren 403 gövdeleri hata olarak kalır. Abonelik, kredi, yetki ve `not allowed to use this model` 403'leri hata olarak kalır. Kombo yük devretmesi özgün HTTP 403'ü görmeye devam eder. +## Bildirilmemiş araç çağrıları + +Bir istek bir araç kataloğu bildirdiğinde, katalog dışındaki istemci tarafından çalıştırılan bir aracı çağıran native passthrough yanıtı, çağrıyı aktarmak yerine turu başarısız kılar. Bu, gelen tüm lehçeler için geçerlidir: Responses, Chat Completions ve Messages. İstemcinin akış sırasında göreceği hata, endpoint’e göre değişir: + +- **Responses:** `undeclared_tool_call` hata kodunu içeren bir `response.failed` olayı, ardından `data: [DONE]`. +- **Chat Completions:** OpenAI biçiminde bir `error` nesnesi içeren `data:` çerçevesi gönderilir; ardından başarılı bir tamamlanma veya `[DONE]` olmadan akış kapatılır. +- **Messages:** `{ "type": "error", "error": ... }` gövdeli bir Anthropic `event: error` çerçevesi gönderilir; başarılı bir `message_stop` veya `[DONE]` gönderilmez. Responses hata kodu korunmaz. + +Arabelleğe alınmış JSON yanıtı, araç adını içeren bir hatayla HTTP 502 döndürür; endpoint’e özgü hata biçimini kullanır (dahili `upstream_error`, Chat ve Messages istemcileri için dönüştürülür). Ret, yanıt devam durumuna girmeden önce gerçekleşir. Bu ret yalnızca `authMode: "forward"` sağlayıcıları veya bildirilmiş kataloğu olmayan istekler için devre dışıdır. Açıkça boş bir katalog bildirilmiş sayılır ve hiçbir araca izin vermez. + ## Uç nokta genel bakışı | İstemci yüzeyi | Uç nokta | Başarılı akışsız sonuç | Başarılı akış veya soket sonucu | diff --git a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md index 91ea424435d..22b79336bd7 100644 --- a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md @@ -29,6 +29,16 @@ Responses 表示是这座桥的中心。原生兼容的路由可以跳过部分 在非 combo 的 Responses 请求上,OpenCodex 会把该 allowlist 中的 403 改写为 HTTP 200 Responses,`status: "incomplete"`,`incomplete_details.reason: "content_filter"`。openai-chat 适配器路径和 openai-responses passthrough(grok-4.6 / grok-4.5 OAuth)都会改写。流式响应使用同一 incomplete 边界。空正文 403 仍是错误。订阅、额度、权限以及 `not allowed to use this model` 的 403 仍是错误。combo 故障切换仍会看到原始 HTTP 403。 +## 未声明的工具调用 + +当请求声明了工具目录时,原生直通响应如果调用目录之外的客户端执行工具,不会转发该调用,而是使本轮失败。此行为适用于所有入站方言:Responses、Chat Completions 和 Messages。流式失败在客户端的呈现方式取决于端点: + +- **Responses:**发送带有错误代码 `undeclared_tool_call` 的 `response.failed` 事件,然后发送 `data: [DONE]`。 +- **Chat Completions:**发送包含 OpenAI 风格 `error` 对象的 `data:` 帧,随后关闭流,不发送成功完成标记或 `[DONE]`。 +- **Messages:**发送包含 `{ "type": "error", "error": ... }` 正文的 Anthropic `event: error` 帧;不会发送成功的 `message_stop` 或 `[DONE]`。Responses 错误代码不会保留。 + +缓冲式 JSON 响应会返回 HTTP 502,并在错误中注明工具名称;错误格式因端点而异(内部的 `upstream_error` 会转换为 Chat 和 Messages 客户端所用的格式)。拒绝发生在响应进入续接状态之前。只有 `authMode: "forward"` 提供方或未声明工具目录的请求才不会触发此拒绝。显式声明为空的目录仍算已声明,因此不授权任何工具。 + ## 端点总览 | 客户端表面 | 端点 | 成功的非流式结果 | 成功的流式或套接字结果 | diff --git a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md index 5f1ca4e8a34..53778e302e4 100644 --- a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md @@ -24,6 +24,16 @@ Responses 表示是橋接的中心。原生相容的路由可跳過部分轉譯 在非 combo 的 Responses 請求上,OpenCodex 會把這種在允許清單中的 403 改寫為 HTTP 200 的 Responses 內容,帶有 `status: "incomplete"` 與 `incomplete_details.reason: "content_filter"`。改寫同時作用於 openai-chat 轉接器路徑與 openai-responses 直通(grok-4.6 / grok-4.5 OAuth)。串流使用相同的 incomplete 邊界。空白的 403 本文仍是錯誤。訂閱、點數、權限與 `not allowed to use this model` 的 403 仍是錯誤。Combo failover 仍看到原始的 HTTP 403。 +## 未宣告的工具呼叫 + +當請求宣告了工具目錄時,原生直通回應若呼叫目錄之外、由用戶端執行的工具,不會轉送該呼叫,而是讓本回合失敗。此行為適用於所有輸入方言:Responses、Chat Completions 與 Messages。串流失敗在用戶端呈現的格式取決於端點: + +- **Responses:**傳送帶有錯誤碼 `undeclared_tool_call` 的 `response.failed` 事件,接著傳送 `data: [DONE]`。 +- **Chat Completions:**傳送包含 OpenAI 格式 `error` 物件的 `data:` frame,接著關閉串流,不會傳送成功完成訊號或 `[DONE]`。 +- **Messages:**傳送包含 `{ "type": "error", "error": ... }` 內文的 Anthropic `event: error` frame;不會傳送成功的 `message_stop` 或 `[DONE]`。Responses 錯誤碼不會保留。 + +緩衝式 JSON 回應會傳回 HTTP 502,並在錯誤中指出工具名稱;錯誤格式依端點而異(內部的 `upstream_error` 會轉換為 Chat 與 Messages 用戶端所需的格式)。拒絕會在回應進入延續狀態之前執行。只有 `authMode: "forward"` 供應商或未宣告工具目錄的請求不會觸發此拒絕。明確宣告為空的目錄仍視為已宣告,因此不會授權任何工具。 + ## 端點概覽 | 客戶端介面 | 端點 | 成功的非串流結果 | 成功的串流或 socket 結果 | From aaf19e045d8a17ff0543e534d83e1b38a55d82c7 Mon Sep 17 00:00:00 2001 From: Epinephrine <27862058+luvs01@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:21:56 +0000 Subject: [PATCH 6/8] test(responses): verify endpoint refusal envelopes and terminals Exercise the real Responses, Chat Completions, and Messages handlers with an undeclared upstream call followed by a successful terminal. Assert each endpoint error envelope and suppress successful completion signals. Preserve canonical Responses failure followed by DONE and keep internal JSON assertions unchanged. Strengthen the internal stream guard checks and prove the new assertions with private wrong-envelope and success-tail controls. No runtime source changes. --- ...nses-undeclared-tool-guard-inbound.test.ts | 89 ++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/tests/responses/responses-undeclared-tool-guard-inbound.test.ts b/tests/responses/responses-undeclared-tool-guard-inbound.test.ts index d5067386bc5..9078abb6626 100644 --- a/tests/responses/responses-undeclared-tool-guard-inbound.test.ts +++ b/tests/responses/responses-undeclared-tool-guard-inbound.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { handleResponses } from "../../src/server/responses"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import { handleClaudeMessages } from "../../src/server/claude-messages"; import { UNDECLARED_TOOL_CALL_ERROR_CODE } from "../../src/server/responses-undeclared-tool-guard"; import type { OcxConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; @@ -12,6 +14,16 @@ function frame(type: string, payload: Record): string { return `event: ${type}\ndata: ${JSON.stringify({ type, ...payload })}`; } +function readFrames(body: string) { + return body.trim().split(/\r?\n\r?\n/).filter(Boolean).map(block => { + const lines = block.split(/\r?\n/); + const data = lines.filter(line => line.startsWith("data:")).map(line => line.slice(5).trimStart()).join("\n"); + const event = lines.find(line => line.startsWith("event:"))?.slice(6).trim(); + const payload = data && data !== "[DONE]" ? JSON.parse(data) as Record : undefined; + return { event, data, payload }; + }); +} + describe("native passthrough inbound tool catalogs", () => { const config = { port: 0, @@ -101,7 +113,12 @@ describe("native passthrough inbound tool catalogs", () => { ); if (stream) { - expect(await response.text()).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + // This direct internal call still returns Responses wire; endpoint projections are checked below. + const body = await response.text(); + expect(body).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + const frames = readFrames(body); + expect(frames.some(item => item.payload?.type === "response.failed")).toBe(true); + expect(frames.some(item => item.payload?.type === "response.completed")).toBe(false); } else { expect(response.status).toBe(502); expect(await response.text()).toContain("undeclared"); @@ -110,4 +127,74 @@ describe("native passthrough inbound tool catalogs", () => { } } + for (const surface of ["responses", "chat", "anthropic"] as const) { + test(`${surface} endpoint refuses the undeclared call in its own wire without a successful terminal`, async () => { + const savedFetch = globalThis.fetch; + let upstreamRequests = 0; + globalThis.fetch = (async () => { + upstreamRequests++; + const writeCall = { ...call, name: "Write" }; + return new Response([ + frame("response.output_item.added", { output_index: 0, item: writeCall }), + frame("response.completed", { response: { id: "resp_wire_refusal", status: "completed", output: [writeCall] } }), + ].join("\n\n") + "\n\n", { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + try { + takeSpendHome(); + const model = "fixture/deepseek-v4-flash"; + const schema = { type: "object" }; + const input = surface === "responses" + ? { input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }], + tools: [{ type: "function", name: "Read", parameters: schema }] } + : surface === "chat" + ? { messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "Read", parameters: schema } }] } + : { max_tokens: 64, messages: [{ role: "user", content: "hi" }], + tools: [{ name: "Read", input_schema: schema }] }; + const path = surface === "responses" ? "responses" : surface === "chat" ? "chat/completions" : "messages"; + const request = new Request(`http://localhost/v1/${path}`, { + method: "POST", headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" }, + body: JSON.stringify({ model, stream: true, ...input }), + }); + const context = { model: "", provider: "" }; + const response = surface === "responses" ? await handleResponses(request, config, context) + : surface === "chat" ? await handleChatCompletions(request, config, context) + : await handleClaudeMessages(request, config, context); + const body = await response.text(); + expect(upstreamRequests).toBeGreaterThan(0); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + const frames = readFrames(body); + const payloads = frames.flatMap(item => item.payload ? [item.payload] : []); + expect(payloads.some(item => item.type === "response.completed")).toBe(false); + if (surface === "responses") { + const failed = frames.filter(item => item.payload?.type === "response.failed"); + expect(failed).toHaveLength(1); + expect(failed[0]?.event).toBe("response.failed"); + expect(failed[0]?.payload).toMatchObject({ response: { status: "failed", error: { code: UNDECLARED_TOOL_CALL_ERROR_CODE } } }); + // [DONE] closes a canonical failure as well; it is not a successful Responses terminal. + expect(frames.at(-1)?.data).toBe("[DONE]"); + } else { + expect(frames.some(item => item.data === "[DONE]")).toBe(false); + const errors = frames.filter(item => typeof item.payload?.error === "object" && item.payload.error !== null); + expect(errors).toHaveLength(1); + expect(errors[0]?.payload).toMatchObject({ error: { message: expect.stringContaining("undeclared") } }); + if (surface === "chat") { + expect(errors[0]?.event).toBeUndefined(); + expect(errors[0]?.payload?.type).toBeUndefined(); + expect(typeof (errors[0]?.payload?.error as { type?: unknown }).type).toBe("string"); + const choices = payloads.flatMap(item => Array.isArray(item.choices) ? item.choices : []); + expect(choices.some(choice => choice.finish_reason != null)).toBe(false); + } else { + expect(errors[0]?.event).toBe("error"); + expect(errors[0]?.payload?.type).toBe("error"); + expect(frames.some(item => item.event === "message_stop" || item.payload?.type === "message_stop")).toBe(false); + expect(payloads.some(item => item.type === "message_delta" + && (item.delta as { stop_reason?: unknown } | undefined)?.stop_reason != null)).toBe(false); + } + } + } finally { globalThis.fetch = savedFetch; } + }); + } + }); From af22f86f5ecbafc77ea0af17530d1ae9c599bf87 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:22:38 +0900 Subject: [PATCH 7/8] fix(responses): scope undeclared-tool refusal to the caller's wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat and Anthropic inbounds deliver partial/deferred tool catalogs — the client's own registry owns the full declaration boundary, so refusing an undeclared name against the translated partial catalog killed legitimate deferred-tool streams mid-turn. Scope the refusal to the Responses wire; on deferred wires only an explicit empty catalog stays deny-all, carried through translation as clientDeclaredEmptyToolCatalog and read verbatim from replayed bodies via hasExplicitEmptyToolCatalog. Normalization still runs wherever a declared name exists. The refusal now keeps undeclared_tool_call across the buffered envelope, the Chat stream projection, and the Anthropic classification so a deterministic catalog violation arrives as api_error, never a retryable overloaded_error. --- .../docs/fr/reference/proxy-formats.md | 11 +- .../docs/ja/reference/proxy-formats.md | 11 +- .../docs/ko/reference/proxy-formats.md | 11 +- .../content/docs/reference/proxy-formats.md | 40 ++++--- .../docs/ru/reference/proxy-formats.md | 11 +- .../docs/tr/reference/proxy-formats.md | 11 +- src/bridge/errors.ts | 5 + src/chat/outbound.ts | 5 + src/claude/outbound.ts | 9 +- src/lib/errors.ts | 7 ++ src/server/chat-completions.ts | 10 +- src/server/claude-messages.ts | 20 +++- src/server/responses-undeclared-tool-guard.ts | 35 +++++- src/server/responses/core-options.ts | 7 ++ src/server/responses/passthrough-delivery.ts | 15 ++- src/server/responses/passthrough-dispatch.ts | 17 ++- src/server/responses/request-prepare.ts | 1 + structure/transports/responses-wire-shapes.md | 40 +++---- structure/transports/responses.md | 2 +- .../passthrough-deferred-tools.test.ts | 100 ++++++++++++++++++ ...nses-undeclared-tool-guard-inbound.test.ts | 56 +++++++++- 21 files changed, 343 insertions(+), 81 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/proxy-formats.md b/docs-site/src/content/docs/fr/reference/proxy-formats.md index 58fa70374e9..5535c90ade6 100644 --- a/docs-site/src/content/docs/fr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/fr/reference/proxy-formats.md @@ -32,13 +32,14 @@ Sur une requête Responses hors combo, OpenCodex réécrit ce 403 de la liste au ## Appels d’outils non déclarés -Lorsqu’une requête déclare un catalogue d’outils, une réponse en passthrough natif qui appelle un outil exécuté par le client hors de ce catalogue fait échouer le tour au lieu de relayer l’appel. Cela s’applique à tous les dialectes entrants : Responses, Chat Completions et Messages. En streaming, l’échec présenté au client dépend du point de terminaison : +Sur le point de terminaison Responses, lorsqu’une requête déclare un catalogue d’outils, une réponse en passthrough natif qui appelle un outil exécuté par le client hors de ce catalogue fait échouer le tour au lieu de relayer l’appel : un événement `response.failed` avec le code d’erreur `undeclared_tool_call`, puis `data: [DONE]` ; le JSON mis en mémoire tampon renvoie HTTP 502 avec le même code. Le refus intervient avant que la réponse puisse entrer dans l’état de continuation, et il n’est désactivé que pour les fournisseurs avec `authMode: "forward"` ou pour une requête sans catalogue déclaré. -- **Responses :** un événement `response.failed` avec le code d’erreur `undeclared_tool_call`, puis `data: [DONE]`. -- **Chat Completions :** une trame `data:` contenant un objet `error` au format OpenAI, puis la fermeture du flux sans fin de succès ni `[DONE]`. -- **Messages :** une trame Anthropic `event: error` avec le corps `{ "type": "error", "error": ... }`, sans `message_stop` réussi ni `[DONE]`. Le code d’erreur Responses n’est pas conservé. +Les points de terminaison Chat Completions et Messages honorent les catalogues d’outils différés : ces clients peuvent ne déclarer qu’un catalogue partiel et découvrir le reste à l’exécution via leur propre registre d’outils, qui — et non l’instantané de catalogue traduit — détient la frontière complète de déclaration. Un appel hors de la liste déclarée est relayé pour que le client le valide ; un appel inventé contre un `tools: []` explicitement vide est toujours refusé, car aucun registre différé n’existe sous une déclaration vide. Ces refus conservent le code `undeclared_tool_call` sur chaque voie : -La réponse JSON mise en mémoire tampon renvoie un HTTP 502 avec une erreur qui nomme l’outil ; le format d’erreur dépend du point de terminaison (l’`upstream_error` interne est traduit pour les clients Chat et Messages). Le refus intervient avant que la réponse puisse entrer dans l’état de continuation. Il n’est désactivé que pour les fournisseurs avec `authMode: "forward"` ou pour une requête sans catalogue déclaré. Un catalogue explicitement vide est considéré comme déclaré et n’autorise donc aucun outil. +- **Chat Completions :** une trame `data:` contenant un objet `error` au format OpenAI portant le code, puis la fermeture du flux sans fin de succès ni `[DONE]` ; le JSON mis en mémoire tampon renvoie HTTP 502 avec le code. +- **Messages :** une trame Anthropic `event: error` dont le corps est `{ "type": "error", "error": { "type": "api_error", "code": "undeclared_tool_call", ... } }`, sans `message_stop` réussi ni `[DONE]` ; le JSON mis en mémoire tampon renvoie HTTP 502 `api_error` avec le code. Une violation déterministe du catalogue n’est jamais rapportée comme un `overloaded_error` réessayable (HTTP 529). + +Par ailleurs, sur chaque voie entrante, un appel dans l’espace de noms `default.` d’un fournisseur est normalisé vers le nom d’outil nu déclaré par la requête (`default.Read` → `Read`). ## Présentation du point de terminaison diff --git a/docs-site/src/content/docs/ja/reference/proxy-formats.md b/docs-site/src/content/docs/ja/reference/proxy-formats.md index 1c97f9bddfc..aa05232953d 100644 --- a/docs-site/src/content/docs/ja/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ja/reference/proxy-formats.md @@ -26,13 +26,14 @@ provider events → internal adapter events → client dialect ## 宣言されていないツール呼び出し -リクエストでツールカタログが宣言されている場合、ネイティブのパススルー応答がそのカタログにないクライアント実行ツールを呼び出すと、その呼び出しを中継せずにターンを失敗させます。この動作はすべての受信ダイアレクト(Responses、Chat Completions、Messages)に適用されます。ストリーミング時にクライアントへ返す失敗形式は、エンドポイントによって異なります。 +Responses エンドポイントでは、リクエストがツールカタログを宣言している場合、ネイティブのパススルー応答がそのカタログにないクライアント実行ツールを呼び出すと、その呼び出しを中継せずにターンを失敗させます: エラーコード `undeclared_tool_call` を含む `response.failed` イベントに続き `data: [DONE]` を送り、バッファリングされた JSON は同じコードで HTTP 502 を返します。この拒否は応答が継続状態に入る前に実行され、`authMode: "forward"` のプロバイダーまたは宣言済みカタログのないリクエストに限り無効になります。 -- **Responses:** エラーコード `undeclared_tool_call` を含む `response.failed` イベントを送り、その後に `data: [DONE]` を送ります。 -- **Chat Completions:** OpenAI 形式の `error` オブジェクトを含む `data:` フレームを送り、成功完了や `[DONE]` を送らずにストリームを閉じます。 -- **Messages:** `{ "type": "error", "error": ... }` 本文を含む Anthropic の `event: error` フレームを送り、成功した `message_stop` や `[DONE]` は送りません。Responses のエラーコードは引き継がれません。 +Chat Completions と Messages の各エンドポイントは遅延ツールカタログを尊重します: これらのクライアントはカタログの一部だけを宣言し、残りを実行時に自身のツールレジストリを通じて発見できます。完全な宣言境界を所有するのは翻訳されたカタログのスナップショットではなくそのレジストリです。宣言リスト外の呼び出しはクライアントが検証するために中継され、明示的に空の `tools: []` に対する呼び出しは依然として拒否されます — 空の宣言の下には遅延レジストリが存在しないためです。これらの拒否もすべてのワイヤで `undeclared_tool_call` コードを保持します: -バッファリングされた JSON 応答は、ツール名を含むエラーとともに HTTP 502 を返します。エラー形式はエンドポイント固有で、内部の `upstream_error` は Chat および Messages クライアント向けに変換されます。この拒否処理は、応答が継続状態に入る前に実行されます。この拒否が行われないのは、`authMode: "forward"` のプロバイダー、またはカタログが宣言されていないリクエストに限られます。明示的に空のカタログも宣言済みとして扱われ、どのツールも許可しません。 +- **Chat Completions:** そのコードを含む OpenAI 形式の `error` オブジェクトを含む `data:` フレームを送り、成功完了や `[DONE]` を送らずにストリームを閉じます。バッファリングされた JSON はコード付きの HTTP 502 を返します。 +- **Messages:** 本文が `{ "type": "error", "error": { "type": "api_error", "code": "undeclared_tool_call", ... } }` の Anthropic `event: error` フレームを送り、成功した `message_stop` や `[DONE]` は送りません。バッファリングされた JSON はコード付きの HTTP 502 `api_error` を返します。確定的なカタログ違反は再試行可能な `overloaded_error`(HTTP 529)として報告されません。 + +これとは別に、すべての受信ワイヤで、プロバイダーの `default.` 名前空間の呼び出しはリクエストが宣言した素のツール名に正規化されます(`default.Read` → `Read`)。 ## エンドポイントの概要 diff --git a/docs-site/src/content/docs/ko/reference/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md index 25102500551..9cad2c81f9a 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -31,13 +31,14 @@ Responses 표현이 이 연결의 중심입니다. 네이티브 호환 경로는 ## 선언되지 않은 도구 호출 -요청에서 도구 카탈로그를 선언한 경우, 네이티브 패스스루 응답이 해당 카탈로그에 없는 클라이언트 실행 도구를 호출하면 그 호출을 전달하는 대신 턴을 실패 처리합니다. 이 동작은 Responses, Chat Completions, Messages를 비롯한 모든 인바운드 방언에 적용됩니다. 스트리밍 실패의 클라이언트 표시 형식은 엔드포인트마다 다릅니다. +Responses 엔드포인트에서 요청이 도구 카탈로그를 선언한 경우, 네이티브 패스스루 응답이 그 카탈로그에 없는 클라이언트 실행 도구를 호출하면 호출을 전달하는 대신 턴을 실패 처리합니다: 오류 코드 `undeclared_tool_call`이 포함된 `response.failed` 이벤트와 `data: [DONE]`을 보내고, 버퍼링된 JSON은 같은 코드와 함께 HTTP 502를 반환합니다. 이 거부는 응답이 후속 처리 상태에 들어가기 전에 실행되며, `authMode: "forward"` 공급자이거나 요청에 선언된 카탈로그가 없는 경우에만 적용되지 않습니다. -- **Responses:** 오류 코드 `undeclared_tool_call`이 포함된 `response.failed` 이벤트를 보낸 다음 `data: [DONE]`을 보냅니다. -- **Chat Completions:** OpenAI 형식의 `error` 객체가 포함된 `data:` 프레임을 보낸 뒤, 성공 완료나 `[DONE]` 없이 스트림을 닫습니다. -- **Messages:** `{ "type": "error", "error": ... }` 본문이 포함된 Anthropic `event: error` 프레임을 보냅니다. 성공적인 `message_stop`이나 `[DONE]`은 보내지 않으며, Responses 오류 코드는 보존되지 않습니다. +Chat Completions와 Messages 엔드포인트는 지연된 도구 카탈로그를 지원합니다: 이러한 클라이언트는 카탈로그의 일부만 선언하고 나머지를 자체 도구 레지스트리를 통해 런타임에 발견할 수 있으며, 전체 선언 경계는 번역된 카탈로그 스냅숏이 아니라 그 레지스트리가 소유합니다. 선언된 목록에 없는 호출은 클라이언트가 검증하도록 전달되고, 명시적으로 빈 `tools: []`에 대한 호출은 여전히 거부됩니다 — 빈 선언 아래에는 지연 레지스트리가 존재하지 않기 때문입니다. 이러한 거부도 모든 와이어에서 `undeclared_tool_call` 코드를 유지합니다: -버퍼링된 JSON 응답은 도구 이름이 포함된 오류와 함께 HTTP 502를 반환합니다. 오류 형식은 엔드포인트별로 다르며, 내부 `upstream_error`는 Chat 및 Messages 클라이언트용으로 변환됩니다. 이 거부 처리는 응답이 후속 처리 상태에 들어가기 전에 실행됩니다. `authMode: "forward"` 공급자이거나 요청에 선언된 카탈로그가 없는 경우에만 이 거부 처리가 적용되지 않습니다. 명시적으로 빈 카탈로그도 선언된 것으로 간주되므로 어떤 도구도 허용하지 않습니다. +- **Chat Completions:** 코드를 포함하는 OpenAI 형식 `error` 객체가 담긴 `data:` 프레임을 보낸 뒤, 성공 완료나 `[DONE]` 없이 스트림을 닫습니다. 버퍼링된 JSON은 코드와 함께 HTTP 502를 반환합니다. +- **Messages:** 본문이 `{ "type": "error", "error": { "type": "api_error", "code": "undeclared_tool_call", ... } }`인 Anthropic `event: error` 프레임을 보내며, 성공적인 `message_stop`이나 `[DONE]`은 보내지 않습니다. 버퍼링된 JSON은 코드가 포함된 HTTP 502 `api_error`를 반환합니다. 확정적인 카탈로그 위반은 재시도 가능한 `overloaded_error`(HTTP 529)로 보고되지 않습니다. + +별도로, 모든 인바운드 와이어에서 공급자의 `default.` 네임스페이스 호출은 요청이 선언한 원래 도구 이름으로 정규화됩니다(`default.Read` → `Read`). ## 엔드포인트 개요 diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index fd4a8ac334a..0168480b8e6 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -73,21 +73,31 @@ frames in order. This handshake policy is separate from the Responses WebSocket ## Undeclared tool calls -When a request declares a tool catalog, a native passthrough reply that calls a client-executed -tool outside that catalog fails the turn instead of relaying the call — on every inbound dialect: -Responses, Chat Completions, and Messages. The client-facing streaming failure depends on the endpoint: - -- **Responses:** a `response.failed` event with error code `undeclared_tool_call`, then `data: [DONE]`. -- **Chat Completions:** a `data:` frame containing an OpenAI-style `error` object, then the stream - closes without a success completion or `[DONE]`. -- **Messages:** an Anthropic `event: error` frame with a `{ "type": "error", "error": ... }` body, - without a successful `message_stop` or `[DONE]`. The Responses error code is not preserved. - -Buffered JSON returns HTTP 502 with an error naming the tool, using the endpoint's error envelope -(the internal `upstream_error` is translated for Chat and Messages clients). The refusal runs -before the response can enter continuation state, and it stands down only for `authMode: "forward"` -providers or a request with no declared catalog. An explicitly empty catalog counts as a declared -catalog and therefore authorizes nothing. +On the Responses endpoint, when a request declares a tool catalog, a native passthrough reply +that calls a client-executed tool outside that catalog fails the turn instead of relaying the +call: a `response.failed` event with error code `undeclared_tool_call`, then `data: [DONE]`; +buffered JSON returns HTTP 502 with the same code. The refusal runs before the response can +enter continuation state, and it stands down only for `authMode: "forward"` providers or a +request with no declared catalog. + +The Chat Completions and Messages endpoints honor deferred tool catalogs: those clients may +declare a partial catalog and discover the rest at runtime through their own tool registry, +which — not the translated catalog snapshot — owns the full declaration boundary. A call +outside the declared list is relayed for the client to validate; a call invented against an +explicitly empty `tools: []` is still refused, because no deferred registry exists under an +empty declaration. Those refusals keep the `undeclared_tool_call` code on every wire: + +- **Chat Completions:** a `data:` frame containing an OpenAI-style `error` object carrying the + code, then the stream closes without a success completion or `[DONE]`; buffered JSON returns + HTTP 502 with the code. +- **Messages:** an Anthropic `event: error` frame whose body is + `{ "type": "error", "error": { "type": "api_error", "code": "undeclared_tool_call", ... } }`, + without a successful `message_stop` or `[DONE]`; buffered JSON returns HTTP 502 `api_error` + with the code. A deterministic catalog violation is never reported as a retryable + `overloaded_error` (HTTP 529). + +Separately, on every inbound wire, a provider's `default.`-namespaced call is normalized back +to the bare tool name the request declared (`default.Read` → `Read`). ## Endpoint overview diff --git a/docs-site/src/content/docs/ru/reference/proxy-formats.md b/docs-site/src/content/docs/ru/reference/proxy-formats.md index c87ce3e99a1..ad8d9d3f3b8 100644 --- a/docs-site/src/content/docs/ru/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ru/reference/proxy-formats.md @@ -33,13 +33,14 @@ control и safety ответа всё равно происходят на гр ## Вызовы незаявленных инструментов -Если в запросе объявлен каталог инструментов, ответ в режиме native passthrough, вызывающий инструмент, выполняемый клиентом, вне этого каталога, завершает ход ошибкой вместо передачи вызова дальше. Это относится ко всем входящим диалектам: Responses, Chat Completions и Messages. Формат потоковой ошибки для клиента зависит от endpoint: +На endpoint'е Responses, если запрос объявляет каталог инструментов, ответ в режиме native passthrough, вызывающий выполняемый клиентом инструмент вне этого каталога, завершает ход ошибкой вместо передачи вызова: событие `response.failed` с кодом `undeclared_tool_call`, затем `data: [DONE]`; буферизованный JSON возвращает HTTP 502 с тем же кодом. Отказ срабатывает до того, как ответ попадёт в состояние продолжения, и отключается только для провайдеров с `authMode: "forward"` или запросов без объявленного каталога. -- **Responses:** событие `response.failed` с кодом ошибки `undeclared_tool_call`, затем `data: [DONE]`. -- **Chat Completions:** кадр `data:` с объектом `error` в формате OpenAI, после которого поток закрывается без успешного завершения и без `[DONE]`. -- **Messages:** кадр Anthropic `event: error` с телом `{ "type": "error", "error": ... }`, без успешного `message_stop` и без `[DONE]`. Код ошибки Responses не сохраняется. +Endpoint'ы Chat Completions и Messages поддерживают отложенные каталоги инструментов: их клиенты могут объявить лишь часть каталога и обнаружить остальное во время выполнения через собственный реестр инструментов, который — а не переведённый снимок каталога — владеет полной границей объявления. Вызов вне объявленного списка передаётся клиенту для проверки; вызов против явно пустого `tools: []` по-прежнему отклоняется, ведь под пустым объявлением отложенного реестра не существует. Эти отказы сохраняют код `undeclared_tool_call` на каждом канале: -Буферизованный JSON возвращает HTTP 502 с ошибкой, содержащей имя инструмента; используется формат ошибки конкретного endpoint (внутренний `upstream_error` преобразуется для клиентов Chat и Messages). Отказ срабатывает до того, как ответ попадёт в состояние продолжения. Проверка отключается только для провайдеров с `authMode: "forward"` или запросов без объявленного каталога. Явно пустой каталог считается объявленным и поэтому не разрешает ни одного инструмента. +- **Chat Completions:** кадр `data:` с объектом `error` в формате OpenAI, несущим код, после чего поток закрывается без успешного завершения и без `[DONE]`; буферизованный JSON возвращает HTTP 502 с кодом. +- **Messages:** кадр Anthropic `event: error` с телом `{ "type": "error", "error": { "type": "api_error", "code": "undeclared_tool_call", ... } }`, без успешного `message_stop` и `[DONE]`; буферизованный JSON возвращает HTTP 502 `api_error` с кодом. Детерминированное нарушение каталога никогда не отображается как повторяемая `overloaded_error` (HTTP 529). + +Отдельно: на каждом входящем канале вызов провайдера в пространстве имён `default.` нормализуется обратно к «голому» имени инструмента из запроса (`default.Read` → `Read`). ## Обзор endpoint'ов diff --git a/docs-site/src/content/docs/tr/reference/proxy-formats.md b/docs-site/src/content/docs/tr/reference/proxy-formats.md index 8a66d3f3d66..6ca40e9fd11 100644 --- a/docs-site/src/content/docs/tr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/tr/reference/proxy-formats.md @@ -35,13 +35,14 @@ Kombo olmayan bir Responses isteğinde OpenCodex, izin listesindeki bu 403'ü `s ## Bildirilmemiş araç çağrıları -Bir istek bir araç kataloğu bildirdiğinde, katalog dışındaki istemci tarafından çalıştırılan bir aracı çağıran native passthrough yanıtı, çağrıyı aktarmak yerine turu başarısız kılar. Bu, gelen tüm lehçeler için geçerlidir: Responses, Chat Completions ve Messages. İstemcinin akış sırasında göreceği hata, endpoint’e göre değişir: +Responses uç noktasında, bir istek araç kataloğu bildirdiğinde, bu kataloğun dışında istemci tarafından çalıştırılan bir aracı çağıran native passthrough yanıtı, çağrıyı aktarmak yerine turu başarısız kılar: `undeclared_tool_call` hata kodlu bir `response.failed` olayı ve ardından `data: [DONE]`; arabelleğe alınmış JSON aynı kodla HTTP 502 döndürür. Ret, yanıt devam durumuna girmeden önce gerçekleşir ve yalnızca `authMode: "forward"` sağlayıcıları veya bildirilmiş kataloğu olmayan istekler için devre dışıdır. -- **Responses:** `undeclared_tool_call` hata kodunu içeren bir `response.failed` olayı, ardından `data: [DONE]`. -- **Chat Completions:** OpenAI biçiminde bir `error` nesnesi içeren `data:` çerçevesi gönderilir; ardından başarılı bir tamamlanma veya `[DONE]` olmadan akış kapatılır. -- **Messages:** `{ "type": "error", "error": ... }` gövdeli bir Anthropic `event: error` çerçevesi gönderilir; başarılı bir `message_stop` veya `[DONE]` gönderilmez. Responses hata kodu korunmaz. +Chat Completions ve Messages uç noktaları ertelenmiş araç kataloglarını destekler: bu istemciler kataloğun yalnızca bir bölümünü bildirebilir ve kalanını çalışma zamanında kendi araç kayıt defterleri aracılığıyla keşfedebilir — tam bildirim sınırına çevrilmiş katalog anlık görüntüsü değil bu kayıt defteri sahiptir. Bildirilen listenin dışındaki bir çağrı, istemcinin doğrulaması için aktarılır; açıkça boş `tools: []` üzerine kurulan bir çağrı yine de reddedilir, çünkü boş bir bildirimin altında ertelenmiş kayıt defteri bulunmaz. Bu retler de her bağlantıda `undeclared_tool_call` kodunu korur: -Arabelleğe alınmış JSON yanıtı, araç adını içeren bir hatayla HTTP 502 döndürür; endpoint’e özgü hata biçimini kullanır (dahili `upstream_error`, Chat ve Messages istemcileri için dönüştürülür). Ret, yanıt devam durumuna girmeden önce gerçekleşir. Bu ret yalnızca `authMode: "forward"` sağlayıcıları veya bildirilmiş kataloğu olmayan istekler için devre dışıdır. Açıkça boş bir katalog bildirilmiş sayılır ve hiçbir araca izin vermez. +- **Chat Completions:** kodu taşıyan OpenAI biçimli bir `error` nesnesi içeren `data:` çerçevesi gönderilir; ardından başarılı tamamlanma veya `[DONE]` olmadan akış kapatılır. Arabelleğe alınmış JSON kodla birlikte HTTP 502 döndürür. +- **Messages:** gövdesi `{ "type": "error", "error": { "type": "api_error", "code": "undeclared_tool_call", ... } }` olan bir Anthropic `event: error` çerçevesi gönderilir; başarılı `message_stop` veya `[DONE]` gönderilmez. Arabelleğe alınmış JSON kodla birlikte HTTP 502 `api_error` döndürür. Deterministik bir katalog ihlali asla yeniden denenebilir `overloaded_error` (HTTP 529) olarak raporlanmaz. + +Ayrıca, her gelen bağlantıda sağlayıcının `default.` ad alanlı çağrısı, isteğin bildirdiği yalın araç adına normalleştirilir (`default.Read` → `Read`). ## Uç nokta genel bakışı diff --git a/src/bridge/errors.ts b/src/bridge/errors.ts index c9cb6a2a9c7..4c464e238d3 100644 --- a/src/bridge/errors.ts +++ b/src/bridge/errors.ts @@ -13,6 +13,7 @@ import { CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, SPEND_LEDGER_STORAGE_UNAVAILABLE_CODE, + UNDECLARED_TOOL_CALL_ERROR_CODE, type OcxErrorPayload, } from "../lib/errors"; @@ -31,6 +32,10 @@ export function formatErrorResponse( error.code = CYBER_POLICY_ERROR_CODE; error.type = cyberPolicyErrorType(type); } + // The undeclared-tool refusal is deterministic, not a transient upstream failure: its code + // survives so the Anthropic translator can classify it instead of folding it into a + // retryable overload (and so clients see the SSE path's rejection identity). + if (options?.code === UNDECLARED_TOOL_CALL_ERROR_CODE) error.code = options.code; // Only the allowlisted transport verdicts survive this formatter. Do not forward // arbitrary provider codes, and preserve the existing cyber-policy precedence. const replayBlocked = error.code !== CYBER_POLICY_ERROR_CODE diff --git a/src/chat/outbound.ts b/src/chat/outbound.ts index 8e1b684861b..d5da058caa8 100644 --- a/src/chat/outbound.ts +++ b/src/chat/outbound.ts @@ -19,6 +19,7 @@ import { CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, isCyberPolicyMessage, + UNDECLARED_TOOL_CALL_ERROR_CODE, } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; import { createSseBlockBuffer, sseDataPayload } from "../server/sse-payload-rewrite"; @@ -196,6 +197,10 @@ export function chatCompletionsStreamErrorPayload( } else if (isCyberPolicyCode(details?.code) || classified.code === CYBER_POLICY_ERROR_CODE) { classified.code = CYBER_POLICY_ERROR_CODE; classified.type = cyberPolicyErrorType(details?.type); + } else if (details?.code === UNDECLARED_TOOL_CALL_ERROR_CODE) { + // The guard's deterministic refusal keeps its wire identity past classifyError's + // generic 5xx remap — same code the buffered Chat error and the SSE frame carry. + classified.code = UNDECLARED_TOOL_CALL_ERROR_CODE; } else if (details?.code !== undefined && details.code !== null && !classified.code) { classified.code = details.code; } diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index 60924c92def..a4e0ef39843 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -11,7 +11,7 @@ * - errors: {type:"error", error:{type,message}}; may arrive mid-stream after HTTP 200. */ import { createHash } from "node:crypto"; -import { httpStatusFromTerminalError } from "../lib/errors"; +import { httpStatusFromTerminalError, UNDECLARED_TOOL_CALL_ERROR_CODE } from "../lib/errors"; import { isTransientUpstreamStatus } from "../lib/upstream-retry"; import { isTranslatorBudgetExceededError, @@ -804,11 +804,14 @@ export function responsesSseToAnthropicSse( // Unclassified status-absent response.failed (relaySseWithFailedTail synthetic // tail) still lands on a transient 5xx here — the mid-stream reset shape maps to // overloaded_error by design. + // The undeclared-tool refusal is deterministic and proxy-owned, not an upstream + // condition: keep its api_error type and code instead of a retryable overload. + const deterministic = code === UNDECLARED_TOOL_CALL_ERROR_CODE; fail( status, message, - true, - code === "context_length_exceeded" ? code : undefined, + !deterministic, + deterministic || code === "context_length_exceeded" ? code : undefined, ); break; } diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 43936634154..9de95942dfe 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -18,6 +18,13 @@ export const ENCRYPTED_FUNCTION_OUTPUT_REJECTION = */ export const SEND_BUDGET_EXHAUSTED_CODE = "request_send_budget_exhausted"; export const SPEND_LEDGER_STORAGE_UNAVAILABLE_CODE = "spend_ledger_storage_unavailable"; +/** + * Wire code for the undeclared-tool guard's deterministic refusal. Endpoint translators + * read it to keep the refusal non-retryable instead of folding it into the transient + * upstream-error class. Declared here rather than in the guard module because the shared + * envelope formatter decides whether the identity survives serialization. + */ +export const UNDECLARED_TOOL_CALL_ERROR_CODE = "undeclared_tool_call"; /** First nonblank string across the canonical upstream error paths, in priority order. */ export function upstreamErrorMessageFromPayload(payload: unknown): string | undefined { diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 2c561039bab..7dba21bce9f 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -19,7 +19,7 @@ import { responsesJsonToChatCompletion, responsesSseToChatCompletionsSse, } from "../chat/outbound"; -import { classifyError, cyberPolicyErrorType, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors"; +import { classifyError, cyberPolicyErrorType, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, UNDECLARED_TOOL_CALL_ERROR_CODE } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; import { resolveClientRetryAfter } from "../lib/retry-after"; import { @@ -455,6 +455,10 @@ async function handleChatCompletionsWithBudget( abortSignal: req.signal, // Body is Responses-shaped by now, but the client spoke Chat Completions. inboundWire: "chat", + // A Chat caller may send an explicit empty `tools` array — a deny-all declaration the + // translation drops along with the whole catalog. Carried separately so the + // undeclared-tool guard can still enforce it; absent/partial catalogs stay deferred. + clientDeclaredEmptyToolCatalog: Array.isArray(chatBody.tools) && chatBody.tools.length === 0, ...((settledRoute?.combo || settledRoute?.routeKind === "policy") && droidDefaultEffort ? { droidDefaultEffort } : {}), @@ -543,6 +547,10 @@ async function handleChatCompletionsWithBudget( // Structured model_not_found must win over classifyError's generic remaps. classified.code = "model_not_found"; classified.type = "invalid_request_error"; + } else if (upstreamCode === UNDECLARED_TOOL_CALL_ERROR_CODE) { + // The guard's deterministic refusal keeps its wire identity — same code the + // streamed `response.failed` frame and the Anthropic surface carry. + classified.code = UNDECLARED_TOOL_CALL_ERROR_CODE; } else if (upstreamCode !== undefined && upstreamCode !== null && classified.code == null) { classified.code = upstreamCode; } diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index e9787d79b82..878ba151003 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -40,6 +40,7 @@ import { REPLAY_REFUSED_STATUS, UPSTREAM_RESET_REPLAY_REFUSED_CODE, } from "../lib/upstream-retry"; +import { UNDECLARED_TOOL_CALL_ERROR_CODE } from "../lib/errors"; import { resolveClientRetryAfter } from "../lib/retry-after"; import { anthropicRateLimitHeaders } from "./anthropic-rate-limit-headers"; import { upstreamMessagesRequestIdHeaders } from "./messages-response-headers"; @@ -1348,6 +1349,12 @@ async function handleClaudeMessagesWithBudget( // Without this the replay would look native and a Responses-scoped wire default // would fire, disagreeing with the pre-flight decision above. inboundWire: "anthropic", + // An explicit empty `tools` array is a deny-all declaration the translation drops with + // the rest of the catalog; carried separately so the undeclared-tool guard can still + // enforce it while partial and absent catalogs stay deferred. + clientDeclaredEmptyToolCatalog: isRec(anthropicBody) + && Array.isArray(anthropicBody.tools) + && anthropicBody.tools.length === 0, nativeReasoningReplay, nativeReasoningMint, claudeGoAffinity: { sessionLane: claudeGoSessionLane }, @@ -1374,11 +1381,13 @@ async function handleClaudeMessagesWithBudget( // Re-shape the OpenAI-style error envelope into the Anthropic one, preserving status. let message = `upstream error (${response.status})`; let contextError = false; + let catalogRefusal = false; try { const text = await response.text(); try { const parsed = JSON.parse(text) as { error?: { message?: string; type?: string; code?: unknown } | string; message?: string }; contextError = typeof parsed?.error === "object" && parsed.error?.code === "context_length_exceeded"; + catalogRefusal = typeof parsed?.error === "object" && parsed.error?.code === UNDECLARED_TOOL_CALL_ERROR_CODE; const nested = typeof parsed?.error === "object" && parsed.error ? parsed.error.message : undefined; const flat = typeof parsed?.error === "string" ? parsed.error : parsed?.message; message = nested || flat || (text ? `upstream error (${response.status}): ${text.slice(0, 400)}` : message); @@ -1407,7 +1416,10 @@ async function handleClaudeMessagesWithBudget( const nativeMainFence = response.status === 503 && upstreamRetryAfter?.trim() === "1" && message === CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE; - const transient = !replayRefusal && !nativeMainFence && !contextError && isTransientUpstreamStatus(response.status); + // The undeclared-tool guard's refusal is deterministic: the same request produces the + // same verdict, so it must not arrive as a retryable overload like a transient 5xx. + const transient = !replayRefusal && !nativeMainFence && !contextError && !catalogRefusal + && isTransientUpstreamStatus(response.status); const outStatus = replayRefusal ? REPLAY_REFUSED_STATUS : nativeMainFence ? 503 : contextError ? 400 : transient ? 529 : response.status; @@ -1419,7 +1431,11 @@ async function handleClaudeMessagesWithBudget( outStatus, message, undefined, - replayRefusal ? UPSTREAM_RESET_REPLAY_REFUSED_CODE : contextError ? "context_length_exceeded" : undefined, + replayRefusal + ? UPSTREAM_RESET_REPLAY_REFUSED_CODE + : contextError + ? "context_length_exceeded" + : catalogRefusal ? UNDECLARED_TOOL_CALL_ERROR_CODE : undefined, )), { status: outStatus, headers: outHeaders, diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index 52cc338e509..e0a8b7029ad 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -1,3 +1,4 @@ +import { UNDECLARED_TOOL_CALL_ERROR_CODE } from "../lib/errors"; import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous, @@ -78,7 +79,7 @@ const NAMELESS_TOOL_SPEC_TYPES = new Set([ /** An upstream-supplied name reaches the error message; keep it bounded. */ const MAX_REPORTED_NAME_CHARS = 100; -export const UNDECLARED_TOOL_CALL_ERROR_CODE = "undeclared_tool_call"; +export { UNDECLARED_TOOL_CALL_ERROR_CODE }; function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); @@ -370,6 +371,24 @@ export function hasExplicitWireToolCatalog(body: unknown): boolean { ); } +/** + * Whether the caller declared an explicitly EMPTY catalog — `tools: []` or an + * `additional_tools` item with `tools: []`. Unlike an absent or partial catalog this is an + * unambiguous deny-all: no deferred registry can exist under it, so even deferred wires + * enforce it. A hosted-only catalog is NOT empty — it still defers like an absent one. + */ +export function hasExplicitEmptyToolCatalog(body: unknown): boolean { + if (!isPlainObject(body)) return false; + if (Array.isArray(body.tools) && body.tools.length === 0) return true; + if (!Array.isArray(body.input)) return false; + return body.input.some(item => + isPlainObject(item) + && item.type === "additional_tools" + && Array.isArray(item.tools) + && item.tools.length === 0 + ); +} + /** * Evaluates whether an individual output item represents an undeclared tool call. * @@ -711,7 +730,11 @@ export function createUndeclaredToolCallGuardBlockRewrite( providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, declaredBare?: ReadonlySet, declaredCustom?: ReadonlySet, + options?: { refuseUndeclared?: boolean }, ): SseBlockRewrite { + // Deferred-catalog wires (Chat/Anthropic) keep the default-namespace normalization — the + // client can only resolve the bare name it declared — while the refusal itself stays off. + const refuse = options?.refuseUndeclared !== false; let tripped = false; return (block: string) => { if (tripped) return []; @@ -723,10 +746,12 @@ export function createUndeclaredToolCallGuardBlockRewrite( } catch { return [block]; } - const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare, declaredCustom); - if (name !== undefined) { - tripped = true; - return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n"); + if (refuse) { + const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare, declaredCustom); + if (name !== undefined) { + tripped = true; + return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n"); + } } const normalized = normalizeDefaultNamespaceInPayload(parsed, declared, declaredBare); if (normalized.changed) { diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index f64c681d50a..10f14a267a8 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -137,6 +137,13 @@ export interface HandleResponsesOptions { * it. Omitted means a genuine Responses inbound. */ inboundWire?: InboundWire; + /** + * Set by a translating ingress (Chat / Anthropic) when the client sent an explicit empty + * tool catalog. Those wires may defer tool delivery, so their translated body carries no + * readable catalog boundary; an explicit `[]` is still a deny-all declaration and the + * undeclared-tool guard enforces it. Absent catalogs and partial catalogs stay deferred. + */ + clientDeclaredEmptyToolCatalog?: boolean; nativeReasoningReplay?: ReadonlyMap; nativeReasoningMint?: { owner?: NativeReasoningOwner }; /** Droid's per-request effort default; each concrete combo or policy target applies it only if its ladder allows it. */ diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index 22432dc9f4d..25d4531c1ac 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -108,6 +108,7 @@ import { createResponsesFunctionToolRepairBlockRewrite } from "../responses-func import { createUndeclaredToolCallGuardBlockRewrite, currentTurnWireToolCatalogBody, + UNDECLARED_TOOL_CALL_ERROR_CODE, undeclaredToolCallNameInResponse, undeclaredToolCallMessage, normalizeDefaultNamespaceInJson, @@ -856,13 +857,17 @@ export async function deliverPassthroughResponse( : undefined, // Last: every rewrite above can still rename or reshape a call item, so the guard must // compare the names the client will actually receive against the declared catalog. - nativeExchange.undeclaredToolGuardActive + // Deferred-catalog wires keep the rewrite for its normalization half only: a provider's + // `default.X` call still collapses to the declared `X`, but an undeclared name is the + // client's own deferred registry's business, not this proxy's verdict. + nativeExchange.undeclaredToolGuardActive || declaredWireToolNames.size > 0 ? createUndeclaredToolCallGuardBlockRewrite( declaredWireToolNames, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBareWireToolNames, recoverableBareCustomWireToolNames, + { refuseUndeclared: nativeExchange.undeclaredToolGuardActive }, ) : undefined, grokUpstreamEchoEnabled @@ -1337,8 +1342,14 @@ export async function deliverPassthroughResponse( } })(); if (undeclared !== undefined) { - return formatErrorResponse(502, "upstream_error", undeclaredToolCallMessage(undeclared)); + return formatErrorResponse(502, "upstream_error", undeclaredToolCallMessage(undeclared), { + code: UNDECLARED_TOOL_CALL_ERROR_CODE, + }); } + } + // Same split as the SSE rewrite: normalization applies whenever a declared name exists, + // even on wires whose deferred catalog stands the refusal down. + if (declaredWireToolNames.size > 0) { clientJson = normalizeDefaultNamespaceInJson( clientJson, declaredWireToolNames, diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index 33d80678572..58a1d8be395 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -21,6 +21,7 @@ import { imageGenToolCallAliases } from "../responses-image-gen-repair"; import { rememberResponseState, isBodyNonPersistable } from "../../responses/state"; import { currentTurnWireToolCatalogBody, + hasExplicitEmptyToolCatalog, hasExplicitWireToolCatalog, collectDeclaredWireToolNames, collectDeclaredBareWireToolNames, @@ -187,6 +188,7 @@ export async function preparePassthroughExchange( | "selectedForwardHeaders" | "clientRequestedStream" | "inboundWire" + | "clientDeclaredEmptyToolCatalog" | "substituteMainCredential" | "callerAuthHeaders" | "subagentFallbackAccountId" @@ -253,6 +255,7 @@ export async function preparePassthroughExchange( responseStateOptions, clientRequestedStream, inboundWire, + clientDeclaredEmptyToolCatalog, substituteMainCredential, callerAuthHeaders, } = requestState; @@ -531,10 +534,18 @@ export async function preparePassthroughExchange( declaredWireToolNames.add(name); } } + // Chat and Anthropic inbounds may deliver tools deferred/partially: their client registry + // owns the full catalog, so this guard cannot read the translated body as the authorization + // boundary. An explicit empty catalog — flagged by the translating ingress or present + // verbatim in a replayed body — is still a deny-all the caller declared; everything else + // stays deferred to the caller's own tool handling. undeclaredToolGuardActive = ( - declaredWireToolNames.size > 0 - || clientDeclaredNamelessCallTypes.size > 0 - || clientExplicitWireToolCatalog + inboundWire === "responses" + ? declaredWireToolNames.size > 0 + || clientDeclaredNamelessCallTypes.size > 0 + || clientExplicitWireToolCatalog + : clientDeclaredEmptyToolCatalog + || hasExplicitEmptyToolCatalog(clientToolAuthorizationBody) ) && route.provider.authMode !== "forward"; options.nativeControl?.configureToolAuthorization?.( undeclaredToolGuardActive, diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 76d3d000c9d..93768abc700 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -1452,6 +1452,7 @@ export async function prepareResponsesRequest( commitSkillsSnapshot?.(); return { inboundWire, + clientDeclaredEmptyToolCatalog: options.clientDeclaredEmptyToolCatalog === true, translatorBudget, parsed, toolBridgeMaps, diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 0fde2a96611..248d0e4b974 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -300,25 +300,27 @@ executes a top-level tool call, so a hallucinated `apply_patch` — which under as a nested `tools.apply_patch(...)` helper inside `exec` — is refused before it reaches the runtime, where it previously surfaced as a bare `aborted` with the file untouched. -On the native passthrough lane the refusal covers every inbound wire: `chat`, `anthropic` and -`responses` each reject an undeclared client-executed call, in SSE and buffered JSON alike, before -continuation state is stored. The translated delivery paths keep the narrower scope — -`src/server/responses/run-turn-execution.ts` and `src/server/responses/adapter-delivery.ts` still -derive `enforceDeclaredToolNames` from `inboundWire`, and only `responses` refuses undeclared calls -there; `chat` and `anthropic` translated replies relay the call instead. Harnesses on those -endpoints may defer part of their catalog to conserve prompt tokens and discover the rest at -runtime, and both vendor specs leave the client's own runner responsible for validating a tool -call — enforcing membership against a partial catalog would kill those streams mid-turn for a -call the client could execute. - -An explicitly empty catalog still authorizes nothing wherever enforcement runs. A request declaring -an empty tool list is making a statement rather than omitting one, which is how the passthrough -guard reads it through `clientExplicitWireToolCatalog` in -`src/server/responses/passthrough-dispatch.ts`. - -`undeclaredToolGuardActive` still gates namespace normalization and continuation suppression on the -passthrough lane, standing down only for `authMode: "forward"` and requests with no catalog; a -refused turn never enters continuation state. +On the native passthrough lane the refusal matches the translated delivery scope: +`src/server/responses/run-turn-execution.ts`, `src/server/responses/adapter-delivery.ts` and +the native guard in `src/server/responses/passthrough-dispatch.ts` all derive enforcement from +`inboundWire`, so only `responses` refuses an undeclared client-executed call — in SSE and +buffered JSON alike, before continuation state is stored. `chat` and `anthropic` inbound +replies relay the call instead: those harnesses may defer part of their catalog to conserve +prompt tokens and discover the rest at runtime, and both vendor specs leave the client's own +runner responsible for validating it — membership enforcement against a partial catalog would +kill those streams mid-turn for a call the client could execute. + +An explicitly empty catalog authorizes nothing wherever enforcement runs — deferred wires +included: `tools: []` is a statement, not an omission, and no deferred registry exists under +it. The ingresses carry the fact as `clientDeclaredEmptyToolCatalog` +(`src/server/chat-completions.ts`, `src/server/claude-messages.ts`) since translation drops an +empty catalog with the rest; `hasExplicitEmptyToolCatalog` reads a verbatim `tools: []` or +`additional_tools: []` too. Refusals keep `undeclared_tool_call` across the buffered envelope +and endpoint projections, so Messages sees a deterministic `api_error`, never `overloaded_error`. + +`undeclaredToolGuardActive` gates refusal and continuation suppression, standing down only for +`authMode: "forward"`, deferred catalogs, and absent ones; a refused turn never enters +continuation state. `default.` normalization is scoped separately. ## Selection outlives the declaration check diff --git a/structure/transports/responses.md b/structure/transports/responses.md index f0421c1eeb3..2e95b879738 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -14,7 +14,7 @@ When a successful streamed native response has a missing or unrecognized non-JSO HTTP ingress applies the [caller header contract](../data-planes/inbound-compat.md#http-caller-conversation-identity) before admission and dispatch. Optional Codex memory selection enters `src/server/responses/request-prepare.ts` for HTTP and WebSocket frames, as specified in [memory phase routing](responses-failover.md#memory-phase-routing); an unset phase keeps its existing route and starts no background work. Responses request preparation stabilizes incoming `` under `skills.catalog_refresh`: `per_session` (default) reuses the first received catalog for a conversation; `per_turn` leaves the supplied catalog unchanged. Other instruction sections and user/tool content remain untouched. Requests without a reliable conversation identity bypass snapshots; shared prompt-cache cohorts are not conversation identities. Only a body with exactly one catalog block across its instructions and developer/system content takes part; two or more pass through unchanged. A known snapshot is substituted before parsing, but a new catalog is stored only when preparation reaches its success return, so a request rejected by parsing or admission pins nothing. Without a named principal, snapshots are shared by conversation id only on a server that requires no data-plane auth. Snapshots are process-local, expire after four idle hours, and use bounded LRU retention; oversized blocks bypass caching. The dashboard's `src/codex/prompt-layers.ts` and `src/codex/prompt-text-probe.ts` continue observing current files for previews and do not own session snapshots. `/v1/responses` is the main Codex-facing endpoint. The server parses Responses input, routes to a provider, lets the selected adapter speak the upstream protocol, then bridges adapter events back to -Responses-compatible streaming output. The routed provider name follows every adapter build, including retries and continuations; only `github-copilot` injects a configured `modelContextTiers` value as upstream `contextTier`, while unconfigured passthrough retains the caller field. For an opted-in key-auth provider, a hosted-search continuation stays bound to the API-key selection that served the first leg; the contract is the [hosted-search continuation binding](../providers-and-adapters.md#hosted-search-continuation-binding). The active native passthrough undeclared-tool guard covers Chat, Anthropic and Responses inbound wires for both SSE and buffered JSON, refusing undeclared client-executed calls before continuation state is stored. +Responses-compatible streaming output. The routed provider name follows every adapter build, including retries and continuations; only `github-copilot` injects a configured `modelContextTiers` value as upstream `contextTier`, while unconfigured passthrough retains the caller field. For an opted-in key-auth provider, a hosted-search continuation stays bound to the API-key selection that served the first leg; the contract is the [hosted-search continuation binding](../providers-and-adapters.md#hosted-search-continuation-binding). The native passthrough undeclared-tool guard fails closed on the Responses inbound wire in SSE and buffered JSON before continuation state is stored; Chat and Anthropic inbounds defer to the caller's own registry except an explicit empty catalog, which stays deny-all. The `openai-responses` adapter preserves caller `User-Agent` unless provider headers own it, in either auth mode. `forwardClientHeaders` additionally opts into `originator`, `x-client-request-id`, `x-codex-app-version`, or `user-agent`. diff --git a/tests/responses/passthrough-deferred-tools.test.ts b/tests/responses/passthrough-deferred-tools.test.ts index 777737dc4f1..3e28ebb6b4e 100644 --- a/tests/responses/passthrough-deferred-tools.test.ts +++ b/tests/responses/passthrough-deferred-tools.test.ts @@ -153,3 +153,103 @@ describe("Responses passthrough deferred tool delivery by inbound wire", () => { } } }); + +// An explicit empty catalog is still a caller declaration — deny-all — even on wires whose +// absent or partial catalogs may be completed later by the client's own tool registry. +// The refusal must stay a deterministic catalog violation, never a retryable overload. +describe("explicit empty tool catalog is deny-all on deferred wires", () => { + for (const wire of ["chat", "anthropic"] as const) { + for (const stream of [true, false]) { + test(`${wire} ${stream ? "stream" : "buffered"}: tools:[] refuses an upstream Write`, async () => { + const upstream = mockResponsesUpstream("Write", stream); + saveConfig({ + port: 0, defaultProvider: "mock", + providers: { mock: { + adapter: "openai-responses", apiKey: "mock-key", authMode: "key", + baseUrl: `${upstream.server.url.toString().replace(/\/$/, "")}/v1`, allowPrivateNetwork: true, + } }, + } as OcxConfig); + const server = startServer(0); + try { + const body = wire === "anthropic" + ? { model: "mock/test-model", stream, max_tokens: 128, + messages: [{ role: "user", content: "write to todo" }], tools: [] } + : { model: "mock/test-model", stream, + messages: [{ role: "user", content: "write to todo" }], tools: [] }; + const result = await fetch(new URL(endpoints[wire], server.url), { + method: "POST", headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" }, + body: JSON.stringify(body), + }); + const text = await result.text(); + expect(upstream.seen).toHaveLength(1); + expect(text).toContain("undeclared client tool"); + expect(text).toContain("Write"); + if (wire === "anthropic") { + // Deterministic refusal: api_error identity, never a retryable overload. + expect(text).not.toContain("overloaded_error"); + expect(text).toContain("undeclared_tool_call"); + if (stream) { + expect(result.status).toBe(200); + expect(text).toContain("api_error"); + } else { + expect(result.status).toBe(502); + expect(JSON.parse(text).error.type).toBe("api_error"); + } + } else { + expect(text).toContain("undeclared_tool_call"); + if (stream) expect(result.status).toBe(200); + else expect(result.status).toBe(502); + } + } finally { + await server.stop(true); + upstream.server.stop(true); + } + }); + } + } +}); + +// Tool-search style discovery lands the provider's expanded catalog back in the request as +// `additional_tools`/`tool_search_output` input items. Those are THIS turn's declarations, so +// a call they name is registered, not invented — the refusal is only for a catalog that never +// authorized the name. +describe("registered deferred catalog input items authorize calls", () => { + for (const itemType of ["additional_tools", "tool_search_output"] as const) { + for (const stream of [true, false]) { + test(`responses ${stream ? "stream" : "buffered"}: Write declared via ${itemType}`, async () => { + const upstream = mockResponsesUpstream("Write", stream); + saveConfig({ + port: 0, defaultProvider: "mock", + providers: { mock: { + adapter: "openai-responses", apiKey: "mock-key", authMode: "key", + baseUrl: `${upstream.server.url.toString().replace(/\/$/, "")}/v1`, allowPrivateNetwork: true, + } }, + } as OcxConfig); + const server = startServer(0); + try { + const result = await fetch(new URL(endpoints.responses, server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "mock/test-model", stream, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "write to todo" }] }, + { type: itemType, tools: [{ type: "function", name: "Write", description: "Write a file", parameters: { type: "object", properties: { path: { type: "string" } } } }] }, + ], + tools: [{ type: "function", name: "Read", description: "Read a file", parameters: { type: "object", properties: { path: { type: "string" } } } }], + }), + }); + const text = await result.text(); + expect(upstream.seen).toHaveLength(1); + expect(result.status).toBe(200); + expect(text).not.toContain("undeclared client tool"); + expect(text).not.toContain('"error"'); + if (stream) expect(text).toContain("response.completed"); + else expect(JSON.parse(text).output[0].name).toBe("Write"); + } finally { + await server.stop(true); + upstream.server.stop(true); + } + }); + } + } +}); diff --git a/tests/responses/responses-undeclared-tool-guard-inbound.test.ts b/tests/responses/responses-undeclared-tool-guard-inbound.test.ts index 9078abb6626..20ca5059717 100644 --- a/tests/responses/responses-undeclared-tool-guard-inbound.test.ts +++ b/tests/responses/responses-undeclared-tool-guard-inbound.test.ts @@ -88,7 +88,9 @@ describe("native passthrough inbound tool catalogs", () => { for (const inboundWire of ["chat", "anthropic"] as const) { for (const stream of [false, true]) { - test(`${inboundWire} passthrough refuses undeclared Write calls (${stream ? "streaming" : "JSON"})`, async () => { + // Deferred-catalog wires relay an undeclared client call: the caller's own tool + // registry owns the full declaration boundary, not the partial translated catalog. + test(`${inboundWire} passthrough relays an undeclared Write call (${stream ? "streaming" : "JSON"})`, async () => { const writeCall = { ...call, name: "Write" }; const upstream = stream ? () => new Response([ @@ -112,6 +114,43 @@ describe("native passthrough inbound tool catalogs", () => { inboundWire, ); + if (stream) { + const body = await response.text(); + const frames = readFrames(body); + expect(frames.some(item => item.payload?.type === "response.failed")).toBe(false); + expect(frames.some(item => item.payload?.type === "response.completed")).toBe(true); + } else { + expect(response.status).toBe(200); + expect(await response.text()).not.toContain("undeclared"); + } + }); + + // An explicit empty catalog is still a deny-all the caller declared; it is the one + // case a deferred wire cannot leave to a fuller client registry. + test(`${inboundWire} passthrough refuses undeclared Write under tools:[] (${stream ? "streaming" : "JSON"})`, async () => { + const writeCall = { ...call, name: "Write" }; + const upstream = stream + ? () => new Response([ + frame("response.output_item.added", { output_index: 0, item: writeCall }), + frame("response.completed", { response: { id: "resp_1", status: "completed", output: [writeCall] } }), + ].join("\n\n") + "\n\n", { headers: { "content-type": "text/event-stream" } }) + : () => new Response( + JSON.stringify({ id: "resp_1", status: "completed", output: [writeCall] }), + { headers: { "content-type": "application/json" } }, + ); + const response = await post( + stream, + [], + upstream, + undefined, + config, + [], + "fixture/deepseek-v4-flash", + undefined, + undefined, + inboundWire, + ); + if (stream) { // This direct internal call still returns Responses wire; endpoint projections are checked below. const body = await response.text(); @@ -143,14 +182,14 @@ describe("native passthrough inbound tool catalogs", () => { takeSpendHome(); const model = "fixture/deepseek-v4-flash"; const schema = { type: "object" }; + // Deferred wires only fail closed on an explicit empty catalog; the Responses wire + // fails closed on any catalog the call's name is absent from. const input = surface === "responses" ? { input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }], tools: [{ type: "function", name: "Read", parameters: schema }] } : surface === "chat" - ? { messages: [{ role: "user", content: "hi" }], - tools: [{ type: "function", function: { name: "Read", parameters: schema } }] } - : { max_tokens: 64, messages: [{ role: "user", content: "hi" }], - tools: [{ name: "Read", input_schema: schema }] }; + ? { messages: [{ role: "user", content: "hi" }], tools: [] } + : { max_tokens: 64, messages: [{ role: "user", content: "hi" }], tools: [] }; const path = surface === "responses" ? "responses" : surface === "chat" ? "chat/completions" : "messages"; const request = new Request(`http://localhost/v1/${path}`, { method: "POST", headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" }, @@ -183,11 +222,18 @@ describe("native passthrough inbound tool catalogs", () => { expect(errors[0]?.event).toBeUndefined(); expect(errors[0]?.payload?.type).toBeUndefined(); expect(typeof (errors[0]?.payload?.error as { type?: unknown }).type).toBe("string"); + // The deterministic refusal keeps its code on every translated wire. + expect(errors[0]?.payload?.error).toMatchObject({ code: UNDECLARED_TOOL_CALL_ERROR_CODE }); const choices = payloads.flatMap(item => Array.isArray(item.choices) ? item.choices : []); expect(choices.some(choice => choice.finish_reason != null)).toBe(false); } else { expect(errors[0]?.event).toBe("error"); expect(errors[0]?.payload?.type).toBe("error"); + // A catalog violation is api_error + its code — never a retryable overloaded_error. + expect(errors[0]?.payload?.error).toMatchObject({ + type: "api_error", + code: UNDECLARED_TOOL_CALL_ERROR_CODE, + }); expect(frames.some(item => item.event === "message_stop" || item.payload?.type === "message_stop")).toBe(false); expect(payloads.some(item => item.type === "message_delta" && (item.delta as { stop_reason?: unknown } | undefined)?.stop_reason != null)).toBe(false); From e77ca3222c5ac98cf18575d7b4723d518e3cf9fe Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:00:08 +0900 Subject: [PATCH 8/8] docs: align Chinese tool-guard protocol contracts Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/content/docs/zh-cn/reference/proxy-formats.md | 11 ++++++----- .../src/content/docs/zh-tw/reference/proxy-formats.md | 11 ++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md index 22b79336bd7..2b8778764f2 100644 --- a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md @@ -31,13 +31,14 @@ Responses 表示是这座桥的中心。原生兼容的路由可以跳过部分 ## 未声明的工具调用 -当请求声明了工具目录时,原生直通响应如果调用目录之外的客户端执行工具,不会转发该调用,而是使本轮失败。此行为适用于所有入站方言:Responses、Chat Completions 和 Messages。流式失败在客户端的呈现方式取决于端点: +在 Responses 端点上,当请求声明了工具目录时,原生直通响应若调用目录之外由客户端执行的工具,本轮会失败而不会转发该调用:流式响应发送带有错误代码 `undeclared_tool_call` 的 `response.failed` 事件,然后发送 `data: [DONE]`;缓冲式 JSON 返回 HTTP 502 和相同错误代码。拒绝发生在响应进入续接状态之前。只有 `authMode: "forward"` 提供方或未声明目录的请求才跳过这一拒绝。 -- **Responses:**发送带有错误代码 `undeclared_tool_call` 的 `response.failed` 事件,然后发送 `data: [DONE]`。 -- **Chat Completions:**发送包含 OpenAI 风格 `error` 对象的 `data:` 帧,随后关闭流,不发送成功完成标记或 `[DONE]`。 -- **Messages:**发送包含 `{ "type": "error", "error": ... }` 正文的 Anthropic `event: error` 帧;不会发送成功的 `message_stop` 或 `[DONE]`。Responses 错误代码不会保留。 +Chat Completions 和 Messages 端点支持延迟提供的工具目录:客户端可先声明部分目录,再通过自己的工具注册表在运行时发现其余工具;完整声明边界由客户端注册表决定,而不是翻译后的目录快照。目录之外的调用会转发给客户端验证;但针对显式空目录 `tools: []` 的调用仍会被拒绝。此类拒绝在每种协议中都保留 `undeclared_tool_call`: -缓冲式 JSON 响应会返回 HTTP 502,并在错误中注明工具名称;错误格式因端点而异(内部的 `upstream_error` 会转换为 Chat 和 Messages 客户端所用的格式)。拒绝发生在响应进入续接状态之前。只有 `authMode: "forward"` 提供方或未声明工具目录的请求才不会触发此拒绝。显式声明为空的目录仍算已声明,因此不授权任何工具。 +- **Chat Completions:**发送包含带有该错误代码的 OpenAI 风格 `error` 对象的 `data:` 帧,然后关闭流,不发送成功完成标记或 `[DONE]`;缓冲式 JSON 返回 HTTP 502 和该代码。 +- **Messages:**发送 Anthropic `event: error` 帧,正文为 `{ "type": "error", "error": { "type": "api_error", "code": "undeclared_tool_call", ... } }`,不发送成功的 `message_stop` 或 `[DONE]`;缓冲式 JSON 返回 HTTP 502 `api_error` 和该代码。确定性的目录违规不会被报告为可重试的 `overloaded_error`(HTTP 529)。 + +此外,在所有入站协议中,提供方返回的 `default.` 命名空间调用都会规范化为客户端声明的裸名称,即使延迟目录规则关闭了上述拒绝检查。 ## 端点总览 diff --git a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md index 53778e302e4..f45782de4b3 100644 --- a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md @@ -26,13 +26,14 @@ Responses 表示是橋接的中心。原生相容的路由可跳過部分轉譯 ## 未宣告的工具呼叫 -當請求宣告了工具目錄時,原生直通回應若呼叫目錄之外、由用戶端執行的工具,不會轉送該呼叫,而是讓本回合失敗。此行為適用於所有輸入方言:Responses、Chat Completions 與 Messages。串流失敗在用戶端呈現的格式取決於端點: +在 Responses 端點上,當請求宣告了工具目錄時,原生直通回應若呼叫目錄之外由用戶端執行的工具,本回合會失敗而不會轉送該呼叫:串流回應傳送帶有錯誤碼 `undeclared_tool_call` 的 `response.failed` 事件,接著傳送 `data: [DONE]`;緩衝式 JSON 傳回 HTTP 502 與相同錯誤碼。拒絕發生在回應進入延續狀態之前。只有 `authMode: "forward"` 供應商或未宣告目錄的請求才略過這項拒絕。 -- **Responses:**傳送帶有錯誤碼 `undeclared_tool_call` 的 `response.failed` 事件,接著傳送 `data: [DONE]`。 -- **Chat Completions:**傳送包含 OpenAI 格式 `error` 物件的 `data:` frame,接著關閉串流,不會傳送成功完成訊號或 `[DONE]`。 -- **Messages:**傳送包含 `{ "type": "error", "error": ... }` 內文的 Anthropic `event: error` frame;不會傳送成功的 `message_stop` 或 `[DONE]`。Responses 錯誤碼不會保留。 +Chat Completions 與 Messages 端點支援延後提供的工具目錄:用戶端可先宣告部分目錄,再透過自己的工具登錄表於執行期間探索其餘工具;完整宣告邊界由用戶端登錄表決定,而非轉譯後的目錄快照。目錄之外的呼叫會轉送給用戶端驗證;但針對明確空目錄 `tools: []` 的呼叫仍會被拒絕。這些拒絕在每種協定中都保留 `undeclared_tool_call`: -緩衝式 JSON 回應會傳回 HTTP 502,並在錯誤中指出工具名稱;錯誤格式依端點而異(內部的 `upstream_error` 會轉換為 Chat 與 Messages 用戶端所需的格式)。拒絕會在回應進入延續狀態之前執行。只有 `authMode: "forward"` 供應商或未宣告工具目錄的請求不會觸發此拒絕。明確宣告為空的目錄仍視為已宣告,因此不會授權任何工具。 +- **Chat Completions:**傳送包含帶有該錯誤碼的 OpenAI 格式 `error` 物件的 `data:` frame,然後關閉串流,不傳送成功完成訊號或 `[DONE]`;緩衝式 JSON 傳回 HTTP 502 與該錯誤碼。 +- **Messages:**傳送 Anthropic `event: error` frame,內文為 `{ "type": "error", "error": { "type": "api_error", "code": "undeclared_tool_call", ... } }`,不傳送成功的 `message_stop` 或 `[DONE]`;緩衝式 JSON 傳回 HTTP 502 `api_error` 與該錯誤碼。確定性的目錄違規不會被回報為可重試的 `overloaded_error`(HTTP 529)。 + +此外,在所有輸入協定中,供應商回傳的 `default.` 命名空間呼叫都會正規化為用戶端宣告的裸名稱,即使延後目錄規則停用了上述拒絕檢查。 ## 端點概覽