From b712c5568e30088723adf847af89048d524a61b7 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 14:00:57 +0900 Subject: [PATCH 01/11] fix(chat): carry allowed_tools and a caller's parallel_tool_calls to the wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways a Chat Completions caller restricts tool use reached the parser and were then dropped on the way out, both under a normal HTTP 200. A tool_choice of type allowed_tools is a record, is not type "function", and carries no "function" member, so it fell past every branch of the Chat inbound translator and body.tool_choice was never assigned. The upstream received the full catalogue and no choice at all. Chat nests the subset under allowed_tools and names each entry under a member keyed by its own type, while the Responses shape mapToolChoice reads carries mode and tools on the choice itself with a flat name, so neither level lined up. Flatten both. An entry nobody can name is refused rather than skipped, because dropping one widens the very subset the field was sent to narrow. parallel_tool_calls had three provider states and two branches, in two places. When a provider expresses no preference, which is the default for every provider that never configured the knob, neither branch ran and an explicit request-level false was lost — on the translated path and, from its own copy of the same branch, on the native Chat passthrough. That state now forwards the caller's false. An explicit true still omits the key, matching the configured opt-out, so strict OpenAI-compatible hosts never see a knob they did not have to accept before. The NVIDIA and pinParallelToolCallsFalse pins are unchanged. The decision moved into openai-chat/parallel-tool-calls.ts, which both builders now read, so the three states cannot drift between them again. openai-chat.ts is 811 lines against its 822-line cap. Regressions assert on the serialized outbound request body for both builders, since a successful tool call and a 200 response look identical with or without either constraint. Closes #5211 --- scripts/test-layout/layout.json | 1 + src/adapters/openai-chat.ts | 16 +-- .../openai-chat/parallel-tool-calls.ts | 32 +++++ src/adapters/openai-chat/passthrough.ts | 13 ++- src/chat/inbound.ts | 51 ++++++++ structure/providers-and-adapters.md | 2 +- .../parallel-tool-calls-optin.test.ts | 71 +++++++++++- tests/fixtures/test-layout-expected.json | 1 + .../chat-tool-choice-allowed-tools.test.ts | 109 ++++++++++++++++++ 9 files changed, 274 insertions(+), 22 deletions(-) create mode 100644 src/adapters/openai-chat/parallel-tool-calls.ts create mode 100644 tests/responses/chat-tool-choice-allowed-tools.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7522f69ddae..76ce483f718 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1567,6 +1567,7 @@ "management-google-tool-schema-policy.test.ts": "server", "codex-shim-destroyed-probe.test.ts": "codex-integration", "client-runtime.test.ts": "clients", + "chat-tool-choice-allowed-tools.test.ts": "responses", "devin-output-budget.test.ts": "providers", "api-key-model-scope.test.ts": "server" }, diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 633c693c7ff..fa024e0cccb 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1,4 +1,5 @@ import { hasShrinkableOpenAIChatImages, normalizeOpenAIChatImages } from "./openai-chat-images"; +import { chatParallelToolCallsWireValue } from "./openai-chat/parallel-tool-calls"; import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "./base"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../types"; import { modelInList } from "../types"; @@ -249,19 +250,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd } if (tools) { - if (provider.parallelToolCalls === false) { - // NIM documents the Boolean defaulting to false and kimi rejects true; pin the - // wire bit so Codex cannot opt in via request.options. Other opted-out providers - // omit the field by default so strict OpenAI-compatible hosts never see an - // unsupported knob, but a self-hosted gateway that DOES honor the field and keeps - // emitting parallel calls without it can opt in via pinParallelToolCallsFalse. - if (provider.baseUrl === "https://integrate.api.nvidia.com/v1" - || provider.pinParallelToolCallsFalse === true) { - body.parallel_tool_calls = false; - } - } else if (provider.parallelToolCalls === true) { - body.parallel_tool_calls = parsed.options.parallelToolCalls !== false; - } + const parallelToolCalls = chatParallelToolCallsWireValue(provider, parsed.options.parallelToolCalls); + if (parallelToolCalls !== undefined) body.parallel_tool_calls = parallelToolCalls; } if (parsed.stream) body.stream_options = { include_usage: true }; diff --git a/src/adapters/openai-chat/parallel-tool-calls.ts b/src/adapters/openai-chat/parallel-tool-calls.ts new file mode 100644 index 00000000000..89d6e4326f2 --- /dev/null +++ b/src/adapters/openai-chat/parallel-tool-calls.ts @@ -0,0 +1,32 @@ +import type { OcxProviderConfig } from "../../types"; + +/** + * The `parallel_tool_calls` value for a translated Chat request, or `undefined` to omit the key. + * + * A provider has three states here, not two, and the third is the default for every provider that + * never configured the knob. While the call site only branched on the two configured states, a + * caller's own explicit `parallel_tool_calls: false` reached the parser, was carried on + * `options.parallelToolCalls`, and was then dropped on the way to the wire — the upstream stayed + * free to emit concurrent calls and the response looked entirely normal (#5211). + * + * Only an explicit request-side `false` is forwarded in the unset state. `true` is already the + * upstream default, so emitting it would introduce the knob to strict OpenAI-compatible hosts + * that have never had to accept it, which is the reason the configured opt-out below omits the + * key rather than sending `false`. + */ +export function chatParallelToolCallsWireValue( + provider: OcxProviderConfig, + requested: boolean | undefined, +): boolean | undefined { + if (provider.parallelToolCalls === false) { + // NIM documents the Boolean defaulting to false and kimi rejects true; pin the wire bit so + // Codex cannot opt in via request.options. Other opted-out providers omit the field, but a + // self-hosted gateway that DOES honor it and keeps emitting parallel calls without it can opt + // in via pinParallelToolCallsFalse. + const pinned = provider.baseUrl === "https://integrate.api.nvidia.com/v1" + || provider.pinParallelToolCallsFalse === true; + return pinned ? false : undefined; + } + if (provider.parallelToolCalls === true) return requested !== false; + return requested === false ? false : undefined; +} diff --git a/src/adapters/openai-chat/passthrough.ts b/src/adapters/openai-chat/passthrough.ts index f7682b7a62d..e653017bbcc 100644 --- a/src/adapters/openai-chat/passthrough.ts +++ b/src/adapters/openai-chat/passthrough.ts @@ -9,6 +9,7 @@ import { debugProviderDiagnostic } from "../../lib/debug"; import { isDebugEnabled } from "../../lib/debug-settings"; import { modelRecordValue } from "../../reasoning-effort"; import { modelInList, type OcxProviderConfig } from "../../types"; +import { chatParallelToolCallsWireValue } from "./parallel-tool-calls"; const CHAT_PASSTHROUGH_FIELDS = [ "audio", @@ -108,12 +109,12 @@ export function buildOpenAIChatPassthroughRequest( body.prompt_cache_key = rawBody.prompt_cache_key; } if (Array.isArray(rawBody.tools) && rawBody.tools.length > 0) { - if (provider.parallelToolCalls === true) { - body.parallel_tool_calls = rawBody.parallel_tool_calls !== false; - } else if (provider.parallelToolCalls === false - && (provider.baseUrl === "https://integrate.api.nvidia.com/v1" || provider.pinParallelToolCallsFalse === true)) { - body.parallel_tool_calls = false; - } + // Same three provider states as the translated path, and the same defect in the unset one: + // a caller's explicit false was dropped here too (#5211). The native route reads the bit off + // the raw request rather than the parsed options, since nothing projects this body. + const requested = typeof rawBody.parallel_tool_calls === "boolean" ? rawBody.parallel_tool_calls : undefined; + const parallelToolCalls = chatParallelToolCallsWireValue(provider, requested); + if (parallelToolCalls !== undefined) body.parallel_tool_calls = parallelToolCalls; } if (stream) { const callerOptions = rawBody.stream_options !== null diff --git a/src/chat/inbound.ts b/src/chat/inbound.ts index b12761c8e4e..ac52e2e7794 100644 --- a/src/chat/inbound.ts +++ b/src/chat/inbound.ts @@ -218,11 +218,62 @@ function toolChoiceToResponses(choice: unknown, body: Rec): void { body.tool_choice = { type: "function", name }; return; } + if (choice.type === "allowed_tools") { + body.tool_choice = allowedToolsChoiceToResponses(choice); + return; + } if (isRec(choice.function) && typeof choice.function.name === "string") { body.tool_choice = { type: "function", name: choice.function.name }; } } +/** + * Chat Completions nests the subset under `allowed_tools`, Responses carries `mode`/`tools` + * on the choice itself, and each entry names its tool under a member keyed by its own type + * (`{"type":"function","function":{"name"}}`) rather than a flat `name`. Neither level lines up + * with `mapToolChoice`, so an unflattened choice fell past every branch and the caller's subset + * was dropped while the full catalogue was still advertised (#5211). + * + * An entry nobody can name is refused rather than skipped: dropping one widens the very subset + * the caller sent this field to narrow. + */ +function allowedToolsChoiceToResponses(choice: Rec): Rec { + const spec = isRec(choice.allowed_tools) ? choice.allowed_tools : choice; + if (!Array.isArray(spec.tools) || spec.tools.length === 0) { + throw new ChatCompletionsRequestError("tool_choice.allowed_tools requires a non-empty tools array"); + } + return { + type: "allowed_tools", + mode: spec.mode === "required" ? "required" : "auto", + tools: spec.tools.map(allowedToolEntryToResponses), + }; +} + +/** Hosted entries are named by their type alone; a function/custom entry must carry a name. */ +const HOSTED_ALLOWED_TOOL_TYPES = new Set([ + "web_search", + "web_search_preview", + "image_generation", + "image_gen", + "tool_search", +]); + +function allowedToolEntryToResponses(raw: unknown): Rec { + if (!isRec(raw)) { + throw new ChatCompletionsRequestError("tool_choice.allowed_tools.tools entries must be objects"); + } + const type = typeof raw.type === "string" && raw.type.length > 0 ? raw.type : "function"; + const nested = isRec(raw[type]) ? raw[type] as Rec : undefined; + const name = typeof raw.name === "string" && raw.name.length > 0 + ? raw.name + : nested !== undefined && typeof nested.name === "string" && nested.name.length > 0 + ? nested.name + : undefined; + if (name !== undefined) return { type, name }; + if (HOSTED_ALLOWED_TOOL_TYPES.has(type)) return { type }; + throw new ChatCompletionsRequestError("tool_choice.allowed_tools.tools entries require a name"); +} + function responseFormatToText(format: unknown): Rec | undefined { if (format === undefined) return undefined; if (!isRec(format)) throw new ChatCompletionsRequestError("response_format must be an object"); diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 0121fb76e40..f6ca700517b 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -15,7 +15,7 @@ the [bounded ingestion contract](transports/inventory.md#bounded-response-ingest | `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders receive no unsupported reasoning/thinking controls, while known ladders retain per-target resolution. | | `src/adapters/openai-responses.ts` | Native OpenAI/ChatGPT Responses passthrough. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | -| `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `tool-call-validation.ts`, `tool-schema.ts`, `errors.ts`). Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | +| `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `tool-call-validation.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | | `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. | | `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | diff --git a/tests/codex-integration/parallel-tool-calls-optin.test.ts b/tests/codex-integration/parallel-tool-calls-optin.test.ts index 689fd207f80..d62920f9e04 100644 --- a/tests/codex-integration/parallel-tool-calls-optin.test.ts +++ b/tests/codex-integration/parallel-tool-calls-optin.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; +import { buildOpenAIChatPassthroughRequest, createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; import { applyProviderConfigHints, normalizeRoutedCatalogEntry } from "../../src/codex/catalog"; import { routeModel } from "../../src/router"; -import type { OcxConfig, OcxParsedRequest, OcxTool } from "../../src/types"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxTool } from "../../src/types"; const tools: OcxTool[] = [{ name: "shell", description: "run", parameters: { type: "object" } }]; @@ -62,6 +62,73 @@ describe("parallel tool calls provider opt-in (request body)", () => { }); }); +/** + * #5211 case 2. The provider knob has three states and the call site only branched on two, so + * the default state — a provider that never configured it — dropped the caller's own explicit + * `parallel_tool_calls: false` on the way to the wire while still answering normally. The + * assertions read the request body because a successful tool call cannot tell the difference. + */ +describe("caller-specified parallel_tool_calls on a provider that expresses no preference", () => { + const unsetProvider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://gateway.example.internal/v1", apiKey: "k" }; + + test("an explicit request-level false reaches the outbound request", () => { + const adapter = createOpenAIChatAdapter(unsetProvider); + const body = JSON.parse(adapter.buildRequest(parsedRequest({ parallelToolCalls: false })).body) as Record; + expect(body.parallel_tool_calls).toBe(false); + }); + + test("an explicit request-level true still omits the knob strict hosts never had to accept", () => { + const adapter = createOpenAIChatAdapter(unsetProvider); + const body = JSON.parse(adapter.buildRequest(parsedRequest({ parallelToolCalls: true })).body) as Record; + expect(body).not.toHaveProperty("parallel_tool_calls"); + }); + + test("a request that says nothing leaves the key absent", () => { + const adapter = createOpenAIChatAdapter(unsetProvider); + const body = JSON.parse(adapter.buildRequest(parsedRequest()).body) as Record; + expect(body).not.toHaveProperty("parallel_tool_calls"); + }); + + test("a toolless request never grows the key", () => { + const adapter = createOpenAIChatAdapter(unsetProvider); + const toolless = { ...parsedRequest({ parallelToolCalls: false }), context: { messages: [{ role: "user", content: "hi", timestamp: 0 }] } } as never; + const body = JSON.parse(adapter.buildRequest(toolless).body) as Record; + expect(body).not.toHaveProperty("parallel_tool_calls"); + }); + + // The native Chat route never projects the body, so it read the same three provider states + // from its own copy of the branch and lost the caller's false in exactly the same way. + describe("native Chat passthrough", () => { + function passthroughBody(provider: OcxProviderConfig, raw: Record): Record { + const request = buildOpenAIChatPassthroughRequest(provider, { + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "shell", parameters: { type: "object" } } }], + ...raw, + }, "grok-4.5", false); + return JSON.parse(request.body) as Record; + } + + test("an explicit request-level false reaches the outbound request", () => { + expect(passthroughBody(unsetProvider, { parallel_tool_calls: false }).parallel_tool_calls).toBe(false); + }); + + test("an explicit true and an absent value both leave the key off", () => { + expect(passthroughBody(unsetProvider, { parallel_tool_calls: true })).not.toHaveProperty("parallel_tool_calls"); + expect(passthroughBody(unsetProvider, {})).not.toHaveProperty("parallel_tool_calls"); + }); + + test("the configured states keep their existing wire values", () => { + const optedIn = { ...unsetProvider, parallelToolCalls: true }; + expect(passthroughBody(optedIn, {}).parallel_tool_calls).toBe(true); + expect(passthroughBody(optedIn, { parallel_tool_calls: false }).parallel_tool_calls).toBe(false); + const optedOut = { ...unsetProvider, parallelToolCalls: false }; + expect(passthroughBody(optedOut, { parallel_tool_calls: true })).not.toHaveProperty("parallel_tool_calls"); + expect(passthroughBody({ ...optedOut, pinParallelToolCallsFalse: true }, { parallel_tool_calls: true }).parallel_tool_calls) + .toBe(false); + }); + }); +}); + describe("stale persisted config backfill (router)", () => { test("persisted xai config without the flag inherits registry parallelToolCalls:true", () => { const config: OcxConfig = { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 308682ccebc..60c8c861a8b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1399,6 +1399,7 @@ "web-search-sidecar-429.test.ts": "web-search", "codex-shim-destroyed-probe.test.ts": "codex-integration", "client-runtime.test.ts": "clients", + "chat-tool-choice-allowed-tools.test.ts": "responses", "devin-output-budget.test.ts": "providers", "api-key-model-scope.test.ts": "server" } diff --git a/tests/responses/chat-tool-choice-allowed-tools.test.ts b/tests/responses/chat-tool-choice-allowed-tools.test.ts new file mode 100644 index 00000000000..8ef7c26c6d6 --- /dev/null +++ b/tests/responses/chat-tool-choice-allowed-tools.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test"; +import { chatCompletionsToResponsesBody, ChatCompletionsRequestError } from "../../src/chat/inbound"; +import { parseRequest } from "../../src/responses/parser"; +import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; + +/** + * #5211 case 1. A Chat Completions caller narrows the catalogue with + * `tool_choice.allowed_tools`, and the translated path used to let that object fall past every + * branch of `toolChoiceToResponses`: the request kept its full tool list, carried no tool choice + * at all, and still answered 200. Asserting on the translated body alone would not catch a + * later regression on the way out, so each case here follows the value into the request the + * adapter actually sends. + */ + +const CHAT_TOOLS = [ + { type: "function", function: { name: "tool_a", parameters: { type: "object", properties: {} } } }, + { type: "function", function: { name: "tool_b", parameters: { type: "object", properties: {} } } }, +]; + +function chatBody(toolChoice: unknown): Record { + return { + model: "mock/test-model", + messages: [{ role: "user", content: "Call only tool_b." }], + tools: CHAT_TOOLS, + tool_choice: toolChoice, + }; +} + +function outboundBody(toolChoice: unknown): Record { + const translated = chatCompletionsToResponsesBody(chatBody(toolChoice)); + const parsed = parseRequest(translated as never); + const adapter = createOpenAIChatAdapter({ + adapter: "openai-chat", + baseUrl: "https://gateway.example.internal/v1", + apiKey: "k", + }); + return JSON.parse(adapter.buildRequest(parsed as never).body) as Record; +} + +function outboundToolNames(body: Record): string[] { + return (body.tools as Array<{ function?: { name?: string } }>).map(tool => tool.function?.name ?? ""); +} + +describe("chat tool_choice allowed_tools reaches the outbound request", () => { + test("a single-tool subset narrows the tools the upstream is offered", () => { + const body = outboundBody({ + type: "allowed_tools", + allowed_tools: { mode: "required", tools: [{ type: "function", function: { name: "tool_b" } }] }, + }); + expect(outboundToolNames(body)).toEqual(["tool_b"]); + expect(body.tool_choice).toBe("required"); + }); + + test("a larger subset keeps every allowed tool and drops the rest", () => { + const body = outboundBody({ + type: "allowed_tools", + allowed_tools: { + mode: "auto", + tools: [ + { type: "function", function: { name: "tool_a" } }, + { type: "function", function: { name: "tool_b" } }, + ], + }, + }); + expect(outboundToolNames(body).sort()).toEqual(["tool_a", "tool_b"]); + expect(body.tool_choice).toBe("auto"); + }); + + test("the flat entry spelling and a flat choice object are both accepted", () => { + const nested = chatCompletionsToResponsesBody(chatBody({ + type: "allowed_tools", + allowed_tools: { mode: "required", tools: [{ type: "function", name: "tool_a" }] }, + })); + const flat = chatCompletionsToResponsesBody(chatBody({ + type: "allowed_tools", + mode: "required", + tools: [{ type: "function", name: "tool_a" }], + })); + expect(nested.tool_choice) + .toEqual({ type: "allowed_tools", mode: "required", tools: [{ type: "function", name: "tool_a" }] }); + expect(flat.tool_choice).toEqual(nested.tool_choice); + }); + + test("mode defaults to auto and hosted entries are named by their type", () => { + const translated = chatCompletionsToResponsesBody(chatBody({ + type: "allowed_tools", + allowed_tools: { tools: [{ type: "web_search" }] }, + })); + expect(translated.tool_choice) + .toEqual({ type: "allowed_tools", mode: "auto", tools: [{ type: "web_search" }] }); + }); + + test("an unnameable entry is refused rather than quietly widening the subset", () => { + expect(() => chatCompletionsToResponsesBody(chatBody({ + type: "allowed_tools", + allowed_tools: { mode: "required", tools: [{ type: "function", function: {} }] }, + }))).toThrow(ChatCompletionsRequestError); + expect(() => chatCompletionsToResponsesBody(chatBody({ + type: "allowed_tools", + allowed_tools: { mode: "required", tools: [] }, + }))).toThrow(ChatCompletionsRequestError); + }); + + test("the existing named and string choices are unchanged", () => { + expect(chatCompletionsToResponsesBody(chatBody("required")).tool_choice).toBe("required"); + expect(chatCompletionsToResponsesBody(chatBody({ type: "function", function: { name: "tool_a" } })).tool_choice) + .toEqual({ type: "function", name: "tool_a" }); + }); +}); From 12368a05624b93a02df5bfe8e8cbcd7761eaf61d Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 14:12:02 +0900 Subject: [PATCH 02/11] fix(adapters): preserve tool declaration strict and allowed_callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fields a caller sets on a tool declaration were parsed, carried internally, and then dropped by the outbound adapter, so the request was dispatched as though the constraint were in force and answered normally. Messages to Messages rebuilt every tool from name, description and input_schema alone. Anthropic is the target that defines strict, the Messages inbound already kept the source intent deliberately, and the OpenAI Chat adapter already forwarded it, so Anthropic was the one destination losing it. It now emits an explicit strict: true. An unstated strict stays absent: the inbound records it as false, so a false on the wire cannot be told apart from silence and must not become an opt-out nobody asked for. allowed_callers had no carrier at all. The identifier existed once in the tree, raising a caller_mode diagnostic that only becomes a refusal when the operator has set claudeCode.compatibility. The field now rides OcxTool.allowedCallers from the Messages inbound through the Responses schema — where an undeclared key is stripped, which is why it never reached buildTools — to the Anthropic wire. The OpenAI Chat and Gemini builders have no counterpart for it, so they refuse with a 400 rather than rebuild the declaration without the fence, in the shape ollama-native and kiro already use for a tool_choice they cannot enforce. The unrestricted ["direct"] default is not treated as a restriction. Gemini expresses schema-enforced calling as functionCallingConfig.mode VALIDATED. The mode was plumbed to the wire compiler but only reachable by matching a model name, so a strict declaration arrived as an ordinary AUTO turn. It now replaces the absent-choice default. NONE, ANY and a forced-name choice are stronger constraints the caller asked for and are never overwritten. Native passthrough is unaffected on every route. Closes #5210 --- scripts/test-layout/layout.json | 2 + src/adapters/anthropic.ts | 6 + src/adapters/google.ts | 29 ++++- src/adapters/openai-chat/tool-schema.ts | 2 + src/adapters/tool-declaration-constraints.ts | 28 +++++ src/claude/inbound-content-options.ts | 6 + src/responses/parser-tools.ts | 3 + src/responses/schema.ts | 3 + src/types/tools.ts | 19 +++ structure/providers-and-adapters.md | 5 +- ...ropic-tool-declaration-constraints.test.ts | 110 ++++++++++++++++++ .../google-strict-tool-validated-mode.test.ts | 69 +++++++++++ tests/fixtures/test-layout-expected.json | 2 + 13 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 src/adapters/tool-declaration-constraints.ts create mode 100644 tests/adapters/anthropic/anthropic-tool-declaration-constraints.test.ts create mode 100644 tests/adapters/google/google-strict-tool-validated-mode.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 76ce483f718..272d86103c8 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1568,6 +1568,8 @@ "codex-shim-destroyed-probe.test.ts": "codex-integration", "client-runtime.test.ts": "clients", "chat-tool-choice-allowed-tools.test.ts": "responses", + "anthropic-tool-declaration-constraints.test.ts": "adapters/anthropic", + "google-strict-tool-validated-mode.test.ts": "adapters/google", "devin-output-budget.test.ts": "providers", "api-key-model-scope.test.ts": "server" }, diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index c90f8009a66..a0156066fa2 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -857,6 +857,12 @@ function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: ( name: toolNames.toWire(namespacedToolName(t.namespace, t.name)), description: t.description, input_schema: normalizeAnthropicInputSchema(t.parameters), + // Anthropic is the target that DEFINES both of these, and both were dropped while the + // OpenAI Chat adapter already forwarded strict (#5210). Only an explicit `true` is + // emitted: the Messages inbound records an absent strict as `false`, so a false here + // cannot be distinguished from silence and must not become an opt-out on the wire. + ...(t.strict === true ? { strict: true } : {}), + ...(t.allowedCallers !== undefined ? { allowed_callers: [...t.allowedCallers] } : {}), })); return converted; } diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 1e8631ae645..19a48e3674b 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -15,6 +15,8 @@ import type { OcxUsage, } from "../types"; import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../types"; +import type { OcxTool } from "../types"; +import { assertToolCallerRestrictionsRepresentable } from "./tool-declaration-constraints"; import { contentPartsToText, parseDataUrl } from "./image"; import { getVertexAccessToken } from "../lib/gcp-adc"; import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http"; @@ -465,10 +467,9 @@ function messagesToGeminiFormat( function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined { if (!parsed.context.tools?.length) return undefined; - const tools = isAllowedToolChoice(parsed.options.toolChoice) - ? parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice, parsed.context.tools)) - : parsed.context.tools; + const tools = advertisedGeminiTools(parsed); if (tools.length === 0) return undefined; + assertToolCallerRestrictionsRepresentable(tools, "the Gemini generateContent wire"); return [{ functionDeclarations: tools.map(t => ({ name: namespacedToolName(t.namespace, t.name), @@ -478,19 +479,37 @@ function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined { }]; } +/** The declarations this request actually advertises, after any allowed-tools filter. */ +function advertisedGeminiTools(parsed: OcxParsedRequest): readonly OcxTool[] { + const declared = parsed.context.tools ?? []; + return isAllowedToolChoice(parsed.options.toolChoice) + ? declared.filter(toolChoiceToolPredicate(parsed.options.toolChoice, declared)) + : declared; +} + /** * Client tool_choice enforcement on the wire. The catalog nudge states the same contract in * prose, but without functionCallingConfig the model is free to ignore it. "auto" stays absent * so the common case is byte-identical. The allowedTools variant already filters the * declarations in toolsToGeminiFormat; only its "required" half needs a wire mode. + * + * A caller that declares strict tools is asking for its argument schemas to be enforced, and + * Gemini expresses that as VALIDATED. The mode existed and was plumbed end to end, but was only + * ever reachable by matching a model name, so a strict declaration arrived as an ordinary + * unvalidated AUTO turn and the response looked the same either way (#5210). VALIDATED replaces + * AUTO only: ANY and NONE are stronger constraints the caller asked for explicitly, and + * overwriting either of them would lose the choice this function exists to enforce. */ function toolChoiceToGeminiToolConfig(parsed: OcxParsedRequest): Record | undefined { const choice = parsed.options.toolChoice; - if (!choice || choice === "auto") return undefined; + const validated = advertisedGeminiTools(parsed).some(t => t.strict === true) + ? { functionCallingConfig: { mode: "VALIDATED" } } + : undefined; + if (!choice || choice === "auto") return validated; if (choice === "none") return { functionCallingConfig: { mode: "NONE" } }; if (choice === "required") return { functionCallingConfig: { mode: "ANY" } }; if (isAllowedToolChoice(choice)) { - return choice.mode === "required" ? { functionCallingConfig: { mode: "ANY" } } : undefined; + return choice.mode === "required" ? { functionCallingConfig: { mode: "ANY" } } : validated; } return { functionCallingConfig: { diff --git a/src/adapters/openai-chat/tool-schema.ts b/src/adapters/openai-chat/tool-schema.ts index 23f77121bd5..02994bc8e9a 100644 --- a/src/adapters/openai-chat/tool-schema.ts +++ b/src/adapters/openai-chat/tool-schema.ts @@ -4,6 +4,7 @@ import { isXaiSchemaTarget, lookupLocalJsonPointer, normalizeXaiToolParameters } import { stripResponsesOnlyEncryptedMarker, stripUnicodePropertyPatterns } from "../responses-tool-schema"; import { isAllowedToolChoice, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../../types"; import type { OcxParsedRequest, OcxProviderConfig } from "../../types"; +import { assertToolCallerRestrictionsRepresentable } from "../tool-declaration-constraints"; const ZEN_SCHEMA_MAP_KEYS = new Set(["properties", "$defs", "definitions"]); const ZEN_DROPPED_SCHEMA_KEYS = new Set(["encrypted"]); @@ -418,6 +419,7 @@ export function toolsToChatFormat( if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined; const tools = parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice, parsed.context.tools)); if (tools.length === 0) return undefined; + assertToolCallerRestrictionsRepresentable(tools, "the OpenAI Chat Completions wire"); const xaiTarget = isXaiSchemaTarget(provider); const moonshotTarget = !xaiTarget && isMoonshotSchemaTarget(provider); const formatted = tools.flatMap(t => { diff --git a/src/adapters/tool-declaration-constraints.ts b/src/adapters/tool-declaration-constraints.ts new file mode 100644 index 00000000000..e2e4eb84f60 --- /dev/null +++ b/src/adapters/tool-declaration-constraints.ts @@ -0,0 +1,28 @@ +import { namespacedToolName, toolRestrictsCallers, type OcxTool } from "../types"; + +/** + * Refuse a request whose tool declarations carry a restriction this wire cannot express. + * + * A declaration field is not decoration: `allowed_callers` says which callers may invoke the + * tool, and a wire with no counterpart rebuilds the declaration without it. The model is then + * offered a tool the caller had fenced off, and the caller gets an ordinary completion with no + * way to tell the fence is gone (#5210). Refusing turns a silent widening into a 400 the caller + * can act on, the same shape `ollama-native` and `kiro` already use for a tool_choice they + * cannot enforce. + * + * Anthropic is the wire that defines the field and carries it; this guard is for the wires that + * do not. It is a per-wire opt-in rather than a global check because only the adapter knows + * whether its own target has a counterpart. + */ +export function assertToolCallerRestrictionsRepresentable( + tools: readonly OcxTool[] | undefined, + wire: string, +): void { + const restricted = tools?.find(toolRestrictsCallers); + if (!restricted) return; + const name = namespacedToolName(restricted.namespace, restricted.name); + throw new Error( + `${wire} cannot express tools[].allowed_callers, declared on "${name}". ` + + "Route this request to an Anthropic-protocol provider, or remove the caller restriction.", + ); +} diff --git a/src/claude/inbound-content-options.ts b/src/claude/inbound-content-options.ts index 0b59a93073a..fbfe6ed503e 100644 --- a/src/claude/inbound-content-options.ts +++ b/src/claude/inbound-content-options.ts @@ -35,6 +35,12 @@ export function toolsToResponses(tools: unknown): Rec[] | undefined { // call, so carry the source intent instead of the destination default. A // non-boolean value is not a valid Anthropic opt-in and must not become one. strict: typeof raw.strict === "boolean" ? raw.strict : false, + // Anthropic restricts who may invoke a tool through allowed_callers. Nothing read it, + // so the restriction never reached the internal tool and every destination rebuilt the + // declaration without it while the request still succeeded (#5210). + ...(Array.isArray(raw.allowed_callers) + ? { allowed_callers: raw.allowed_callers.filter((c): c is string => typeof c === "string") } + : {}), }); continue; } diff --git a/src/responses/parser-tools.ts b/src/responses/parser-tools.ts index 8812b0bbe66..763b061c8f6 100644 --- a/src/responses/parser-tools.ts +++ b/src/responses/parser-tools.ts @@ -61,6 +61,9 @@ export function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined parameters: normalizeParameters(t.parameters), }; if (t.strict !== undefined) tool.strict = t.strict as boolean; + if (Array.isArray(t.allowed_callers)) { + tool.allowedCallers = (t.allowed_callers as unknown[]).filter((c): c is string => typeof c === "string"); + } if (namespace) tool.namespace = namespace; out.push(tool); }; diff --git a/src/responses/schema.ts b/src/responses/schema.ts index fced1a9e6e2..556bc9fd978 100644 --- a/src/responses/schema.ts +++ b/src/responses/schema.ts @@ -122,6 +122,9 @@ export const toolSchema = z.object({ description: z.string().optional(), parameters: z.record(z.string(), z.unknown()).optional(), strict: z.boolean().optional(), + // Unknown keys are stripped here, so a field the parser is expected to read has to be + // declared: an undeclared allowed_callers never reached buildTools at all (#5210). + allowed_callers: z.array(z.string()).optional(), }); const builtinToolSchema = z.object({ type: z.string() }).loose(); diff --git a/src/types/tools.ts b/src/types/tools.ts index 83decd85b14..70d777d7491 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -3,6 +3,12 @@ export interface OcxTool { description: string; parameters: Record; strict?: boolean; + /** + * Anthropic `tools[*].allowed_callers`: which callers may invoke this tool. Carried rather + * than diagnosed, because rebuilding the declaration without it hands the model a tool the + * caller had restricted and returns a normal response (#5210). + */ + allowedCallers?: string[]; /** MCP namespace (e.g. "mcp__context7") for tools flattened out of a Responses "namespace" tool. */ namespace?: string; /** Freeform/custom tool (e.g. apply_patch): the model's call must be relayed as a custom_tool_call. */ @@ -31,6 +37,19 @@ export function namespacedToolName(namespace: string | undefined, name: string): return namespace ? `${namespace}__${name}` : name; } +/** + * Whether a declaration actually narrows who may call the tool. + * + * `["direct"]` is the state every unrestricted tool is already in, so treating it as a + * restriction would refuse ordinary traffic. Mirrors the `caller_mode` predicate in + * src/claude/compatibility.ts, which draws the same line. + */ +export function toolRestrictsCallers(tool: Pick): boolean { + const callers = tool.allowedCallers; + if (callers === undefined) return false; + return !(callers.length === 1 && callers[0] === "direct"); +} + /** * Dotted alias of a namespaced tool's wire name. Some routed providers (observed: muse-spark * via opencode-go) echo a namespaced tool call as "." instead of the flattened diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index f6ca700517b..bb4e7c2bfd7 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -16,8 +16,9 @@ the [bounded ingestion contract](transports/inventory.md#bounded-response-ingest | `src/adapters/openai-responses.ts` | Native OpenAI/ChatGPT Responses passthrough. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | | `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `tool-call-validation.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | -| `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. | -| `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. | +| `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. | +| `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. A caller-declared strict tool selects `functionCallingConfig.mode: "VALIDATED"` in place of the absent-choice default; `NONE`, `ANY` and a forced-name choice are stronger constraints the caller asked for and are never overwritten. | +| `src/adapters/tool-declaration-constraints.ts` | Refusal for tool-declaration fields a wire cannot express. `allowed_callers` fences a tool off from callers, so a wire with no counterpart would rebuild the declaration without it and answer normally; the OpenAI Chat and Gemini builders refuse instead. The unrestricted `["direct"]` default is not a restriction. Adoption is per wire, because only the adapter knows whether its target has a counterpart. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | | `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog and JWT support RPCs remain outside inference-send accounting. | diff --git a/tests/adapters/anthropic/anthropic-tool-declaration-constraints.test.ts b/tests/adapters/anthropic/anthropic-tool-declaration-constraints.test.ts new file mode 100644 index 00000000000..02047e10140 --- /dev/null +++ b/tests/adapters/anthropic/anthropic-tool-declaration-constraints.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test"; +import { createAnthropicAdapter } from "../../../src/adapters/anthropic"; +import { createGoogleAdapter } from "../../../src/adapters/google"; +import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import { anthropicToResponsesBody } from "../../../src/claude/inbound"; +import { parseRequest } from "../../../src/responses/parser"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; + +/** + * #5210. `strict` and `allowed_callers` are declaration fields Anthropic defines, and the + * Messages-to-Messages route rebuilt every tool from name, description and input_schema alone. + * The request succeeded, so a caller had no way to learn that the schema was no longer enforced + * or that the tool had been offered to a caller it was fenced off from. Each case below reads + * the request the adapter actually sends. + */ + +const anthropicProvider = { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + apiKey: "sk-x", + authMode: "apiKey", +} as unknown as OcxProviderConfig; + +function claudeTool(extra: Record): Record { + return { + name: "tool_a", + description: "Controlled tool.", + input_schema: { type: "object", properties: {} }, + ...extra, + }; +} + +function parsedFromClaude(tool: Record): OcxParsedRequest { + return parseRequest(anthropicToResponsesBody({ + model: "anthropic/claude-sonnet-4.5", + max_tokens: 64, + messages: [{ role: "user", content: "Call the tool." }], + tools: [tool], + })); +} + +async function anthropicTools(tool: Record): Promise>> { + const { body } = await createAnthropicAdapter(anthropicProvider).buildRequest(parsedFromClaude(tool)); + return (JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as { + tools: Array>; + }).tools; +} + +describe("anthropic tool declarations carry their caller-supplied constraints", () => { + test("an explicit strict:true survives the round trip", async () => { + const [tool] = await anthropicTools(claudeTool({ strict: true })); + expect(tool.strict).toBe(true); + expect(tool.name).toBe("tool_a"); + expect(tool.input_schema).toEqual({ type: "object", properties: {} }); + }); + + test("an unstated strict stays absent rather than becoming an opt-out", async () => { + const [tool] = await anthropicTools(claudeTool({})); + expect(tool).not.toHaveProperty("strict"); + const [explicitFalse] = await anthropicTools(claudeTool({ strict: false })); + expect(explicitFalse).not.toHaveProperty("strict"); + }); + + test("allowed_callers reaches the upstream instead of being rebuilt away", async () => { + const [tool] = await anthropicTools(claudeTool({ allowed_callers: ["code_execution_20260120"] })); + expect(tool.allowed_callers).toEqual(["code_execution_20260120"]); + }); + + test("a tool without allowed_callers gains no key", async () => { + const [tool] = await anthropicTools(claudeTool({})); + expect(tool).not.toHaveProperty("allowed_callers"); + }); +}); + +describe("wires without an allowed_callers counterpart refuse rather than widen", () => { + const restricted = claudeTool({ allowed_callers: ["code_execution_20260120"] }); + const unrestricted = claudeTool({ allowed_callers: ["direct"] }); + + test("the OpenAI Chat wire refuses a caller-restricted declaration", () => { + const adapter = createOpenAIChatAdapter({ + adapter: "openai-chat", + baseUrl: "https://gateway.example.internal/v1", + apiKey: "k", + }); + expect(() => adapter.buildRequest(parsedFromClaude(restricted))) + .toThrow(/cannot express tools\[\]\.allowed_callers/); + }); + + test("the Gemini wire refuses a caller-restricted declaration", async () => { + const adapter = createGoogleAdapter({ + adapter: "google", + baseUrl: "https://generativelanguage.googleapis.com", + apiKey: "key", + } as unknown as OcxProviderConfig); + await expect(adapter.buildRequest(parsedFromClaude(restricted))) + .rejects.toThrow(/cannot express tools\[\]\.allowed_callers/); + }); + + test('the unrestricted ["direct"] default is not treated as a restriction', () => { + const adapter = createOpenAIChatAdapter({ + adapter: "openai-chat", + baseUrl: "https://gateway.example.internal/v1", + apiKey: "k", + }); + const built = JSON.parse(adapter.buildRequest(parsedFromClaude(unrestricted)).body) as { + tools: Array<{ function: { name: string } }>; + }; + expect(built.tools.map(tool => tool.function.name)).toEqual(["tool_a"]); + }); +}); diff --git a/tests/adapters/google/google-strict-tool-validated-mode.test.ts b/tests/adapters/google/google-strict-tool-validated-mode.test.ts new file mode 100644 index 00000000000..f508162ec60 --- /dev/null +++ b/tests/adapters/google/google-strict-tool-validated-mode.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import { createGoogleAdapter } from "../../../src/adapters/google"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; + +/** + * #5210 case 3. Gemini expresses schema-enforced function calling as + * `functionCallingConfig.mode: "VALIDATED"`. The mode was plumbed all the way to the wire + * compiler but was only reachable by matching a model name, so a caller that declared strict + * tools got an ordinary AUTO turn and a normal answer. These assertions read the compiled + * request body, which is the only place the difference is visible. + */ + +const provider = { + adapter: "google", + baseUrl: "https://generativelanguage.googleapis.com", + apiKey: "key", +} as unknown as OcxProviderConfig; + +const STRICT_TOOLS = [ + { name: "get_weather", description: "", parameters: { type: "object", properties: {} }, strict: true }, + { name: "shot", description: "", parameters: { type: "object", properties: {} } }, +]; +const LOOSE_TOOLS = STRICT_TOOLS.map(({ strict: _strict, ...rest }) => rest); + +async function toolConfig(tools: unknown[], toolChoice?: unknown): Promise { + const parsed = { + modelId: "gemini-3-pro", + stream: false, + options: toolChoice === undefined ? {} : { toolChoice }, + context: { messages: [{ role: "user", content: "hi" }], tools }, + } as unknown as OcxParsedRequest; + const { body } = await createGoogleAdapter(provider).buildRequest(parsed); + return (JSON.parse(body) as Record).toolConfig; +} + +describe("strict tool declarations select Gemini VALIDATED function calling", () => { + test("a strict declaration turns the absent-choice default into VALIDATED", async () => { + expect(await toolConfig(STRICT_TOOLS)).toEqual({ functionCallingConfig: { mode: "VALIDATED" } }); + expect(await toolConfig(STRICT_TOOLS, "auto")).toEqual({ functionCallingConfig: { mode: "VALIDATED" } }); + }); + + test("an allowed-tools subset in auto mode keeps VALIDATED when the subset is strict", async () => { + expect(await toolConfig(STRICT_TOOLS, { allowedTools: ["get_weather"], mode: "auto" })) + .toEqual({ functionCallingConfig: { mode: "VALIDATED" } }); + }); + + test("a subset that excludes the strict tool does not claim validation", async () => { + expect(await toolConfig(STRICT_TOOLS, { allowedTools: ["shot"], mode: "auto" })).toBeUndefined(); + }); + + test("without a strict declaration the wire is unchanged", async () => { + expect(await toolConfig(LOOSE_TOOLS)).toBeUndefined(); + expect(await toolConfig(LOOSE_TOOLS, "auto")).toBeUndefined(); + expect(await toolConfig(LOOSE_TOOLS, { allowedTools: ["get_weather"], mode: "auto" })).toBeUndefined(); + }); + + test("a stronger caller-chosen mode is never overwritten by VALIDATED", async () => { + expect(await toolConfig(STRICT_TOOLS, "none")).toEqual({ functionCallingConfig: { mode: "NONE" } }); + expect(await toolConfig(STRICT_TOOLS, "required")).toEqual({ functionCallingConfig: { mode: "ANY" } }); + expect(await toolConfig(STRICT_TOOLS, { allowedTools: ["get_weather"], mode: "required" })) + .toEqual({ functionCallingConfig: { mode: "ANY" } }); + expect(await toolConfig(STRICT_TOOLS, { name: "get_weather" })) + .toEqual({ functionCallingConfig: { mode: "ANY", allowedFunctionNames: ["get_weather"] } }); + }); + + test("a request with no declared tools gains no toolConfig", async () => { + expect(await toolConfig([])).toBeUndefined(); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 60c8c861a8b..f54ff3f851c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1400,6 +1400,8 @@ "codex-shim-destroyed-probe.test.ts": "codex-integration", "client-runtime.test.ts": "clients", "chat-tool-choice-allowed-tools.test.ts": "responses", + "anthropic-tool-declaration-constraints.test.ts": "adapters/anthropic", + "google-strict-tool-validated-mode.test.ts": "adapters/google", "devin-output-budget.test.ts": "providers", "api-key-model-scope.test.ts": "server" } From fade36249d0548f98096c70010127ae31450f11b Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 14:32:42 +0900 Subject: [PATCH 03/11] fix(openai-chat): keep developer messages in their conversation position A developer message kept its slot only when the provider base URL host was exactly api.openai.com. On every other OpenAI-compatible Chat endpoint its text was appended to the system prompt and the message itself was skipped, so an instruction written to apply from the second turn onward arrived ahead of the first one and the caller got an ordinary completion either way. The two halves of a Claude Code route were working against each other because of it: #4161 established that folding in-conversation instructions into the prompt preamble is harmful and made the Claude inbound mint chronological developer items specifically to preserve timeline order, and this adapter then folded them again on every host but one. One destination already had the chronological behaviour, keyed to a model id and a registry entry, because hoisting a newly appended reminder rewrites the reusable prompt prefix. That is a property of prompt-prefix caching rather than of that destination, so it is now what every destination gets, and the model/registry test is gone. A reminder that arrives while a tool call is open is still deferred past the result, which is what keeps tool-call adjacency intact; it lands in its own slot immediately after, never at the front. This commit changes placement only. The wire role is still developer on api.openai.com and system elsewhere, and is addressed separately. Co-authored-by: Yum-wu <118118663+Yum-wu@users.noreply.github.com> --- scripts/test-layout/layout.json | 1 + src/adapters/openai-chat/messages.ts | 39 +++++------ structure/providers/chat-compat.md | 31 +++++---- .../openai-chat-dangling-toolcalls.test.ts | 22 +++---- .../openai-chat-developer-position.test.ts | 64 +++++++++++++++++++ .../openai/openai-chat-system-order.test.ts | 38 ++++++----- tests/fixtures/test-layout-expected.json | 1 + 7 files changed, 133 insertions(+), 63 deletions(-) create mode 100644 tests/adapters/openai/openai-chat-developer-position.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 272d86103c8..5c92c73884a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1570,6 +1570,7 @@ "chat-tool-choice-allowed-tools.test.ts": "responses", "anthropic-tool-declaration-constraints.test.ts": "adapters/anthropic", "google-strict-tool-validated-mode.test.ts": "adapters/google", + "openai-chat-developer-position.test.ts": "adapters/openai", "devin-output-budget.test.ts": "providers", "api-key-model-scope.test.ts": "server" }, diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index 8e88dabc6b0..0d35f5e8fb2 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -5,9 +5,8 @@ import { contentPartsToText } from "../image"; import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "../empty-tool-output-annotation"; import { identifyRoutedModel } from "../identity"; import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "../tool-catalog-nudge"; -import { registryEntryForProviderDestination } from "../../providers/registry"; import { peekReasoningForCall } from "../../responses/reasoning-replay-cache"; -import type { OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall } from "../../types"; +import type { OcxAssistantMessage, OcxContentPart, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall } from "../../types"; import { modelInList, namespacedToolName } from "../../types"; /** @@ -21,13 +20,6 @@ import { modelInList, namespacedToolName } from "../../types"; */ const VIDEO_UNSUPPORTED_MARKER = "[video omitted: the translated Chat route has no video mapping]"; -export function developerSystemText(message: OcxMessage): string | undefined { - if (message.role !== "developer") return undefined; - if (typeof message.content === "string") return message.content; - if (message.content.some(part => part.type === "image")) return undefined; - return message.content.map(part => (part as OcxTextContent).text).join(""); -} - /** * Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool" * content is text-only on every chat provider, so these ride in a follow-up user message instead of @@ -121,21 +113,22 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv }; const nativeOpenAI = isNativeOpenAIChatTarget(provider); - // Hoisting a newly appended reminder rewrites the reusable prompt prefix. - // Keep this compatibility exception on the destination/model tested with OCG. - const chronologicalSystem = parsed.modelId === "deepseek-v4.1-flash" - && registryEntryForProviderDestination(provider)?.id === "opencode-go"; + // A developer message keeps the slot it arrived in. Hoisting its text into the leading + // system block moved a mid-conversation instruction ahead of every turn it was written to + // follow, and the caller saw an ordinary answer either way (#5213). The Claude inbound mints + // chronological developer items for exactly this reason (#4161), so the two halves of the + // route were working against each other on every host but api.openai.com. Placement is now + // uniform; which ROLE that slot carries is decided separately below. + // + // One destination already had the chronological behaviour, keyed to a model and a registry + // id, because hoisting a newly appended reminder rewrites the reusable prompt prefix. That is + // a property of prompt-prefix caching rather than of that destination, and it is now what + // every destination gets. const toolCatalogNudge = shouldInjectNonOpenAIToolCatalogNudge(provider) ? buildNonOpenAIToolCatalogNudgeForTools(context.tools, options.toolChoice) : undefined; - const developerSystemParts = nativeOpenAI || chronologicalSystem - ? [] - : context.messages - .map(developerSystemText) - .filter((part): part is string => part !== undefined && part.length > 0); const systemParts = [ ...(context.systemPrompt ?? []), - ...developerSystemParts, ...(toolCatalogNudge ? [toolCatalogNudge] : []), ]; if (systemParts.length > 0) { @@ -154,13 +147,13 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv const hasImages = parts?.some(p => p.type === "image") ?? false; let chatMsg: Record; if (msg.role === "developer" && !hasImages) { - if (!nativeOpenAI && !chronologicalSystem) break; const text = typeof msg.content === "string" ? msg.content : parts!.map(p => (p as OcxTextContent).text).join(""); - // A non-text timeline part (video, for example) serializes to nothing here. - // The generic path drops such a message; the chronological exception must not - // turn it into an empty system message that some upstreams reject. + // A non-text timeline part (video, for example) serializes to nothing here. The + // generic user path drops such a message, and emitting a content-free system + // message instead is rejected by some upstreams. Native OpenAI keeps its existing + // empty-developer wire, which is a separate question from placement. if (!nativeOpenAI && text.length === 0) break; chatMsg = { role: nativeOpenAI ? "developer" : "system", content: text }; } else if (typeof msg.content === "string") { diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 6fe286bd76d..a12c3cd4fff 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -9,18 +9,20 @@ Native Codex Spark-specific request exceptions are absent. General Lite and name remain shared [Responses compatibility](../transports/responses.md#responses-httpsse), including other providers whose models happen to share a name fragment. -## OpenCode Go chronological instructions - -For the registry-recognized OpenCode Go Chat destination and exact model -`deepseek-v4.1-flash`, `src/adapters/openai-chat.ts` keeps text-only timeline -developer messages in place as system messages. Appending a reminder therefore -does not hoist new text into the leading system prompt and rewrite the existing -serialized message prefix. Pending tool results still precede deferred reminders. -The base system prompt, vision conversion and native OpenAI developer roles retain -their existing behavior; other Chat destinations and models retain leading-system -folding. This is independent of the Claude trailing-notice stabilization option -and does not guarantee upstream cache hits. Regression coverage is in -`tests/adapters/openai/openai-chat-system-order.test.ts`. +## Chronological in-conversation instructions + +`src/adapters/openai-chat/messages.ts` keeps a text-only timeline developer message in the +slot it arrived in, on every Chat destination and model. Appending a reminder therefore does +not hoist new text into the leading system prompt and rewrite the existing serialized message +prefix, and a mid-conversation instruction no longer moves ahead of the turns it was written +to follow. Pending tool results still precede deferred reminders. This was previously scoped +to the registry-recognized OpenCode Go destination and the exact model +`deepseek-v4.1-flash`, which made prompt-prefix stability read as a property of that one +destination. The base system prompt, vision conversion and native OpenAI developer roles +retain their existing behavior. This is independent of the Claude trailing-notice +stabilization option and does not guarantee upstream cache hits. Regression coverage is in +`tests/adapters/openai/openai-chat-system-order.test.ts` and +`tests/adapters/openai/openai-chat-developer-position.test.ts`. Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. @@ -418,8 +420,9 @@ The final registered adapter also checks the original input under the [untranslated-media contract](../adapters/registry.md#untranslated-input-media). Audio/file attachments cannot succeed merely because the normalized representation retained a text marker: translated adapters refuse them, while native Responses retains the original body. -Chat conversion rejects recognized audio/file parts before projection; the native Chat wire -is unchanged. No audio/file transport or automatic URL fetch is added, and no client filename, +Chat conversion rejects recognized audio/file parts before projection, except a user-content +file part whose inline base64 bytes now have a lossless carrier; the native Chat wire is +unchanged. No audio transport and no automatic URL fetch is added, and no client filename, payload, URL or metadata is included in the new error messages. The shared coding-agent projection (CodeBuddy, Qoder) carries tool-result images as diff --git a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts index df5e8afc5b7..855b7391527 100644 --- a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts +++ b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts @@ -90,7 +90,7 @@ function assertWireInvariants(messages: ChatMsg[]): void { } describe("openai-chat dangling tool_calls hardening", () => { - test("T1 incident: developer guidance is hoisted while the real result reattaches to the original call", () => { + test("T1 incident: developer guidance lands after the result while the result reattaches to the original call", () => { const messages = wire([ user("hi"), assistantWithCalls([{ id: "call_x", name: "request_user_input" }]), @@ -100,14 +100,14 @@ describe("openai-chat dangling tool_calls hardening", () => { ]); assertWireInvariants(messages); const roles = messages.map(m => m.role); - expect(messages[0]).toEqual({ role: "system", content: "[injected guidance]" }); - // canonical history order: assistant, tool(real), user; no in-history system barrier + expect(messages[0]).toEqual({ role: "user", content: "hi" }); + // canonical history order: assistant, tool(real), then the barrier in its own slot (#5213) const aIdx = roles.indexOf("assistant"); expect(roles[aIdx + 1]).toBe("tool"); expect(messages[aIdx + 1].tool_call_id).toBe("call_x"); expect(messages[aIdx + 1].content).toBe('{"answers":{}}'); - expect(roles[aIdx + 2]).toBe("user"); - expect(messages.slice(1).some(m => m.role === "system")).toBe(false); + expect(messages[aIdx + 2]).toEqual({ role: "system", content: "[injected guidance]" }); + expect(roles[aIdx + 3]).toBe("user"); // no synthetic result fabricated for an answered call expect(messages.some(m => typeof m.content === "string" && m.content.includes("no tool result was recorded"))).toBe(false); }); @@ -166,9 +166,9 @@ describe("openai-chat dangling tool_calls hardening", () => { expect(String(synth?.content)).toContain("no tool result was recorded"); const real = block.find(m => m.tool_call_id === "call_2"); expect(real?.content).toBe("img-ok"); - expect(messages[0]).toEqual({ role: "system", content: "barrier while call_1 pending" }); - expect(messages[aIdx + 3].role).toBe("assistant"); - expect(messages.slice(1).some(m => m.role === "system")).toBe(false); + expect(messages[0]).toEqual({ role: "user", content: "hi" }); + expect(messages[aIdx + 3]).toEqual({ role: "system", content: "barrier while call_1 pending" }); + expect(messages[aIdx + 4].role).toBe("assistant"); }); test("T6 mismatched result while calls pending: round closes synthetically, then orphan pair", () => { @@ -183,9 +183,9 @@ describe("openai-chat dangling tool_calls hardening", () => { expect(messages[aIdx + 1].role).toBe("tool"); expect(messages[aIdx + 1].tool_call_id).toBe("call_p"); expect(String(messages[aIdx + 1].content)).toContain("no tool result was recorded"); - expect(messages[0]).toEqual({ role: "system", content: "deferred barrier" }); - expect(messages[aIdx + 2].role).toBe("assistant"); - expect(messages.slice(1).some(m => m.role === "system")).toBe(false); + expect(messages[0]).toEqual({ role: "user", content: "hi" }); + expect(messages[aIdx + 2]).toEqual({ role: "system", content: "deferred barrier" }); + expect(messages[aIdx + 3].role).toBe("assistant"); const orphanIdx = messages.findIndex((m, i) => i > aIdx && m.role === "assistant"); expect(messages[orphanIdx].tool_calls?.[0].id).toBe("call_unknown"); expect(messages[orphanIdx + 1].tool_call_id).toBe("call_unknown"); diff --git a/tests/adapters/openai/openai-chat-developer-position.test.ts b/tests/adapters/openai/openai-chat-developer-position.test.ts new file mode 100644 index 00000000000..e5e2a622d19 --- /dev/null +++ b/tests/adapters/openai/openai-chat-developer-position.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; + +/** + * #5213. A `developer` message used to keep its slot only when the provider base URL host was + * exactly `api.openai.com`. Everywhere else its text was appended to the system prompt and the + * message itself was skipped, so an instruction written to apply from the second turn onward + * arrived ahead of the first one. Both shapes return a normal completion, which is why these + * assertions read the serialized request body rather than the response. + */ + +const gateway: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://gateway.example.internal/v1", + apiKey: "k", +}; + +function wireMessages(provider: OcxProviderConfig): Array> { + const parsed = { + modelId: "local-model", + context: { + systemPrompt: ["base instructions"], + messages: [ + { role: "user", content: "First turn.", timestamp: 0 }, + { role: "developer", content: "Answer in exactly one sentence.", timestamp: 0 }, + { role: "user", content: "Second turn.", timestamp: 0 }, + ], + }, + stream: false, + options: {}, + } as unknown as OcxParsedRequest; + const request = createOpenAIChatAdapter(provider).buildRequest(parsed); + return (JSON.parse(request.body) as { messages: Array> }).messages; +} + +describe("developer message placement on the Chat wire", () => { + test("a non-OpenAI gateway keeps the instruction between the two turns", () => { + const messages = wireMessages(gateway); + expect(messages.map(message => message.role)).toEqual(["system", "user", "system", "user"]); + expect(messages[0]).toEqual({ role: "system", content: "base instructions" }); + expect(messages[1]).toEqual({ role: "user", content: "First turn." }); + expect(messages[2]).toEqual({ role: "system", content: "Answer in exactly one sentence." }); + expect(messages[3]).toEqual({ role: "user", content: "Second turn." }); + }); + + test("the leading system block no longer absorbs the instruction", () => { + expect(String(wireMessages(gateway)[0].content)).not.toContain("Answer in exactly one sentence."); + }); + + test("placement does not depend on the destination host", () => { + const hosts = [ + "https://openrouter.ai/api/v1", + "http://localhost:1234/v1", + "https://api.openai.com/v1", + ]; + for (const baseUrl of hosts) { + const messages = wireMessages({ ...gateway, baseUrl }); + expect(messages.map(message => message.role).indexOf("user")).toBe(1); + expect(messages[2].content).toBe("Answer in exactly one sentence."); + expect(messages[3]).toEqual({ role: "user", content: "Second turn." }); + } + }); +}); diff --git a/tests/adapters/openai/openai-chat-system-order.test.ts b/tests/adapters/openai/openai-chat-system-order.test.ts index def3179d814..4ad3d8a9fb8 100644 --- a/tests/adapters/openai/openai-chat-system-order.test.ts +++ b/tests/adapters/openai/openai-chat-system-order.test.ts @@ -21,7 +21,7 @@ function buildMessages(context: OcxParsedRequest["context"]): Array { - test("folds interleaved developer reminders into one leading system message", () => { + test("keeps interleaved developer reminders in their original slots", () => { const messages = buildMessages({ systemPrompt: ["base instructions"], messages: [ @@ -44,13 +44,15 @@ describe("openai-chat system message ordering", () => { expect(messages[0]).toEqual({ role: "system", - content: "base instructions\n\nfirst reminder\n\nsecond reminder", + content: "base instructions", }); - expect(messages.slice(1).map(message => message.role)).toEqual(["user", "assistant", "user"]); - expect(messages.slice(1).some(message => message.role === "system")).toBe(false); + expect(messages.map(message => message.role)) + .toEqual(["system", "user", "system", "assistant", "system", "user"]); + expect(messages[2]).toEqual({ role: "system", content: "first reminder" }); + expect(messages[4]).toEqual({ role: "system", content: "second reminder" }); }); - test("keeps tool calls and results adjacent when a developer reminder follows the call", () => { + test("defers a reminder past a pending tool result instead of hoisting it", () => { const messages = buildMessages({ messages: [ { role: "user", content: "inspect", timestamp: 0 }, @@ -72,9 +74,11 @@ describe("openai-chat system message ordering", () => { ], }); - expect(messages[0]).toEqual({ role: "system", content: "remember the policy" }); - expect(messages.map(message => message.role)).toEqual(["system", "user", "assistant", "tool"]); - expect(messages[3]).toMatchObject({ role: "tool", tool_call_id: "call_1" }); + // The reminder arrived while call_1 was open. Emitting it there would break tool-call + // adjacency, so it is released immediately after the result rather than moved to the front. + expect(messages.map(message => message.role)).toEqual(["user", "assistant", "tool", "system"]); + expect(messages[2]).toMatchObject({ role: "tool", tool_call_id: "call_1" }); + expect(messages[3]).toEqual({ role: "system", content: "remember the policy" }); }); test("keeps developer vision content as a user-compatible message in place", () => { @@ -104,7 +108,7 @@ describe("openai-chat system message ordering", () => { }); }); -describe("OpenCode Go DeepSeek chronological system messages", () => { +describe("chronological in-conversation system messages", () => { const model = "deepseek-v4.1-flash"; const ocg: OcxProviderConfig = { adapter: "openai-chat", @@ -167,7 +171,7 @@ describe("OpenCode Go DeepSeek chronological system messages", () => { test.each([ "https://opencode.ai/zen/go/v1/", "https://opencode.ai:443/zen/go/v1", - ])("matches the canonical destination %s", baseUrl => { + ])("keeps the reminder last on the canonical OpenCode Go destination %s", baseUrl => { expect(build(history, { ...ocg, baseUrl }).messages.at(-1).role).toBe("system"); }); @@ -177,14 +181,18 @@ describe("OpenCode Go DeepSeek chronological system messages", () => { "https://opencode.ai:444/zen/go/v1", "http://opencode.ai/zen/go/v1", "http://localhost:1234/v1", - ])("retains generic hoisting for other destinations: %s", baseUrl => { + ])("keeps the same chronological placement on other destinations: %s", baseUrl => { const messages = build(history, { ...ocg, baseUrl }).messages; - expect(messages[0].content).toContain("Synthetic reminder A."); - expect(messages.map((message: { role: string }) => message.role)).toEqual(["system", "user", "assistant"]); + expect(messages[0].content).not.toContain("Synthetic reminder A."); + expect(messages.map((message: { role: string }) => message.role)) + .toEqual(["system", "user", "assistant", "system"]); + expect(messages.at(-1)).toEqual({ role: "system", content: "Synthetic reminder A." }); }); - test("retains generic hoisting for other OCG models", () => { - expect(build(history, ocg, "kimi-k3").messages[0].content).toContain("Synthetic reminder A."); + test("placement no longer depends on the model either", () => { + const messages = build(history, ocg, "kimi-k3").messages; + expect(messages[0].content).not.toContain("Synthetic reminder A."); + expect(messages.at(-1)).toEqual({ role: "system", content: "Synthetic reminder A." }); }); test("retains native OpenAI developer roles", () => { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f54ff3f851c..d147b008287 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1402,6 +1402,7 @@ "chat-tool-choice-allowed-tools.test.ts": "responses", "anthropic-tool-declaration-constraints.test.ts": "adapters/anthropic", "google-strict-tool-validated-mode.test.ts": "adapters/google", + "openai-chat-developer-position.test.ts": "adapters/openai", "devin-output-budget.test.ts": "providers", "api-key-model-scope.test.ts": "server" } From c0220dafb30e0743054e8b34371de6eca8efa8a5 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 14:35:20 +0900 Subject: [PATCH 04/11] fix(openai-chat): forward the developer role instead of inferring it from the host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A developer message reached the upstream as developer only when the provider base URL host was exactly api.openai.com. Everywhere else it was rewritten to system, so every OpenAI-compatible gateway was assumed not to support a standard Chat Completions role until proven otherwise — including gateways that proxy OpenAI itself — and the instruction silently lost the precedence the caller chose. The role is now forwarded as sent. A destination that genuinely rejects it sets foldDeveloperRoleToSystem, which converts the role where the message already is and never moves it, so the placement contract from the previous commit holds on both paths. That makes the conversion a recorded decision about one destination rather than an inference from its hostname, which is what the hostname test could never express. The flag is registered in the provider config schema and in the exhaustive provider field policy, which is keyed on keyof OcxProviderConfig and fails typecheck until a new key is classified. Closes #5213 --- .../src/content/docs/fr/guides/claude-code.md | 2 +- .../fr/reference/configuration/providers.md | 1 + .../src/content/docs/guides/claude-code.md | 20 ++++++----- .../src/content/docs/ja/guides/claude-code.md | 2 +- .../ja/reference/configuration/providers.md | 1 + .../src/content/docs/ko/guides/claude-code.md | 2 +- .../ko/reference/configuration/providers.md | 1 + .../docs/reference/configuration/providers.md | 1 + .../src/content/docs/ru/guides/claude-code.md | 2 +- .../ru/reference/configuration/providers.md | 1 + .../src/content/docs/tr/guides/claude-code.md | 2 +- .../tr/reference/configuration/providers.md | 1 + .../content/docs/zh-cn/guides/claude-code.md | 2 +- .../reference/configuration/providers.md | 1 + .../content/docs/zh-tw/guides/claude-code.md | 2 +- .../reference/configuration/providers.md | 1 + src/adapters/openai-chat/messages.ts | 9 ++++- src/config/schema/leaf-validators.ts | 1 + src/server/auth-cors.ts | 1 + src/types/provider.ts | 13 +++++++ structure/providers/chat-compat.md | 7 ++++ .../openai-chat-dangling-toolcalls.test.ts | 6 ++-- .../openai-chat-developer-position.test.ts | 28 +++++++++++++-- .../openai/openai-chat-system-order.test.ts | 36 +++++++++++-------- 24 files changed, 106 insertions(+), 37 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index d207a3bc003..3e8decdd978 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -628,4 +628,4 @@ Utilisez `"haiku"` comme valeur de remplacement pour le modèle. Dans `config.json`, `claudeCode.stabilizePromptCache: true` déplace les notices Claude reconnues en fin des instructions système vers un dernier message utilisateur sur les routes traduites. La valeur par défaut est `false`. Activez cette option seulement si ce changement de rôle convient à vos clients. Les exemples dans des blocs de code et le texte non reconnu sont conservés ; le transfert Anthropic natif reste inchangé. Sans métadonnées, la clé de cache suit les instructions stabilisées. Cette option ne crée pas une identité de conversation et ne garantit aucun succès du cache amont. -Sur la route Chat d’OpenCode Go pour `deepseek-v4.1-flash`, les rappels système traduits dans l’historique conservent automatiquement leur position et leur rôle system, après les résultats d’outils encore attendus. Ainsi, l’ajout de rappels ne réécrit pas le prompt système initial. Ce comportement s’applique avec ou sans `stabilizePromptCache` ; la conversion des autres modèles et destinations, ainsi que le transfert Anthropic natif, restent inchangés. La réutilisation du cache exige toujours une identité de session stable et un cache disponible en amont. Les changements des instructions ou outils antérieurs et la compaction de la conversation peuvent aussi affecter les succès du cache ; préserver l’ordre des rappels ne suffit pas à garantir sa réutilisation. +Sur toutes les routes Chat traduites, les rappels de l’historique conservent leur position dans la conversation, après les résultats d’outils encore attendus, et sont transmis avec le rôle `developer`. L’ajout d’un rappel ne réécrit donc pas le prompt système initial, et une instruction placée au milieu de la conversation n’arrive plus avant les tours qu’elle était censée suivre. Si le service en amont refuse le rôle `developer`, activez `foldDeveloperRoleToSystem` sur ce fournisseur : le rappel est alors envoyé en `system`, à la même position. Ce comportement s’applique avec ou sans `stabilizePromptCache` ; le transfert Anthropic natif reste inchangé. La réutilisation du cache exige toujours une identité de session stable et un cache disponible en amont. Les changements des instructions ou outils antérieurs et la compaction de la conversation peuvent aussi affecter les succès du cache ; préserver l’ordre des rappels ne suffit pas à garantir sa réutilisation. diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index a96d502b6c4..be5c9dcad1f 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -132,6 +132,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `noPenaltyModels?` | `string[]` | Modèles qui rejettent les pénalités presence/frequency. | | `noStructuredOutputModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette `response_format`. Seule une correspondance exacte du modèle demandé omet le champ ; la traduction à sortie structurée reste activée pour tous les autres modèles `openai-chat`. | | `noJsonSchemaModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette un `response_format` `json_schema` mais accepte encore `json_object`. Une telle requête est rétrogradée vers `json_object` au lieu d’être supprimée, donc un appelant qui demande du JSON en reçoit toujours. `noStructuredOutputModels` l’emporte quand un modèle figure dans les deux listes. Les préréglages `opencode go`, `opencode zen` et `opencode free` l’embarquent pour leurs routes DeepSeek. | +| `foldDeveloperRoleToSystem?` | `boolean` | Envoyer un message `developer` en `system` pour un fournisseur `openai-chat` dont le service en amont refuse le rôle `developer`. Dans les deux cas le message conserve sa position dans la conversation ; seul le rôle change. La valeur par défaut est `false`, donc le rôle standard de Chat Completions est transmis tel quel. | | `parallelToolCalls?` | `boolean` | Contrôler les appels d’outils parallèles. Pour `openai-chat`, ils sont activés par défaut ; `false` envoie explicitement `parallel_tool_calls: false`. Les autres adaptateurs ne les annoncent que lorsque la valeur vaut explicitement `true`. | | `terminalContinuationGuard?` | `boolean` | Active, pour un fournisseur `openai-chat`, une relance interne bornée lorsqu’un tour exploitable annonce une action puis s’arrête proprement sans appel d’outil. La valeur par défaut est `false`, et une valeur explicite `false` équivaut à l’absence du champ. Les tentatives de combinaison et les tours de compactage routés sont exclus ; les autres adaptateurs ignorent cette option. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Réparation SSE en aval désactivée par défaut pour les identifiants d'espace réservé exacts, les identifiants de terminal manquants et (avec `repairInvalidIds`) les identifiants message/reasoning manquant du préfixe canonique `msg_`/`rs_`. Les identifiants d’appel de fonction ne sont jamais réécrits. Le DeepSeek intégré active les deux derniers par défaut. | diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 89078ca1f6d..5f04d0f41b9 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -708,12 +708,14 @@ route. Pass `"haiku"` as the model placeholder. Set `claudeCode.stabilizePromptCache` to `true` in `config.json` to relocate supported trailing Claude harness notices from system instructions to a trailing user message on translated routes. The default is `false`. Enable it only when this role change is appropriate for your clients. It preserves fenced examples and unmatched text; native Anthropic passthrough is unchanged. The metadata-less prompt-cache key then follows stabilized instructions. This does not create conversation identity or guarantee upstream cache hits. -On OpenCode Go's `deepseek-v4.1-flash` Chat route, translated timeline system -reminders automatically retain their position and system role, after any pending -tool results. This prevents newly appended reminders from rewriting the leading -system prompt. It applies with or without `stabilizePromptCache`; other models -and destinations keep their existing conversion; native Anthropic passthrough -is unchanged. Cache reuse still requires stable session identity and upstream -cache availability. Changes to earlier instructions or tools, and conversation -compaction, can still affect cache hits; preserving reminder order alone does -not guarantee reuse. +On every translated Chat route, timeline reminders keep their position in the +conversation, after any pending tool results, and are forwarded with the +`developer` role. This prevents a newly appended reminder from rewriting the +leading system prompt, and stops a mid-conversation instruction from arriving +ahead of the turns it was written to follow. Set `foldDeveloperRoleToSystem` on +a provider whose upstream rejects the `developer` role; the reminder is then +sent as `system` in the same position. This applies with or without +`stabilizePromptCache`, and native Anthropic passthrough is unchanged. Cache +reuse still requires stable session identity and upstream cache availability. +Changes to earlier instructions or tools, and conversation compaction, can still +affect cache hits; preserving reminder order alone does not guarantee reuse. diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index 4dfa380673f..b797f5a69e0 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -498,4 +498,4 @@ Anthropic バックエンドを明示すると意図的に失敗後停止しま `config.json` の `claudeCode.stabilizePromptCache` を `true` にすると、変換ルートのシステム指示末尾にある対応済み Claude 通知を最後のユーザーメッセージへ移します。既定値は `false` です。このロール変更が適切なクライアントでのみ有効にしてください。コードフェンス内の例と一致しない本文は保持され、Anthropic のネイティブ転送は変わりません。メタデータがない場合のキャッシュキーは安定化した指示から計算されます。会話 ID の生成やキャッシュヒットの保証は行いません。 -OpenCode Go の `deepseek-v4.1-flash` Chat ルートでは、変換されたタイムライン上のシステムリマインダーは、保留中のツール結果の後で位置と system ロールを自動的に維持します。これにより、新しいリマインダーを追加しても先頭のシステムプロンプトが書き換わりません。`stabilizePromptCache` の設定にかかわらず適用され、他のモデルや接続先の変換、および Anthropic のネイティブ転送は変わりません。キャッシュの再利用には、安定したセッション ID と上流キャッシュの利用可能性が引き続き必要です。過去の指示やツールの変更、会話の圧縮もキャッシュヒットに影響します。リマインダーの順序を保つだけで再利用が保証されるわけではありません。 +変換されたすべての Chat ルートで、タイムライン上のリマインダーは保留中のツール結果の後、会話内の元の位置を保ったまま `developer` ロールで転送されます。これにより、新しいリマインダーを追加しても先頭のシステムプロンプトが書き換わらず、会話の途中に置かれた指示がそれより前のターンの前に移動することもありません。上流が `developer` ロールを受け付けない場合は、そのプロバイダーに `foldDeveloperRoleToSystem` を設定してください。同じ位置のまま `system` として送信されます。`stabilizePromptCache` の設定にかかわらず適用され、Anthropic のネイティブ転送は変わりません。キャッシュの再利用には、安定したセッション ID と上流キャッシュの利用可能性が引き続き必要です。過去の指示やツールの変更、会話の圧縮もキャッシュヒットに影響します。リマインダーの順序を保つだけで再利用が保証されるわけではありません。 diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index a9fdaf8c813..20633fc2da4 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -125,6 +125,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `noPenaltyModels?` | `string[]` |存在/周波数ペナルティを拒否するモデル。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` エンドポイントが `response_format` を拒否する正確なモデル ID。要求モデルが項目と完全一致する場合だけフィールドを省略し、その他の `openai-chat` モデルでは structured-output 変換を維持します。 | | `noJsonSchemaModels?` | `string[]` | `openai-chat` エンドポイントが `json_schema` 形式は拒否しつつ `json_object` は受け入れる正確なモデル ID。この要求はフィールドを削除せず `json_object` に降格して送るため、JSON を求めた呼び出し側は散文ではなく JSON を受け取れます。両方の一覧に載るモデルでは `noStructuredOutputModels` が優先します。`opencode go` / `opencode zen` / `opencode free` プリセットが DeepSeek 経路に既定で載せます。 | +| `foldDeveloperRoleToSystem?` | `boolean` | 上流が `developer` ロールを受け付けない `openai-chat` プロバイダーで、`developer` メッセージを `system` として送ります。どちらの場合もメッセージは会話内の位置を保ち、変わるのはロールだけです。既定は `false` で、標準の Chat Completions ロールをそのまま転送します。 | | `parallelToolCalls?` | `boolean` |並列ツール呼び出しを切り替えます。 OpenAI Chat はデフォルトでオンになっています。非チャット アダプターは明示的な `true` でのみアドバタイズします。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` |正確なプレースホルダー ID、欠落している端末 ID、および(`repairInvalidIds` で)正規の `msg_`/`rs_` 接頭辞を欠く message/reasoning ID に対するダウンストリーム SSE 修復はデフォルトで無効になっています。関数呼び出し ID は決して書き換えられません。組み込み DeepSeek は最後の 2 つをデフォルトで有効にします。 | | `responsesSnapshotRepair?` | `boolean` | デフォルトで無効のクライアント向け修復です。SSE と JSON の Responses ライフサイクルで欠落した status、output、ツールメタデータを補完し、raw 検査と永続化は変更しません。 | diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 97ccbf35666..9dbce2e526d 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -563,4 +563,4 @@ Anthropic 백엔드를 명시하면 의도적으로 실패 후 중단해요. `config.json`에서 `claudeCode.stabilizePromptCache`를 `true`로 설정하면 번역 경로의 시스템 지시 끝에 붙은 지원 대상 Claude 알림을 마지막 사용자 메시지로 옮깁니다. 기본값은 `false`입니다. 사용하는 클라이언트에서 이 역할 변경을 허용할 때만 켜세요. 코드 펜스 안의 예제와 일치하지 않는 원문은 보존하며, Anthropic 원본 전달 경로는 바꾸지 않습니다. 메타데이터가 없는 요청의 캐시 키는 정리된 지시문을 기준으로 계산합니다. 대화 식별자를 만들거나 상위 서비스의 캐시 적중을 보장하는 기능은 아닙니다. -OpenCode Go의 `deepseek-v4.1-flash` Chat 경로에서는 변환된 타임라인 시스템 알림이 대기 중인 도구 결과 뒤에서 원래 위치와 system 역할을 자동으로 유지합니다. 따라서 새 알림을 추가해도 맨 앞의 시스템 프롬프트를 다시 쓰지 않습니다. `stabilizePromptCache` 설정과 관계없이 적용되며, 다른 모델과 대상의 변환 및 Anthropic 네이티브 전달은 기존 동작을 유지합니다. 캐시 재사용에는 안정적인 세션 식별자와 사용 가능한 상위 서비스 캐시가 여전히 필요합니다. 이전 지시나 도구의 변경, 대화 압축도 캐시 적중에 영향을 줄 수 있으며, 알림 순서를 유지하는 것만으로 재사용을 보장하지는 않습니다. +변환된 모든 Chat 경로에서 타임라인 알림은 대기 중인 도구 결과 뒤, 대화 안의 원래 위치를 그대로 유지하며 `developer` 역할로 전달됩니다. 덕분에 새 알림을 추가해도 맨 앞의 시스템 프롬프트를 다시 쓰지 않고, 대화 중간의 지시가 그 지시보다 앞선 턴으로 끌려가지도 않습니다. 상위 서비스가 `developer` 역할을 거부한다면 해당 공급자에 `foldDeveloperRoleToSystem`을 설정하세요. 그러면 같은 위치에서 `system`으로 보냅니다. `stabilizePromptCache` 설정과 관계없이 적용되며 Anthropic 네이티브 전달은 기존 동작을 유지합니다. 캐시 재사용에는 안정적인 세션 식별자와 사용 가능한 상위 서비스 캐시가 여전히 필요합니다. 이전 지시나 도구의 변경, 대화 압축도 캐시 적중에 영향을 줄 수 있으며, 알림 순서를 유지하는 것만으로 재사용을 보장하지는 않습니다. diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 55e1f4457d4..3731b1b3eb4 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -125,6 +125,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `noPenaltyModels?` | `string[]` | presence/frequency penalty를 허용하지 않는 모델입니다. | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 엔드포인트가 `response_format`을 거부하는 정확한 모델 ID입니다. 요청 모델이 항목과 정확히 일치할 때만 필드를 생략하며, 그 외 `openai-chat` 모델에서는 structured-output 변환을 유지합니다. | | `noJsonSchemaModels?` | `string[]` | `openai-chat` 엔드포인트가 `json_schema` 형식은 거부하지만 `json_object`는 받는 정확한 모델 ID입니다. 이런 요청은 필드를 지우는 대신 `json_object`로 낮춰 보내므로, JSON을 요청한 클라이언트가 산문 대신 JSON을 받습니다. 한 모델이 두 목록에 모두 있으면 `noStructuredOutputModels`가 우선합니다. `opencode go`, `opencode zen`, `opencode free` 프리셋이 DeepSeek 경로에 기본으로 싣습니다. | +| `foldDeveloperRoleToSystem?` | `boolean` | 상위 서비스가 `developer` 역할을 거부하는 `openai-chat` 공급자에서 `developer` 메시지를 `system`으로 보냅니다. 어느 쪽이든 메시지는 대화 안의 원래 위치를 유지하며 역할만 바뀝니다. 기본값은 `false`이며, 표준 Chat Completions 역할을 받은 그대로 전달합니다. | | `parallelToolCalls?` | `boolean` | 병렬 도구 호출을 켜거나 끕니다. OpenAI Chat은 기본으로 켜져 있고, 비-chat 어댑터는 명시적으로 `true`일 때만 이를 노출합니다. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 기본값이 꺼진 downstream SSE 복구입니다. 정확한 자리표시자 id, 누락된 종료 id, 그리고(`repairInvalidIds`) 정규 `msg_`/`rs_` 접두사가 없는 message/reasoning id를 복구합니다. function-call id는 다시 쓰지 않습니다. 내장 DeepSeek은 마지막 두 가지를 기본으로 켭니다. | | `responsesSnapshotRepair?` | `boolean` | 기본값이 꺼진 클라이언트용 복구입니다. SSE와 JSON의 Responses 수명 주기에서 누락된 status, output, 도구 메타데이터를 채우며 raw 검사와 영속화는 변경하지 않습니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 29f66c70ffa..445e99df483 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -201,6 +201,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `noPenaltyModels?` | `string[]` | Models that reject presence/frequency penalties. | | `noStructuredOutputModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects `response_format`. Only an exact requested-model match omits the field; structured-output translation stays enabled for every other `openai-chat` model. | | `noJsonSchemaModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects a `json_schema` `response_format` but still accepts `json_object`. Such a request is downgraded to `json_object` instead of being dropped, so a caller asking for JSON still gets JSON. `noStructuredOutputModels` wins when a model is on both lists. The `opencode go`, `opencode zen`, and `opencode free` presets ship this for their DeepSeek routes. | +| `foldDeveloperRoleToSystem?` | `boolean` | Send a `developer` message as `system` on an `openai-chat` provider whose upstream rejects the `developer` role. The message keeps its position in the conversation either way; only the role changes. Defaults to `false`, so the standard Chat Completions role is forwarded as sent. | | `omitReasoningEffortWithToolsModels?` | `string[]` | Exact `openai-chat` model IDs that accept a reasoning-effort field on an ordinary turn but reject it once function tools are present. The model keeps its advertised effort ladder; OpenCodex omits the wire field for tool-bearing requests only and the upstream default applies. Narrower than `noReasoningModels`, which strips reasoning from every request and costs the model its picker entirely. | | `parallelToolCalls?` | `boolean` | Toggle parallel tool calls. OpenAI Chat defaults on; non-chat adapters advertise only on explicit `true`. | | `terminalContinuationGuard?` | `boolean` | Opt in an `openai-chat` provider to one bounded internal re-ask when an actionable turn announces work, then cleanly stops without a tool call. Defaults to `false`; explicit `false` behaves like omission. Combo attempts and routed compaction turns are excluded, and non-`openai-chat` adapters ignore this option. | diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index 1ceea7da0e8..795a1a80d89 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -529,4 +529,4 @@ Responses `web_search_call` в парные блоки Anthropic `server_tool_us Параметр `claudeCode.stabilizePromptCache: true` в `config.json` переносит поддерживаемые уведомления Claude в конце системных инструкций в последнее пользовательское сообщение на маршрутах с преобразованием. По умолчанию он выключен (`false`). Включайте его только когда такое изменение роли допустимо для ваших клиентов. Примеры в блоках кода и нераспознанный текст сохраняются; нативная передача Anthropic не меняется. Без метаданных ключ кэша рассчитывается по стабилизированным инструкциям. Идентификатор разговора не создаётся, попадания в кэш не гарантируются. -На Chat-маршруте OpenCode Go для `deepseek-v4.1-flash` преобразованные системные напоминания в истории автоматически сохраняют свою позицию и роль system после ожидаемых результатов инструментов. Поэтому добавление новых напоминаний не переписывает начальный системный промпт. Это работает независимо от `stabilizePromptCache`; преобразование для других моделей и адресатов, а также нативная передача Anthropic остаются прежними. Для повторного использования кэша по-прежнему нужны стабильный идентификатор сессии и доступный кэш провайдера. Изменения прежних инструкций или инструментов и сжатие разговора также могут влиять на попадания в кэш; само сохранение порядка напоминаний не гарантирует повторного использования. +На всех преобразованных Chat-маршрутах напоминания в истории сохраняют свою позицию в разговоре — после ожидаемых результатов инструментов — и передаются с ролью `developer`. Поэтому добавление нового напоминания не переписывает начальный системный промпт, а инструкция из середины разговора не оказывается раньше тех ходов, после которых она была написана. Если вышестоящий сервис не принимает роль `developer`, задайте у этого провайдера `foldDeveloperRoleToSystem`: напоминание будет отправлено как `system` в той же позиции. Это работает независимо от `stabilizePromptCache`; нативная передача Anthropic остаётся прежней. Для повторного использования кэша по-прежнему нужны стабильный идентификатор сессии и доступный кэш провайдера. Изменения прежних инструкций или инструментов и сжатие разговора также могут влиять на попадания в кэш; само сохранение порядка напоминаний не гарантирует повторного использования. diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 21227ef1abd..1c73ea4571e 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -138,6 +138,7 @@ cross-route credential fallback не существует. Строки API GPT- | `noPenaltyModels?` | `string[]` | Модели, отвергающие penalty presence/frequency. | | `noStructuredOutputModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format`. Поле опускается только при точном совпадении запрошенной модели; для остальных моделей `openai-chat` преобразование structured output остаётся включённым. | | `noJsonSchemaModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format` типа `json_schema`, но принимает `json_object`. Такой запрос понижается до `json_object`, а не отбрасывается, поэтому вызывающая сторона всё равно получает JSON. Если модель есть в обоих списках, побеждает `noStructuredOutputModels`. Пресеты `opencode go`, `opencode zen` и `opencode free` включают это для своих маршрутов DeepSeek. | +| `foldDeveloperRoleToSystem?` | `boolean` | Отправлять сообщение `developer` как `system` для провайдера `openai-chat`, чей вышестоящий сервис не принимает роль `developer`. В обоих случаях сообщение сохраняет свою позицию в разговоре; меняется только роль. По умолчанию `false`, то есть стандартная роль Chat Completions передаётся как есть. | | `parallelToolCalls?` | `boolean` | Переключатель parallel tool call'ов. Для OpenAI Chat по умолчанию включено; не-chat adapter'ы рекламируют это только при явном `true`. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | По умолчанию выключенная downstream SSE-repair для exact placeholder-id, отсутствующих terminal-id и (с `repairInvalidIds`) message/reasoning id без канонического префикса `msg_`/`rs_`. Function-call id никогда не переписываются. Встроенный DeepSeek включает последние два по умолчанию. | | `responsesSnapshotRepair?` | `boolean` | По умолчанию выключенная клиентская repair для неполных lifecycle snapshot'ов Responses в SSE и JSON. Добавляет отсутствующие status, output и tool metadata, не меняя raw inspection и persistence. | diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index 4920210639a..24efef89db3 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -738,4 +738,4 @@ tutucusu olarak `"haiku"` iletin. `config.json` içindeki `claudeCode.stabilizePromptCache: true`, dönüştürülen rotalarda sistem talimatlarının sonundaki desteklenen Claude bildirimlerini son kullanıcı mesajına taşır. Varsayılan değer `false` olur. Yalnızca bu rol değişikliği istemcileriniz için uygunsa etkinleştirin. Kod bloklarındaki örnekler ve eşleşmeyen metin korunur; yerel Anthropic aktarımı değişmez. Meta veri yoksa önbellek anahtarı kararlı talimatlardan hesaplanır. Bu seçenek konuşma kimliği oluşturmaz veya üst hizmette önbellek isabeti garanti etmez. -OpenCode Go’nun `deepseek-v4.1-flash` Chat rotasında, dönüştürülen zaman çizelgesi sistem hatırlatmaları bekleyen araç sonuçlarından sonra konumlarını ve system rolünü otomatik olarak korur. Böylece yeni hatırlatmalar eklenmesi, baştaki sistem istemini yeniden yazmaz. Bu davranış `stabilizePromptCache` açık veya kapalıyken geçerlidir; diğer modellerin ve hedeflerin dönüşümü ile yerel Anthropic aktarımı değişmez. Önbelleğin yeniden kullanımı için kararlı bir oturum kimliği ve kullanılabilir üst hizmet önbelleği hâlâ gereklidir. Önceki talimatların veya araçların değişmesi ve konuşmanın sıkıştırılması da önbellek isabetini etkileyebilir; hatırlatma sırasını korumak tek başına yeniden kullanımı garanti etmez. +Dönüştürülen tüm Chat rotalarında zaman çizelgesi hatırlatmaları, bekleyen araç sonuçlarından sonra konuşmadaki konumlarını korur ve `developer` rolüyle iletilir. Böylece yeni bir hatırlatma eklenmesi baştaki sistem istemini yeniden yazmaz ve konuşmanın ortasındaki bir yönerge, izlemesi gereken turların önüne geçmez. Üst hizmet `developer` rolünü kabul etmiyorsa ilgili sağlayıcıda `foldDeveloperRoleToSystem` ayarını açın; hatırlatma aynı konumda `system` olarak gönderilir. Bu davranış `stabilizePromptCache` açık veya kapalıyken geçerlidir; yerel Anthropic aktarımı değişmez. Önbelleğin yeniden kullanımı için kararlı bir oturum kimliği ve kullanılabilir üst hizmet önbelleği hâlâ gereklidir. Önceki talimatların veya araçların değişmesi ve konuşmanın sıkıştırılması da önbellek isabetini etkileyebilir; hatırlatma sırasını korumak tek başına yeniden kullanımı garanti etmez. diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 57f5d867189..447f02b49d5 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -139,6 +139,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `noPenaltyModels?` | `string[]` | Varlık/frekans cezalarını reddeden modeller. | | `noStructuredOutputModels?` | `string[]` | `openai-chat` uç noktası `response_format`'ı reddeden tam model kimlikleri. Yalnızca tam bir istenen model eşleşmesi alanı atlar; yapılandırılmış çıktı çevirisi diğer her `openai-chat` modeli için etkin kalır. | | `noJsonSchemaModels?` | `string[]` | `openai-chat` uç noktası `json_schema` biçimini reddeden ama `json_object` kabul eden tam model kimlikleri. Böyle bir istek atılmak yerine `json_object` seviyesine düşürülür, böylece JSON isteyen çağıran yine JSON alır. Bir model her iki listede de varsa `noStructuredOutputModels` kazanır. `opencode go`, `opencode zen` ve `opencode free` hazır ayarları bunu DeepSeek rotaları için getirir. | +| `foldDeveloperRoleToSystem?` | `boolean` | Üst hizmeti `developer` rolünü kabul etmeyen bir `openai-chat` sağlayıcısında `developer` mesajını `system` olarak gönderir. Her iki durumda da mesaj konuşmadaki konumunu korur; yalnızca rol değişir. Varsayılan `false` olduğundan standart Chat Completions rolü geldiği gibi iletilir. | | `parallelToolCalls?` | `boolean` | Paralel araç çağrılarını açıp kapatın. OpenAI Chat varsayılan olarak açıktır; sohbet harici adaptörler yalnızca açık `true` durumunda bildirir. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Tam yer tutucu kimlikleri, eksik terminal kimlikleri ve (`repairInvalidIds` ile) kurallı `msg_`/`rs_` öneki eksik olan mesaj/akıl yürütme kimlikleri için varsayılan olarak devre dışı bırakılmış aşağı akış SSE onarımı. Fonksiyon çağrısı kimlikleri asla yeniden yazılmaz. Yerleşik DeepSeek son ikisini varsayılan olarak etkinleştirir. | | `responsesSnapshotRepair?` | `boolean` | SSE ve JSON'daki seyrek Responses yaşam döngüsü anlık görüntüleri için varsayılan olarak devre dışı bırakılmış istemciye yönelik onarım. Ham inceleme ve kalıcılık değişmeden kalırken eksik kurallı durumu, çıktıyı ve araç meta verilerini doldurur. | diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index db814d3aef7..6c65903fa46 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -467,4 +467,4 @@ Claude 模型时自动加载。对于原生透传,这是正常现象;对于 在 `config.json` 中设置 `claudeCode.stabilizePromptCache: true`,可在转换路由上将系统指令末尾受支持的 Claude 提示移到最后一条用户消息。默认值为 `false`。仅在客户端允许这种角色变化时启用。代码围栏内的示例和不匹配的文本会保留,Anthropic 原生透传不变。没有元数据时,缓存键按稳定后的指令计算。该选项不会生成会话标识,也不保证上游缓存命中。 -在 OpenCode Go 的 `deepseek-v4.1-flash` Chat 路由上,转换后的时间线系统提醒会自动保留原有位置和 system 角色,并排在尚待返回的工具结果之后。因此,追加提醒不会重写开头的系统提示。无论 `stabilizePromptCache` 是否启用,该行为都会生效;其他模型、目标地址的转换方式以及 Anthropic 原生透传保持不变。缓存复用仍需要稳定的会话标识和可用的上游缓存。修改较早的指令或工具、压缩对话也可能影响缓存命中;仅保留提醒顺序并不保证缓存复用。 +在所有转换后的 Chat 路由上,时间线提醒都会保留在对话中的原有位置(排在尚待返回的工具结果之后),并以 `developer` 角色转发。因此,追加提醒不会重写开头的系统提示,对话中途的指令也不会被挪到它本应跟随的轮次之前。如果上游拒绝 `developer` 角色,请在该提供方上设置 `foldDeveloperRoleToSystem`,提醒会在同一位置以 `system` 发送。无论 `stabilizePromptCache` 是否启用,该行为都会生效;Anthropic 原生透传保持不变。缓存复用仍需要稳定的会话标识和可用的上游缓存。修改较早的指令或工具、压缩对话也可能影响缓存命中;仅保留提醒顺序并不保证缓存复用。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index e0e61490a1d..8fcfa10e6e8 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -125,6 +125,7 @@ selector,而不是分配一个新名称。 | `noPenaltyModels?` | `string[]` | 会拒绝 presence/frequency penalty 的模型。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 端点拒绝 `response_format` 的精确模型 ID。仅当请求模型与条目完全匹配时才省略该字段;其他 `openai-chat` 模型仍启用 structured-output 转换。 | | `noJsonSchemaModels?` | `string[]` | `openai-chat` 端点拒绝 `json_schema` 形式但仍接受 `json_object` 的精确模型 ID。这类请求会降级为 `json_object` 而不是被丢弃,因此请求 JSON 的调用方仍能拿到 JSON。同一模型同时出现在两个列表时,以 `noStructuredOutputModels` 为准。`opencode go`、`opencode zen`、`opencode free` 预设已为其 DeepSeek 路由内置该项。 | +| `foldDeveloperRoleToSystem?` | `boolean` | 对上游拒绝 `developer` 角色的 `openai-chat` 提供方,将 `developer` 消息作为 `system` 发送。无论哪种方式,消息都会保留在对话中的原有位置,只有角色改变。默认为 `false`,即按原样转发标准的 Chat Completions 角色。 | | `parallelToolCalls?` | `boolean` | 切换并行工具调用。OpenAI Chat 默认开启;非 chat 适配器只有显式 `true` 时才会声明支持。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 默认关闭的下游 SSE 修复,用于精确占位 id、缺失的终止 id,以及(`repairInvalidIds`)缺少规范 `msg_`/`rs_` 前缀的 message/reasoning id。function-call id 永远不会被重写。内置 DeepSeek 默认启用后两项。 | | `responsesSnapshotRepair?` | `boolean` | 默认关闭的客户端修复,用于补全 SSE 与 JSON 中稀疏 Responses 生命周期快照缺失的 status、output 和工具元数据;原始检查与持久化保持不变。 | diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index 493aeea9a5b..51231f1a38d 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -538,4 +538,4 @@ Claude 模型時自動載入。對於原生透傳,這是正常現象;對於 在 `config.json` 中設定 `claudeCode.stabilizePromptCache: true`,可在轉換路由上將系統指令末尾支援的 Claude 提示移到最後一則使用者訊息。預設值為 `false`。僅在用戶端允許這種角色變更時啟用。程式碼圍欄中的範例和不符合的文字會保留,Anthropic 原生轉送不變。沒有中繼資料時,快取鍵依穩定後的指令計算。此選項不會產生對話識別碼,也不保證上游快取命中。 -在 OpenCode Go 的 `deepseek-v4.1-flash` Chat 路由上,轉換後的時間線系統提醒會自動保留原有位置和 system 角色,並排在尚待傳回的工具結果之後。因此,新增提醒不會重寫開頭的系統提示。無論 `stabilizePromptCache` 是否啟用,此行為都會生效;其他模型、目標位址的轉換方式以及 Anthropic 原生轉送維持不變。快取重用仍需要穩定的工作階段識別碼和可用的上游快取。修改較早的指令或工具、壓縮對話也可能影響快取命中;僅保留提醒順序並不保證快取重用。 +在所有轉換後的 Chat 路由上,時間線提醒都會保留在對話中的原有位置(排在尚待傳回的工具結果之後),並以 `developer` 角色轉送。因此,新增提醒不會重寫開頭的系統提示,對話中途的指令也不會被移到它原本應跟隨的輪次之前。若上游拒絕 `developer` 角色,請在該提供者上設定 `foldDeveloperRoleToSystem`,提醒會在相同位置以 `system` 傳送。無論 `stabilizePromptCache` 是否啟用,此行為都會生效;Anthropic 原生轉送維持不變。快取重用仍需要穩定的工作階段識別碼和可用的上游快取。修改較早的指令或工具、壓縮對話也可能影響快取命中;僅保留提醒順序並不保證快取重用。 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 395ec06ec77..15e4ce53a37 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -99,6 +99,7 @@ ocx models provider openrouter on | `noPenaltyModels?` | `string[]` | 拒絕 presence/frequency penalty 的模型。 | | `noStructuredOutputModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `response_format` 的精確模型 ID。僅精確符合的請求模型會省略該欄位;structured-output 轉譯對其他每個 `openai-chat` 模型保持啟用。 | | `noJsonSchemaModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `json_schema` 形式但仍接受 `json_object` 的精確模型 ID。這類請求會降級為 `json_object` 而非被丟棄,因此要求 JSON 的呼叫端仍會拿到 JSON。同一模型同時列在兩份清單時,以 `noStructuredOutputModels` 為準。`opencode go`、`opencode zen`、`opencode free` 預設已為其 DeepSeek 路由內建。 | +| `foldDeveloperRoleToSystem?` | `boolean` | 對上游拒絕 `developer` 角色的 `openai-chat` 提供者,將 `developer` 訊息以 `system` 傳送。無論何者,訊息都會保留在對話中的原有位置,只有角色改變。預設為 `false`,亦即照原樣轉送標準的 Chat Completions 角色。 | | `parallelToolCalls?` | `boolean` | 切換平行工具呼叫。OpenAI Chat 預設開啟;非 chat adapter 僅在明確 `true` 時廣告。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | 預設停用的下游 SSE 修復,用於精確佔位 id 與缺失的終端 id。Function-call id 永不被重寫。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 僅限使用金鑰認證的 `openai-chat` 與 `openai-responses` 供應商。`authMode: "forward"` 的供應商(ChatGPT 帳號池)從不讀取此選項,維持預設重試次數。選擇性重試串流開始前的暫時性上游狀態(500、502、503、504、520、521、522):未設定時停用;只要有此物件即啟用,除非 `enabled: false`。涵蓋初始 `Responses` 請求、終止防護續接、原生 `/v1/chat/completions`,以及 429/帳號復原的重新擷取。`attempts` 是單一請求允許傳送至上游的總次數,包含第一次(1..10,預設 3);這是與連線重設復原共用的單一請求範圍預算,因此 `3` 表示最多只有三個實際請求會送達供應商。等待採固定 400 毫秒、上限 5 秒的指數退避,並遵循 `Retry-After`。此機制獨立於處理速率限制的 `retryOn429`;串流中的失敗絕不重播。 | diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index 0d35f5e8fb2..b44e4e28ec8 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -113,6 +113,13 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv }; const nativeOpenAI = isNativeOpenAIChatTarget(provider); + // `developer` is part of the Chat Completions role set, so it is forwarded as itself. The + // host test above used to decide the role too, which assumed every OpenAI-compatible gateway + // rejects a standard role until proven otherwise — including gateways that proxy OpenAI — + // and quietly gave the instruction `system` precedence instead (#5213). A destination that + // really does reject it records that with `foldDeveloperRoleToSystem`, which converts the + // role where the message already is and never moves it. + const developerWireRole = provider.foldDeveloperRoleToSystem === true ? "system" : "developer"; // A developer message keeps the slot it arrived in. Hoisting its text into the leading // system block moved a mid-conversation instruction ahead of every turn it was written to // follow, and the caller saw an ordinary answer either way (#5213). The Claude inbound mints @@ -155,7 +162,7 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv // message instead is rejected by some upstreams. Native OpenAI keeps its existing // empty-developer wire, which is a separate question from placement. if (!nativeOpenAI && text.length === 0) break; - chatMsg = { role: nativeOpenAI ? "developer" : "system", content: text }; + chatMsg = { role: developerWireRole, content: text }; } else if (typeof msg.content === "string") { chatMsg = { role: "user", content: msg.content }; } else if (!hasImages) { diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 93edd25d0fc..abf7e5b0bb7 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -258,6 +258,7 @@ export const providerConfigSchema = z.object({ requiresAdjacentResponsesToolResults: z.boolean().optional(), requiresPairedResponsesToolResults: z.boolean().optional(), annotateEmptyToolOutputs: z.boolean().optional(), + foldDeveloperRoleToSystem: z.boolean().optional(), fastWire: fastWireSchema.nullable().optional(), supportsServiceTier: z.boolean().optional(), modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index c8f53c861ae..e5228d8272c 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1024,6 +1024,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { pinParallelToolCallsFalse: "editor", terminalContinuationGuard: "editor", openaiChatEofTolerance: "editor", + foldDeveloperRoleToSystem: "editor", promptCacheKey: "editor", chatServiceTier: "editor", responsesItemIdRepair: "editor", diff --git a/src/types/provider.ts b/src/types/provider.ts index e67041ee5dd..bbc3d6b5c35 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -816,6 +816,19 @@ export interface OcxProviderConfig { * incomplete JSON, missing arguments, and empty streams remain truncation errors. */ openaiChatEofTolerance?: boolean; + /** + * Opt-in: fold a `developer` message into a `system` message instead of forwarding the role. + * + * `developer` is part of the Chat Completions message role set, so forwarding it is the + * default. The role used to be decided by testing the base URL host against + * `api.openai.com`, which assumed every OpenAI-compatible gateway rejects a standard role + * until proven otherwise — including gateways that proxy OpenAI itself — and quietly gave the + * instruction `system` precedence instead (#5213). This flag exists for a destination that + * genuinely rejects the role, so the conversion is a recorded decision about that destination + * rather than an inference from its hostname. Position is unaffected either way: the message + * keeps its slot in the conversation. + */ + foldDeveloperRoleToSystem?: boolean; /** * Opt-in: forward `prompt_cache_key` to the upstream `/chat/completions` body. * OpenAI-specific extension; strict backends (Groq, Cerebras, etc.) reject unknown diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index a12c3cd4fff..cf601215ed8 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -24,6 +24,13 @@ stabilization option and does not guarantee upstream cache hits. Regression cove `tests/adapters/openai/openai-chat-system-order.test.ts` and `tests/adapters/openai/openai-chat-developer-position.test.ts`. +The role that slot carries is a separate decision. `developer` is part of the Chat Completions +message role set and is forwarded as itself on every destination. A destination that genuinely +rejects the role sets `foldDeveloperRoleToSystem`, which converts it in place and still never +moves the message. The role was previously decided by testing the base URL host against +`api.openai.com`, so every OpenAI-compatible gateway was assumed not to support a standard role +until proven otherwise, and the instruction silently lost `developer` precedence. + Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. ## Reasoning and tool-result compatibility diff --git a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts index 855b7391527..41a61c21023 100644 --- a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts +++ b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts @@ -106,7 +106,7 @@ describe("openai-chat dangling tool_calls hardening", () => { expect(roles[aIdx + 1]).toBe("tool"); expect(messages[aIdx + 1].tool_call_id).toBe("call_x"); expect(messages[aIdx + 1].content).toBe('{"answers":{}}'); - expect(messages[aIdx + 2]).toEqual({ role: "system", content: "[injected guidance]" }); + expect(messages[aIdx + 2]).toEqual({ role: "developer", content: "[injected guidance]" }); expect(roles[aIdx + 3]).toBe("user"); // no synthetic result fabricated for an answered call expect(messages.some(m => typeof m.content === "string" && m.content.includes("no tool result was recorded"))).toBe(false); @@ -167,7 +167,7 @@ describe("openai-chat dangling tool_calls hardening", () => { const real = block.find(m => m.tool_call_id === "call_2"); expect(real?.content).toBe("img-ok"); expect(messages[0]).toEqual({ role: "user", content: "hi" }); - expect(messages[aIdx + 3]).toEqual({ role: "system", content: "barrier while call_1 pending" }); + expect(messages[aIdx + 3]).toEqual({ role: "developer", content: "barrier while call_1 pending" }); expect(messages[aIdx + 4].role).toBe("assistant"); }); @@ -184,7 +184,7 @@ describe("openai-chat dangling tool_calls hardening", () => { expect(messages[aIdx + 1].tool_call_id).toBe("call_p"); expect(String(messages[aIdx + 1].content)).toContain("no tool result was recorded"); expect(messages[0]).toEqual({ role: "user", content: "hi" }); - expect(messages[aIdx + 2]).toEqual({ role: "system", content: "deferred barrier" }); + expect(messages[aIdx + 2]).toEqual({ role: "developer", content: "deferred barrier" }); expect(messages[aIdx + 3].role).toBe("assistant"); const orphanIdx = messages.findIndex((m, i) => i > aIdx && m.role === "assistant"); expect(messages[orphanIdx].tool_calls?.[0].id).toBe("call_unknown"); diff --git a/tests/adapters/openai/openai-chat-developer-position.test.ts b/tests/adapters/openai/openai-chat-developer-position.test.ts index e5e2a622d19..311d109b804 100644 --- a/tests/adapters/openai/openai-chat-developer-position.test.ts +++ b/tests/adapters/openai/openai-chat-developer-position.test.ts @@ -37,10 +37,10 @@ function wireMessages(provider: OcxProviderConfig): Array { test("a non-OpenAI gateway keeps the instruction between the two turns", () => { const messages = wireMessages(gateway); - expect(messages.map(message => message.role)).toEqual(["system", "user", "system", "user"]); + expect(messages.map(message => message.role)).toEqual(["system", "user", "developer", "user"]); expect(messages[0]).toEqual({ role: "system", content: "base instructions" }); expect(messages[1]).toEqual({ role: "user", content: "First turn." }); - expect(messages[2]).toEqual({ role: "system", content: "Answer in exactly one sentence." }); + expect(messages[2]).toEqual({ role: "developer", content: "Answer in exactly one sentence." }); expect(messages[3]).toEqual({ role: "user", content: "Second turn." }); }); @@ -56,9 +56,31 @@ describe("developer message placement on the Chat wire", () => { ]; for (const baseUrl of hosts) { const messages = wireMessages({ ...gateway, baseUrl }); - expect(messages.map(message => message.role).indexOf("user")).toBe(1); + expect(messages.map(message => message.role)).toEqual(["system", "user", "developer", "user"]); expect(messages[2].content).toBe("Answer in exactly one sentence."); expect(messages[3]).toEqual({ role: "user", content: "Second turn." }); } }); }); + +describe("developer role on the Chat wire", () => { + test("the role is forwarded as itself rather than inferred from the hostname", () => { + for (const baseUrl of ["https://openrouter.ai/api/v1", "http://localhost:1234/v1", "https://api.openai.com/v1"]) { + expect(wireMessages({ ...gateway, baseUrl })[2]).toEqual({ + role: "developer", + content: "Answer in exactly one sentence.", + }); + } + }); + + test("a destination that rejects the role converts it without moving the message", () => { + const messages = wireMessages({ ...gateway, foldDeveloperRoleToSystem: true }); + expect(messages.map(message => message.role)).toEqual(["system", "user", "system", "user"]); + expect(messages[2]).toEqual({ role: "system", content: "Answer in exactly one sentence." }); + expect(String(messages[0].content)).not.toContain("Answer in exactly one sentence."); + }); + + test("the opt-out is off unless the operator sets it", () => { + expect(wireMessages({ ...gateway, foldDeveloperRoleToSystem: false })[2].role).toBe("developer"); + }); +}); diff --git a/tests/adapters/openai/openai-chat-system-order.test.ts b/tests/adapters/openai/openai-chat-system-order.test.ts index 4ad3d8a9fb8..29511492412 100644 --- a/tests/adapters/openai/openai-chat-system-order.test.ts +++ b/tests/adapters/openai/openai-chat-system-order.test.ts @@ -47,9 +47,9 @@ describe("openai-chat system message ordering", () => { content: "base instructions", }); expect(messages.map(message => message.role)) - .toEqual(["system", "user", "system", "assistant", "system", "user"]); - expect(messages[2]).toEqual({ role: "system", content: "first reminder" }); - expect(messages[4]).toEqual({ role: "system", content: "second reminder" }); + .toEqual(["system", "user", "developer", "assistant", "developer", "user"]); + expect(messages[2]).toEqual({ role: "developer", content: "first reminder" }); + expect(messages[4]).toEqual({ role: "developer", content: "second reminder" }); }); test("defers a reminder past a pending tool result instead of hoisting it", () => { @@ -76,9 +76,9 @@ describe("openai-chat system message ordering", () => { // The reminder arrived while call_1 was open. Emitting it there would break tool-call // adjacency, so it is released immediately after the result rather than moved to the front. - expect(messages.map(message => message.role)).toEqual(["user", "assistant", "tool", "system"]); + expect(messages.map(message => message.role)).toEqual(["user", "assistant", "tool", "developer"]); expect(messages[2]).toMatchObject({ role: "tool", tool_call_id: "call_1" }); - expect(messages[3]).toEqual({ role: "system", content: "remember the policy" }); + expect(messages[3]).toEqual({ role: "developer", content: "remember the policy" }); }); test("keeps developer vision content as a user-compatible message in place", () => { @@ -145,10 +145,10 @@ describe("chronological in-conversation system messages", () => { { role: "system", content: "Synthetic reminder B." }, ], ocg, model, stabilize); expect(JSON.stringify(next.messages.slice(0, first.messages.length))).toBe(JSON.stringify(first.messages)); - expect(first.messages.map((message: { role: string }) => message.role)).toEqual(["system", "user", "assistant", "system"]); + expect(first.messages.map((message: { role: string }) => message.role)).toEqual(["system", "user", "assistant", "developer"]); expect(first.messages[0].content).not.toContain("Synthetic reminder A."); - expect(first.messages.at(-1)).toEqual({ role: "system", content: "Synthetic reminder A." }); - expect(next.messages.at(-1)).toEqual({ role: "system", content: "Synthetic reminder B." }); + expect(first.messages.at(-1)).toEqual({ role: "developer", content: "Synthetic reminder A." }); + expect(next.messages.at(-1)).toEqual({ role: "developer", content: "Synthetic reminder B." }); expect(next.tools).toEqual(first.tools); expect(next.model).toBe(model); expect(next.stream).toBe(true); @@ -165,14 +165,14 @@ describe("chronological in-conversation system messages", () => { expect(callIndex).toBeGreaterThan(0); expect(body.messages[callIndex].reasoning_content).toBe(" "); expect(body.messages[callIndex + 1]).toMatchObject({ role: "tool", tool_call_id: "call_fixture", content: "Fixture result." }); - expect(body.messages[callIndex + 2]).toEqual({ role: "system", content: "Reminder during pending tool." }); + expect(body.messages[callIndex + 2]).toEqual({ role: "developer", content: "Reminder during pending tool." }); }); test.each([ "https://opencode.ai/zen/go/v1/", "https://opencode.ai:443/zen/go/v1", ])("keeps the reminder last on the canonical OpenCode Go destination %s", baseUrl => { - expect(build(history, { ...ocg, baseUrl }).messages.at(-1).role).toBe("system"); + expect(build(history, { ...ocg, baseUrl }).messages.at(-1).role).toBe("developer"); }); test.each([ @@ -185,22 +185,30 @@ describe("chronological in-conversation system messages", () => { const messages = build(history, { ...ocg, baseUrl }).messages; expect(messages[0].content).not.toContain("Synthetic reminder A."); expect(messages.map((message: { role: string }) => message.role)) - .toEqual(["system", "user", "assistant", "system"]); - expect(messages.at(-1)).toEqual({ role: "system", content: "Synthetic reminder A." }); + .toEqual(["system", "user", "assistant", "developer"]); + expect(messages.at(-1)).toEqual({ role: "developer", content: "Synthetic reminder A." }); }); test("placement no longer depends on the model either", () => { const messages = build(history, ocg, "kimi-k3").messages; expect(messages[0].content).not.toContain("Synthetic reminder A."); - expect(messages.at(-1)).toEqual({ role: "system", content: "Synthetic reminder A." }); + expect(messages.at(-1)).toEqual({ role: "developer", content: "Synthetic reminder A." }); }); - test("retains native OpenAI developer roles", () => { + test("the native OpenAI wire is unchanged", () => { const messages = build(history, { ...ocg, baseUrl: "https://api.openai.com/v1" }).messages; expect(messages[0].content).not.toContain("Synthetic reminder A."); expect(messages.at(-1)).toEqual({ role: "developer", content: "Synthetic reminder A." }); }); + test("a destination that rejects the role folds it in place", () => { + const messages = build(history, { ...ocg, foldDeveloperRoleToSystem: true }).messages; + expect(messages.map((message: { role: string }) => message.role)) + .toEqual(["system", "user", "assistant", "system"]); + expect(messages[0].content).not.toContain("Synthetic reminder A."); + expect(messages.at(-1)).toEqual({ role: "system", content: "Synthetic reminder A." }); + }); + test("drops a non-text timeline message instead of emitting an empty system message", () => { const context = { messages: [ From 65d6e771f6732e1edb3c0005d52ae9989b5f13cd Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 14:35:48 +0900 Subject: [PATCH 05/11] fix(inbound): carry inline document bytes through to the wires that hold them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both inbound parsers reduced an attached document to its name before any adapter ran, so no adapter could forward one even to a target that has a representation for it. The Messages inbound replaced a base64 document block with a "[document: title]" marker, and the Chat file part matched no branch of the content loop at all. The request succeeded either way, so the caller could not tell "the model read the document" from "the model was told a document existed". OcxContentPart gains a document member carrying the media type and the base64 payload. The Anthropic wire emits it as the document block the caller sent, the OpenAI Chat wire as the file part that is its direct counterpart, and Gemini as the inline_data part it already uses for images and video. Widening the union is the hazard here, so the part also carries the marker every text-only consumer already falls back to. That keeps a wire with no document representation emitting exactly what it emitted before instead of undefined or a mislabelled [video]. Six consumers needed more than the fallback and are fixed explicitly: ollama-native and the Cursor tool-result decoder would have read a nonexistent imageUrl, and Kiro, Devin, Cursor and coding-agent text serializers would have produced an empty turn. Token admission counts the encoded payload rather than the marker. The untranslated-media refusal is narrowed to match, and only where a converter actually builds the part: user content on the Chat projection, user and developer messages on the Responses one. A file in a tool output, a system message or an assistant message is still refused, because those converters flatten their content to a string and exempting them would restore the silent drop the scanner exists to prevent. The scanner and the decoder share one predicate, so a request cannot be exempted in one and reduced to a marker in the other; a ";notbase64," parameter is not a payload. A reference with no bytes — a file_id, a remote source — is unchanged in every position. Tool-result documents keep the #939 marker: the Responses tool-output vocabulary has no file block and every adapter's tool-result path flattens to text, so carrying bytes there needs a separate change. Closes #5212 --- scripts/test-layout/layout.json | 1 + src/adapters/anthropic.ts | 10 ++ src/adapters/coding-agent/protocol.ts | 11 +- src/adapters/command-code.ts | 3 +- src/adapters/cursor/protobuf-request.ts | 9 +- src/adapters/cursor/request-builder.ts | 3 + src/adapters/devin.ts | 3 + src/adapters/google-antigravity-wire.ts | 7 +- src/adapters/google.ts | 7 + src/adapters/image.ts | 5 +- src/adapters/kiro-tool-fallback.ts | 2 +- src/adapters/kiro/usage.ts | 5 +- src/adapters/ollama-native.ts | 6 + src/adapters/openai-chat/messages.ts | 19 ++- src/chat/inbound.ts | 23 ++- src/claude/inbound.ts | 38 ++++- src/responses/inline-document.ts | 58 +++++++ src/responses/input-media.ts | 50 +++++- src/responses/parser-content.ts | 10 +- src/responses/parser.ts | 4 +- src/server/responses/input-admission.ts | 3 + src/types.ts | 1 + src/types/request.ts | 30 +++- structure/adapters/registry.md | 14 +- structure/data-planes/inbound-compat.md | 6 +- .../adapter-input-media-guard.test.ts | 27 +++- tests/fixtures/test-layout-expected.json | 1 + .../chat-inline-document-bytes.test.ts | 144 ++++++++++++++++++ .../responses/chat-media-translation.test.ts | 27 +++- 29 files changed, 483 insertions(+), 44 deletions(-) create mode 100644 src/responses/inline-document.ts create mode 100644 tests/responses/chat-inline-document-bytes.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 5c92c73884a..74ff3fe3868 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1571,6 +1571,7 @@ "anthropic-tool-declaration-constraints.test.ts": "adapters/anthropic", "google-strict-tool-validated-mode.test.ts": "adapters/google", "openai-chat-developer-position.test.ts": "adapters/openai", + "chat-inline-document-bytes.test.ts": "responses", "devin-output-budget.test.ts": "providers", "api-key-model-scope.test.ts": "server" }, diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index a0156066fa2..9e2e1ffb425 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -39,6 +39,16 @@ function toAnthropicContentPart(p: OcxContentPart): unknown { : { type: "image", source: { type: "url", url: p.imageUrl } }; } if (p.type === "video") return { type: "text", text: "[video]" }; + // The block the caller sent, rebuilt. A Messages-to-Messages route used to reduce it to its + // title before any adapter ran, so the model was told a document existed rather than given + // one, and the answer came back looking the same (#5212). + if (p.type === "document") { + return { + type: "document", + source: { type: "base64", media_type: p.mediaType, data: p.data }, + ...(p.filename !== undefined ? { title: p.filename } : {}), + }; + } return { type: "text", text: p.text }; } diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts index 628341bb8d1..4d38ac071e1 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -332,7 +332,7 @@ function formatMessageForHistory(message: OcxMessage): string { if (message.role === "user") { const text = typeof message.content === "string" ? message.content - : message.content.map(p => (p.type === "text" ? p.text : `[${p.type}]`)).join("\n"); + : message.content.map(p => (p.type === "text" || p.type === "document" ? p.text : `[${p.type}]`)).join("\n"); return `USER:\n${text}`; } if (message.role === "assistant") { @@ -352,7 +352,7 @@ function formatMessageForHistory(message: OcxMessage): string { if (message.role === "toolResult") { const text = typeof message.content === "string" ? message.content - : message.content.map(p => (p.type === "text" ? p.text : "[image]")).join(""); + : message.content.map(p => (p.type === "text" || p.type === "document" ? p.text : "[image]")).join(""); const status = message.isError ? " (error)" : ""; return `TOOL RESULT (call_id: ${message.toolCallId})${status}:\n${text}`; } @@ -379,6 +379,8 @@ export function buildInputLines(message: OcxMessage): string[] { else if (part.type === "image") { const image = imagePart(part.imageUrl); if (image) content.push(image); + } else if (part.type === "document") { + content.push(textPart(part.text)); } else { content.push(textPart("[video]")); } @@ -402,7 +404,7 @@ export function buildSystemPrompt(parsed: OcxParsedRequest): string | undefined if (message.role !== "developer") continue; const text = typeof message.content === "string" ? message.content - : message.content.map(part => (part.type === "text" ? part.text : "")).join(""); + : message.content.map(part => (part.type === "text" || part.type === "document" ? part.text : "")).join(""); if (text.trim()) parts.push(text); } return parts.length > 0 ? parts.join("\n\n") : undefined; @@ -464,6 +466,8 @@ export function buildConversationInput(parsed: OcxParsedRequest, options: { maxH const image = imagePart(part.imageUrl); if (image) currentImageBlocks.push(image); else textParts.push("[image omitted: unsupported reference]"); + } else if (part.type === "document") { + textParts.push(part.text); } else { textParts.push("[video]"); } @@ -487,6 +491,7 @@ export function buildConversationInput(parsed: OcxParsedRequest, options: { maxH else segments.push("[image omitted: unsupported reference]"); continue; } + if (part.type === "document") { segments.push(part.text); continue; } segments.push("[video]"); } text = segments.join(""); diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index 35d54ebd463..c05263110f9 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -32,7 +32,7 @@ function canonicalCommandCodeModelId(modelId: string): string { /** Flatten tool-result content for the text-only wire output, keeping an `[image]` marker per image part in content order. */ function toolResultText(content: string | OcxContentPart[]): string { if (typeof content === "string") return content; - return content.map(part => (part.type === "text" ? part.text : "[image]")).join(""); + return content.map(part => (part.type === "text" || part.type === "document" ? part.text : "[image]")).join(""); } /** Best-effort media type from a remote https URL extension, e.g. image/png. */ @@ -149,6 +149,7 @@ function wireMessages(messages: OcxMessage[]): Array> { else for (const part of message.content) { if (part.type === "text") content.push({ type: "text", text: part.text }); else if (part.type === "image") content.push(wireImagePart(part.imageUrl)); + else if (part.type === "document") content.push({ type: "text", text: part.text }); else content.push({ type: "text", text: "[video]" }); } out.push({ role: "user", content }); diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 894cf3eae45..83e076dcced 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -741,7 +741,7 @@ function contentText(message: OcxMessage): string { if (typeof message.content === "string") return message.content; return message.content .map(part => { - if (part.type === "text") return part.text; + if (part.type === "text" || part.type === "document") return part.text; if (part.type === "thinking") return part.thinking; if (part.type === "image") return undefined; return undefined; @@ -754,7 +754,7 @@ function contentToText(content: OcxToolResultMessage["content"]): string { if (typeof content === "string") return content; return content .map(part => { - if (part.type === "text") return part.text; + if (part.type === "text" || part.type === "document") return part.text; if (part.type === "image") return CURSOR_VISION_IMAGE_HISTORY_MARKER; return undefined; }) @@ -767,7 +767,7 @@ function historyContentText(message: OcxMessage): string { if (message.role === "toolResult" || typeof message.content === "string") return contentText(message); return message.content .map(part => { - if (part.type === "text") return part.text; + if (part.type === "text" || part.type === "document") return part.text; if (part.type === "thinking") return part.thinking; if (part.type === "image") return CURSOR_VISION_IMAGE_HISTORY_MARKER; return undefined; @@ -836,6 +836,9 @@ function decodeResultParts(message: OcxToolResultMessage): DecodedResultPart[] | return content.map((part): DecodedResultPart => { if (part.type === "text") return { kind: "text", text: part.text }; if (part.type === "video") return { kind: "text", text: "[video]" }; + // Without this the document falls through to decodeInlineImage(part.imageUrl) below and is + // treated as an image it is not. + if (part.type === "document") return { kind: "text", text: part.text }; const decoded = decodeInlineImage(part.imageUrl); return decoded ? { kind: "image", ...decoded } : { kind: "undecodable" }; }); diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 065f971afa3..3c0e5faa802 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -251,6 +251,9 @@ function contentPartToText(part: OcxContentPart | OcxAssistantContentPart): stri switch (part.type) { case "text": return part.text; + case "document": + // Cursor has no document carrier; the marker keeps the turn from serializing to nothing. + return part.text; case "thinking": return part.thinking; case "image": diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index c1c67bdd409..fe81f356ba4 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -331,6 +331,9 @@ function mapOcxContentToWire(content: string | OcxContentPart[] | undefined): st for (const part of content) { if (part.type === "text" && part.text) { out.push({ type: "text", text: part.text }); + } else if (part.type === "document") { + // No Devin document field; the marker keeps the turn from disappearing entirely. + out.push({ type: "text", text: part.text }); } else if (part.type === "image") { const m = part.imageUrl.match(/^data:([^;]+);base64,(.+)$/); if (m) out.push({ type: "image", mimeType: m[1]!, base64Data: m[2]! }); diff --git a/src/adapters/google-antigravity-wire.ts b/src/adapters/google-antigravity-wire.ts index a40703f8377..223d95293e4 100644 --- a/src/adapters/google-antigravity-wire.ts +++ b/src/adapters/google-antigravity-wire.ts @@ -45,8 +45,11 @@ function firstUserText(parsed: OcxParsedRequest): string | undefined { for (const msg of parsed.context.messages) { if (msg.role !== "user") continue; if (typeof msg.content === "string") return msg.content; - const first = (msg.content as OcxContentPart[]).find(p => p.type === "text" && typeof p.text === "string"); - if (first && first.type === "text") return first.text; + // A document part carries text too: ignoring it left a document-only opening turn with no + // anchor, which silently downgrades the deterministic session id to a random one. + const first = (msg.content as OcxContentPart[]) + .find(p => (p.type === "text" || p.type === "document") && typeof p.text === "string"); + if (first && (first.type === "text" || first.type === "document")) return first.text; } return undefined; } diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 19a48e3674b..e9025004df8 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -348,6 +348,13 @@ function messagesToGeminiFormat( parts.push(data ? { inline_data: { mime_type: data.mediaType, data: data.base64 } } : { text: `[video: ${p.videoUrl}]` }); continue; } + if (p.type === "document") { + // Gemini takes document bytes through the same inline_data part as images and + // video. The marker on the part is the fallback for wires without one, not this + // wire's best effort (#5212). + parts.push({ inline_data: { mime_type: p.mediaType, data: p.data } }); + continue; + } // Drop empty/malformed text instead of emitting `{ text: "" }` or a bare `{}` part. const textPart = geminiTextPart(p.text); if (textPart) parts.push(textPart); diff --git a/src/adapters/image.ts b/src/adapters/image.ts index 39c5d683e50..1a6569fb0f4 100644 --- a/src/adapters/image.ts +++ b/src/adapters/image.ts @@ -18,6 +18,9 @@ export function parseDataUrl(url: string): { mediaType: string; base64: string } */ export function contentPartsToText(content: string | OcxContentPart[]): string { if (typeof content === "string") return content; - const text = content.map(p => p.type === "text" ? p.text : p.type === "image" ? "[image]" : "[video]").join(""); + // A document carries its own marker, so this wire states the attachment instead of + // mislabelling it as a video. + const text = content.map(p => + p.type === "text" || p.type === "document" ? p.text : p.type === "image" ? "[image]" : "[video]").join(""); return text || "[image]"; } diff --git a/src/adapters/kiro-tool-fallback.ts b/src/adapters/kiro-tool-fallback.ts index 1164277368a..24c917f2853 100644 --- a/src/adapters/kiro-tool-fallback.ts +++ b/src/adapters/kiro-tool-fallback.ts @@ -9,7 +9,7 @@ function contentText(content: string | OcxContentPart[]): string { if (typeof content === "string") return content; return content .map(part => { - if (part.type === "text") return part.text; + if (part.type === "text" || part.type === "document") return part.text; if (part.type === "image") return `[image:${part.detail ?? "auto"}]`; return ""; }) diff --git a/src/adapters/kiro/usage.ts b/src/adapters/kiro/usage.ts index 5f417b57295..3bb8f2b0ffe 100644 --- a/src/adapters/kiro/usage.ts +++ b/src/adapters/kiro/usage.ts @@ -12,14 +12,15 @@ import type { KiroHistoryEntry } from "./wire"; export function userContentText(content: string | OcxContentPart[]): string { if (typeof content === "string") return content; - return content.map(p => (p.type === "text" ? p.text : "")).filter(Boolean).join("\n"); + // A document carries its own marker: dropping it built an empty user turn that Kiro rejects. + return content.map(p => (p.type === "text" || p.type === "document" ? p.text : "")).filter(Boolean).join("\n"); } export function usageContentText(content: string | OcxContentPart[]): string { if (typeof content === "string") return content; return content .map(p => { - if (p.type === "text") return p.text; + if (p.type === "text" || p.type === "document") return p.text; if (p.type === "image") return `[image:${p.detail ?? "auto"}]`; return ""; }) diff --git a/src/adapters/ollama-native.ts b/src/adapters/ollama-native.ts index c78f154d49d..726fe55ad49 100644 --- a/src/adapters/ollama-native.ts +++ b/src/adapters/ollama-native.ts @@ -262,6 +262,12 @@ function contentToNative( text += part.text; continue; } + // No Ollama document carrier: keep the marker rather than falling through to the image + // branch below, which would read a nonexistent imageUrl. + if (part.type === "document") { + text += part.text; + continue; + } // Ollama's native /api/chat message shape carries `images: string[]` and has no video // counterpart, so a video part is refused rather than silently dropped or mis-sent as an image. if (part.type === "video") throw new Error(`ollama-native cannot send video content in ${label}`); diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index b44e4e28ec8..16952b959d2 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -6,6 +6,7 @@ import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "../ import { identifyRoutedModel } from "../identity"; import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "../tool-catalog-nudge"; import { peekReasoningForCall } from "../../responses/reasoning-replay-cache"; +import { inlineDocumentDataUrl } from "../../responses/inline-document"; import type { OcxAssistantMessage, OcxContentPart, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall } from "../../types"; import { modelInList, namespacedToolName } from "../../types"; @@ -152,8 +153,11 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv case "developer": { const parts = typeof msg.content === "string" ? undefined : msg.content as OcxContentPart[]; const hasImages = parts?.some(p => p.type === "image") ?? false; + // A document has a structured counterpart on this wire, so it needs the parts array for + // the same reason an image does: flattening it to a string would drop the bytes. + const hasStructured = hasImages || (parts?.some(p => p.type === "document") ?? false); let chatMsg: Record; - if (msg.role === "developer" && !hasImages) { + if (msg.role === "developer" && !hasStructured) { const text = typeof msg.content === "string" ? msg.content : parts!.map(p => (p as OcxTextContent).text).join(""); @@ -165,7 +169,7 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv chatMsg = { role: developerWireRole, content: text }; } else if (typeof msg.content === "string") { chatMsg = { role: "user", content: msg.content }; - } else if (!hasImages) { + } else if (!hasStructured) { // A video part has no `text`, so joining it produced "" and the whole message // was dropped: a video-only or text-plus-video turn vanished silently. OpenAI's // Chat Completions wire has no video content part, so state the omission @@ -183,6 +187,17 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv if (p.type === "image") { return { type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } }; } + // Chat Completions carries an attached document as a file part with inline bytes, + // the direct counterpart of the Anthropic document block the caller sent. + if (p.type === "document") { + return { + type: "file", + file: { + file_data: inlineDocumentDataUrl(p), + ...(p.filename !== undefined ? { filename: p.filename } : {}), + }, + }; + } // Previously this produced { type: "text", text: undefined } for a video // part — a malformed part, worse than a drop because it can fail upstream // schema validation. diff --git a/src/chat/inbound.ts b/src/chat/inbound.ts index ac52e2e7794..f900d1dfe6c 100644 --- a/src/chat/inbound.ts +++ b/src/chat/inbound.ts @@ -92,11 +92,32 @@ function userContentToBlocks(content: unknown): Rec[] { continue; } const videoUrl = videoUrlFromPart(raw); - if (videoUrl) blocks.push({ type: "input_video", video_url: videoUrl }); + if (videoUrl) { + blocks.push({ type: "input_video", video_url: videoUrl }); + continue; + } + const file = fileFromPart(raw); + if (file) blocks.push(file); } return blocks; } +/** + * A Chat Completions `file` part carrying inline bytes, as the Responses `input_file` block. + * + * Nothing here recognized the shape, so the part reached the end of the loop with no branch and + * was dropped in silence (#5212). A part with no inline bytes is still not translatable and is + * left to the untranslated-media refusal, which runs before this loop. + */ +function fileFromPart(part: Rec): Rec | null { + if (part.type !== "file" && part.type !== "input_file") return null; + const file = isRec(part.file) ? part.file : part; + const fileData = file.file_data; + if (typeof fileData !== "string" || fileData.length === 0) return null; + const filename = typeof file.filename === "string" && file.filename.length > 0 ? file.filename : undefined; + return { type: "input_file", file_data: fileData, ...(filename ? { filename } : {}) }; +} + /** * The assistant's prior thinking, as plaintext, from either Chat spelling. * diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index e3664bf01c2..2938c9b2a9d 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -19,6 +19,7 @@ import { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, f import { systemToInstructions, toolsToResponses, toolChoiceToResponses } from "./inbound-content-options"; import { stabilizeClaudeInstructionsForPromptCache } from "./inbound-cache-stabilize"; import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; +import { inlineDocumentMarker } from "../responses/inline-document"; import { createTranslatorBudget, type TranslatorBudget } from "../lib/translator-budget"; @@ -41,6 +42,26 @@ function imageBlockToInputImage(block: Rec): Rec | null { return null; } +function documentTitle(block: Rec): string | undefined { + return typeof block.title === "string" && block.title.length > 0 ? block.title : undefined; +} + +/** An Anthropic base64 document as the Responses `input_file` block that carries its bytes. */ +function documentBlockToInputFile(block: Rec): Rec | null { + const source = block.source; + if (!isRec(source) || source.type !== "base64") return null; + const mediaType = typeof source.media_type === "string" && source.media_type.length > 0 + ? source.media_type + : "application/octet-stream"; + if (typeof source.data !== "string" || source.data.length === 0) return null; + const title = documentTitle(block); + return { + type: "input_file", + file_data: `data:${mediaType};base64,${source.data}`, + ...(title !== undefined ? { filename: title } : {}), + }; +} + function toolResultOutput(block: Rec): string | Rec[] { const isError = block.is_error === true; const content = block.content; @@ -55,9 +76,11 @@ function toolResultOutput(block: Rec): string | Rec[] { const img = imageBlockToInputImage(item); if (img) out.push(img); } else if (item.type === "document") { - // Same marker as the user-message document case below: the model should see the - // attachment happened instead of an empty tool output. - out.push({ type: "input_text", text: `[document${typeof item.title === "string" ? `: ${item.title}` : ""}]` }); + // Tool output has no structured document carrier on this route — the Responses tool + // output vocabulary has no input_file block, and every adapter's tool-result path + // flattens to text — so this keeps the #939 marker. The user-message branch below is + // where bytes survive. Recorded as the remaining half of #5212. + out.push({ type: "input_text", text: inlineDocumentMarker(documentTitle(item)) }); } } if (isError) out.unshift({ type: "input_text", text: "[tool error]" }); @@ -208,9 +231,12 @@ function userMessageToItems(content: unknown, input: Rec[], elide: SkillElisionC break; } case "document": - // No Responses equivalent for raw document blocks; surface the title so the - // model at least sees the attachment happened. - pending.push({ type: "input_text", text: `[document${typeof raw.title === "string" ? `: ${raw.title}` : ""}]` }); + // A base64 document now rides the Responses input_file block, so a target with a + // counterpart receives the bytes instead of a sentence about them (#5212). Every other + // source is a reference this route cannot dereference, and keeps the marker #939 + // introduced — which is also what a target with no document representation still sees. + pending.push(documentBlockToInputFile(raw) + ?? { type: "input_text", text: inlineDocumentMarker(documentTitle(raw)) }); break; default: break; // thinking/redacted_thinking never appear in user messages; ignore unknowns diff --git a/src/responses/inline-document.ts b/src/responses/inline-document.ts new file mode 100644 index 00000000000..208d3f84d02 --- /dev/null +++ b/src/responses/inline-document.ts @@ -0,0 +1,58 @@ +import type { OcxDocumentContent } from "../types"; + +const DATA_URL = /^data:([^;,]+)((?:;[^;,]*)*),(.*)$/s; +const BASE64_PAYLOAD = /^[A-Za-z0-9+/]+={0,2}$/; + +/** The one marker vocabulary for an attached document, derived from what the part knows. */ +export function inlineDocumentMarker(filename: string | undefined): string { + return filename !== undefined && filename.length > 0 ? `[document: ${filename}]` : "[document]"; +} + +/** + * An inline document part from a `data:` URL, or `undefined` when there are no usable bytes. + * + * A reference with no payload — a `file_id`, a bare filename — is deliberately not a document: + * there is nothing to carry, and minting a part for it would claim an attachment the request + * never contained. Those keep the marker path they already had. + */ +export function inlineDocumentFromDataUrl( + fileData: string | undefined, + filename: string | undefined, +): OcxDocumentContent | undefined { + if (typeof fileData !== "string" || fileData.length === 0) return undefined; + const match = DATA_URL.exec(fileData); + if (!match) return undefined; + const mediaType = match[1]!; + const payload = match[3]!; + if (!hasBase64Parameter(match[2] ?? "") || !BASE64_PAYLOAD.test(payload)) return undefined; + return { + type: "document", + text: inlineDocumentMarker(filename), + mediaType, + data: payload, + ...(filename !== undefined && filename.length > 0 ? { filename } : {}), + }; +} + +/** The `data:` URL spelling of a document part, for wires whose counterpart takes one. */ +export function inlineDocumentDataUrl(part: OcxDocumentContent): string { + return `data:${part.mediaType};base64,${part.data}`; +} + +/** + * Whether this string is a `data:` URL this module would actually decode. + * + * The media-type scanner and this parser have to agree exactly. A looser scanner would exempt an + * attachment from the untranslated-media refusal that the parser then reduces to a marker, which + * is the silent drop the refusal exists to prevent. `;base64` must be a whole parameter token, + * not a substring of one: `;notbase64,` and `;x=base64,` are not base64 payloads. + */ +export function isInlineDocumentDataUrl(value: unknown): boolean { + if (typeof value !== "string" || value.length === 0) return false; + const match = DATA_URL.exec(value); + return match !== null && hasBase64Parameter(match[2] ?? "") && BASE64_PAYLOAD.test(match[3]!); +} + +function hasBase64Parameter(parameters: string): boolean { + return parameters.split(";").includes("base64"); +} diff --git a/src/responses/input-media.ts b/src/responses/input-media.ts index 43f8db583ec..be4909198e9 100644 --- a/src/responses/input-media.ts +++ b/src/responses/input-media.ts @@ -1,3 +1,5 @@ +import { isInlineDocumentDataUrl } from "./inline-document"; + /** Input kinds for which the normalized request has no lossless content carrier. */ export type UntranslatedInputMedia = "audio" | "file"; @@ -7,20 +9,47 @@ function isRecord(value: unknown): value is RecordValue { return value !== null && typeof value === "object" && !Array.isArray(value); } -function mediaKind(value: unknown): UntranslatedInputMedia | undefined { +/** + * Whether the position being scanned has a converter that builds a document part. + * + * Only user content does. A tool output, a system or assistant message, and a Chat `developer` + * message are all flattened to text by their converters, so exempting an attachment there would + * turn today's explicit refusal into the silent drop this scanner exists to prevent. + */ +type CarrierPosition = "user-content" | "flattened"; + +function mediaKind(value: unknown, position: CarrierPosition): UntranslatedInputMedia | undefined { if (!isRecord(value)) return undefined; if (value.type === "input_audio" || value.type === "audio") return "audio"; - if (value.type === "input_file" || value.type === "file" || value.type === "document") return "file"; + if (value.type === "input_file" || value.type === "file" || value.type === "document") { + // A document that carries its own bytes has a lossless carrier in user content, so refusing + // it there would reject the very request #5212 exists to preserve. A reference with no + // payload has none anywhere: translated adapters cannot dereference a file_id or a remote + // source. + return position === "user-content" && carriesInlineDocumentBytes(value) ? undefined : "file"; + } // A file-id-only image is not pixels: translated adapters cannot dereference it. if (value.type === "input_image" && typeof value.file_id === "string" && value.file_id.length > 0 && !(typeof value.image_url === "string" && value.image_url.length > 0)) return "file"; return undefined; } -function contentMedia(content: unknown): UntranslatedInputMedia | undefined { +/** Presence of a base64 payload only. No payload is read, decoded, copied or returned. */ +function carriesInlineDocumentBytes(value: RecordValue): boolean { + if (isInlineDocumentDataUrl(value.file_data)) return true; + // Chat Completions nests the payload under `file`. + if (isRecord(value.file) && isInlineDocumentDataUrl(value.file.file_data)) return true; + // Anthropic nests it under a base64 `source`. + return isRecord(value.source) + && value.source.type === "base64" + && typeof value.source.data === "string" + && value.source.data.length > 0; +} + +function contentMedia(content: unknown, position: CarrierPosition): UntranslatedInputMedia | undefined { if (!Array.isArray(content)) return undefined; for (const part of content) { - const kind = mediaKind(part); + const kind = mediaKind(part, position); if (kind) return kind; } return undefined; @@ -35,13 +64,16 @@ export function untranslatedResponsesInputMedia(body: unknown): UntranslatedInpu if (!isRecord(body) || !Array.isArray(body.input)) return undefined; for (const item of body.input) { if (!isRecord(item)) continue; - const direct = mediaKind(item); + const direct = mediaKind(item, "flattened"); if (direct) return direct; if (item.type === "function_call_output" || item.type === "custom_tool_call_output") { - const kind = contentMedia(item.output); + const kind = contentMedia(item.output, "flattened"); if (kind) return kind; } else if (item.type === "message" || item.type === undefined) { - const kind = contentMedia(item.content); + // `inputContentParts` runs for user and developer messages only; a system message is + // flattened to text by the parser. + const role = item.role; + const kind = contentMedia(item.content, role === "user" || role === "developer" ? "user-content" : "flattened"); if (kind) return kind; } } @@ -53,7 +85,9 @@ export function untranslatedChatInputMedia(body: unknown): UntranslatedInputMedi if (!isRecord(body) || !Array.isArray(body.messages)) return undefined; for (const message of body.messages) { if (!isRecord(message)) continue; - const kind = contentMedia(message.content); + // Only the `user` branch of the Chat projection builds content blocks. `system`, + // `developer`, `assistant` and `tool` all reduce their content to a string. + const kind = contentMedia(message.content, message.role === "user" ? "user-content" : "flattened"); if (kind) return kind; } return undefined; diff --git a/src/responses/parser-content.ts b/src/responses/parser-content.ts index 7675a42f7e8..00b174320ca 100644 --- a/src/responses/parser-content.ts +++ b/src/responses/parser-content.ts @@ -1,4 +1,5 @@ import type { OcxContentPart, OcxTextContent } from "../types"; +import { inlineDocumentFromDataUrl } from "./inline-document"; export function isObj(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); @@ -90,8 +91,13 @@ export function inputContentParts(blocks: unknown): string | OcxContentPart[] { if (fileId) { parts.push({ type: "text", text: `[file: ${fileId}]` }); } else if (fileData) { - // Inline file_data is often large base64. Preserve only its presence and name, never bytes. - parts.push({ type: "text", text: filename ? `[file: ${filename}]` : "[file: inline data]" }); + // Inline bytes used to be reduced to a name here, which meant no adapter could forward + // the attachment even to a target that has a representation for it, and a title-only + // forward came back as a confident answer about a document the model never saw (#5212). + // The marker survives on the part for every wire that still cannot carry one. Bytes with + // no declared media type are not decodable into a document and keep the marker they had. + const document = inlineDocumentFromDataUrl(fileData, filename); + parts.push(document ?? { type: "text", text: filename ? `[file: ${filename}]` : "[file: inline data]" }); } // A bare filename is not a file resource in the Responses schema, so omit it rather than // fabricating a "[file: ...]" marker for an attachment that was never sent. diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 396f2170b2e..51bb36f5245 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -245,7 +245,9 @@ export function parseRequest( case "system": { pendingReasoning.length = 0; const text = inputContentParts(msg.content); - const flat = typeof text === "string" ? text : text.map(p => (p.type === "text" ? p.text : "")).join(""); + const flat = typeof text === "string" + ? text + : text.map(p => (p.type === "text" || p.type === "document" ? p.text : "")).join(""); if (flat.length > 0) systemPrompt.push(flat); break; } diff --git a/src/server/responses/input-admission.ts b/src/server/responses/input-admission.ts index ca11fd60eb5..595716417ff 100644 --- a/src/server/responses/input-admission.ts +++ b/src/server/responses/input-admission.ts @@ -85,6 +85,9 @@ function imageTokens(imageUrl: string): number { function contentPartTokens(part: OcxContentPart, modelId: string): number { if (part.type === "image") return imageTokens(part.imageUrl); if (part.type === "video") return imageTokens(part.videoUrl); + // An inline document is a base64 payload, not a sentence: estimating it from its marker would + // admit a request whose real input is orders of magnitude larger. + if (part.type === "document") return imageTokens(`data:${part.mediaType};base64,${part.data}`); return estimateTokens(part.text, modelId); } diff --git a/src/types.ts b/src/types.ts index 93390ce6063..4cbd4494d79 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,6 +43,7 @@ export type { OcxToolResultMessage, OcxTextContent, OcxImageContent, + OcxDocumentContent, OcxContentPart, OcxThinkingContent, OcxToolCall, diff --git a/src/types/request.ts b/src/types/request.ts index 73bc671c8c2..7a73eaa5987 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -210,8 +210,34 @@ export interface OcxVideoContent { videoUrl: string; } -/** A user/developer message content part: text or native media. */ -export type OcxContentPart = OcxTextContent | OcxImageContent | OcxVideoContent; +/** + * An attached document carried as bytes rather than as a description of itself. + * + * Both inbound parsers used to reduce an attachment to a title before any adapter ran, so no + * adapter could forward one even to a target that has a representation for it, and the caller + * could not tell "the model read the document" from "the model was told a document existed" + * (#5212). + * + * `text` is that marker, derived once from what the part knows and kept on the part itself. + * Every text-only consumer in the tree reaches a `.text` fallback for a part it does not + * recognize, so carrying it here means a wire with no document representation still states the + * attachment instead of emitting `undefined` or a mislabelled `[video]`. Only the wires that + * have a counterpart read `data`. + */ +export interface OcxDocumentContent { + type: "document"; + /** `[document: name]` marker, for every wire with no document representation. */ + text: string; + /** IANA media type of the payload, for example `application/pdf`. */ + mediaType: string; + /** Base64 payload with no `data:` prefix. */ + data: string; + /** The document's own name: an Anthropic document title or a Chat file part's filename. */ + filename?: string; +} + +/** A user/developer message content part: text, native media, or an attached document. */ +export type OcxContentPart = OcxTextContent | OcxImageContent | OcxVideoContent | OcxDocumentContent; export interface OcxThinkingContent { type: "thinking"; diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 28a464e3e03..fcf65bf5c7c 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -196,8 +196,18 @@ variant; unrelated model families retain their existing suffix precedence. `src/responses/input-media.ts` inspects actual content blocks and typed tool-output arrays without parsing text or function arguments, copying attachment payloads, resolving file IDs, -or fetching URLs. Audio, files/documents and file-ID-only images have no lossless normalized -carrier. The scanner returns only an input-kind name, never client content. +or fetching URLs. Audio and file-ID-only images have no lossless normalized carrier, and +neither does a file or document reference that carries no bytes. The scanner returns only an +input-kind name, never client content. + +A document that carries its own base64 bytes is the one exception, and only in user or +developer message content: `src/responses/inline-document.ts` decodes it into the +`OcxDocumentContent` part, which the Anthropic, OpenAI Chat and Gemini wires emit as a native +document, file part and `inline_data` respectively. Every other position — tool output, system +and assistant content — is still refused, because those converters reduce their content to text +and exempting them would restore the silent drop the scanner exists to prevent. The scanner and +the decoder share one predicate so a request cannot be exempted here and reduced to a marker +there. `src/adapters/input-media-guard.ts` guards adapters created by the registry after effective wire selection. A translated `buildRequest` refuses these inputs through the existing 400 diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index e6171845efa..21b23f28406 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -334,7 +334,11 @@ default and require an explicit `thinking:{type:"disabled"}` to stop. The native Chat path retains provider-native file/audio blocks. When a request instead needs Chat-to-Responses projection, `src/chat/inbound.ts` rejects recognized audio/file content -before it can become empty text, regardless of message role. Legacy `function`-role images +before it can become empty text. The one exception is a `file` part carrying inline base64 +bytes in a `user` message: that projection builds an `input_file` block and the bytes survive +to any wire with a counterpart. The same part in a `system`, `developer`, `assistant` or +`tool` message is still refused, because those branches flatten their content to a string. +Legacy `function`-role images also return an explicit error; their call/result pairing is not implemented by this projection. Modern `tool` images continue through the existing following-user carrier. These errors state an OpenCodex conversion limit, not a provider capability claim. Final Responses-to-adapter diff --git a/tests/adapters/adapter-input-media-guard.test.ts b/tests/adapters/adapter-input-media-guard.test.ts index c63c137d52b..c6dc9f8c0a8 100644 --- a/tests/adapters/adapter-input-media-guard.test.ts +++ b/tests/adapters/adapter-input-media-guard.test.ts @@ -8,7 +8,11 @@ import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../sr import { createTestTranslatorBudget, withTestTranslatorBudget } from "../helpers/translator-budget"; const AUDIO = { type: "input_audio", audio_url: "data:audio/wav;base64,YWJj" }; -const FILE = { type: "input_file", filename: "private.pdf", file_data: "data:application/pdf;base64,JVBERi0=" }; +// A reference this route cannot dereference: there are no bytes to carry anywhere. +const FILE = { type: "input_file", filename: "private.pdf", file_id: "file-private" }; +// The same attachment with its bytes. User content has a carrier for it (#5212); no other +// position does, because every other converter reduces its content to text. +const INLINE_FILE = { type: "input_file", filename: "private.pdf", file_data: "data:application/pdf;base64,JVBERi0=" }; function request(content: unknown[]): OcxParsedRequest { return parseRequest({ model: "test-model", input: [{ type: "message", role: "user", content }] }); @@ -39,6 +43,27 @@ describe("typed input media inspection", () => { for (const type of ["function_call_output", "custom_tool_call_output"]) { expect(untranslatedResponsesInputMedia({ input: [{ type, call_id: "call1", output: [AUDIO] }] })).toBe("audio"); expect(untranslatedResponsesInputMedia({ input: [{ type, call_id: "call1", output: [FILE] }] })).toBe("file"); + // Bytes do not help here: the tool-output converter flattens its content to text. + expect(untranslatedResponsesInputMedia({ input: [{ type, call_id: "call1", output: [INLINE_FILE] }] })).toBe("file"); + } + }); + + test("an inline document is permitted only where a converter carries it", () => { + expect(untranslatedResponsesInputMedia(request([INLINE_FILE])._rawBody)).toBeUndefined(); + for (const role of ["developer", "user"]) { + expect(untranslatedResponsesInputMedia({ input: [{ type: "message", role, content: [INLINE_FILE] }] })) + .toBeUndefined(); + } + for (const role of ["system", "assistant"]) { + expect(untranslatedResponsesInputMedia({ input: [{ type: "message", role, content: [INLINE_FILE] }] })) + .toBe("file"); + } + }); + + test("a base64 look-alike parameter is not an inline payload", () => { + for (const fileData of ["data:text/plain;notbase64,abc", "data:text/plain;x=base64,abc", "data:text/plain,abc"]) { + expect(untranslatedResponsesInputMedia(request([{ type: "input_file", file_data: fileData }])._rawBody)) + .toBe("file"); } }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d147b008287..51a843c63ff 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1403,6 +1403,7 @@ "anthropic-tool-declaration-constraints.test.ts": "adapters/anthropic", "google-strict-tool-validated-mode.test.ts": "adapters/google", "openai-chat-developer-position.test.ts": "adapters/openai", + "chat-inline-document-bytes.test.ts": "responses", "devin-output-budget.test.ts": "providers", "api-key-model-scope.test.ts": "server" } diff --git a/tests/responses/chat-inline-document-bytes.test.ts b/tests/responses/chat-inline-document-bytes.test.ts new file mode 100644 index 00000000000..970f5d9891e --- /dev/null +++ b/tests/responses/chat-inline-document-bytes.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from "bun:test"; +import { createAnthropicAdapter } from "../../src/adapters/anthropic"; +import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; +import { chatCompletionsToResponsesBody, ChatCompletionsRequestError } from "../../src/chat/inbound"; +import { anthropicToResponsesBody } from "../../src/claude/inbound"; +import { parseRequest } from "../../src/responses/parser"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../src/types"; + +/** + * #5212. Both inbound parsers reduced an attachment to its name before any adapter ran, so no + * adapter could forward one even to a target that has a representation for it. The caller could + * not tell "the model read the document" from "the model was told a document existed", which is + * why every assertion here reads the outbound request rather than the response. + */ + +const PDF_BYTES = "JVBERi0xLjQK"; +const PDF_DATA_URL = `data:application/pdf;base64,${PDF_BYTES}`; + +const chatProvider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://gateway.example.internal/v1", + apiKey: "k", +}; +const anthropicProvider = { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + apiKey: "sk-x", + authMode: "apiKey", +} as unknown as OcxProviderConfig; + +function chatRequest(part: unknown): Record { + return { + model: "mock/test-model", + messages: [{ role: "user", content: [part] }], + }; +} + +function claudeRequest(block: unknown): Record { + return { + model: "anthropic/claude-sonnet-4.5", + max_tokens: 64, + messages: [{ role: "user", content: [block] }], + }; +} + +const DOCUMENT_BLOCK = { + type: "document", + title: "spec", + source: { type: "base64", media_type: "application/pdf", data: PDF_BYTES }, +}; + +/** The user turn's content, which `inputContentParts` collapses to a string for lone text. */ +function parsedContent(body: Record): unknown { + const parsed = parseRequest(body as never); + const user = parsed.context.messages.find(message => message.role === "user"); + return user!.content; +} + +describe("inline document bytes survive the inbound parse", () => { + test("a Chat file part becomes a document carrying its bytes", () => { + const content = parsedContent(chatCompletionsToResponsesBody(chatRequest({ + type: "file", + file: { filename: "doc.pdf", file_data: PDF_DATA_URL }, + }))); + expect(content).toEqual([ + { type: "document", text: "[document: doc.pdf]", mediaType: "application/pdf", data: PDF_BYTES, filename: "doc.pdf" }, + ]); + }); + + test("an Anthropic base64 document keeps its bytes and its title", () => { + const content = parsedContent(anthropicToResponsesBody(claudeRequest(DOCUMENT_BLOCK))); + expect(content).toEqual([ + { type: "document", text: "[document: spec]", mediaType: "application/pdf", data: PDF_BYTES, filename: "spec" }, + ]); + }); + + test("a document with no usable bytes still reduces to the marker", () => { + const content = parsedContent(anthropicToResponsesBody(claudeRequest({ + type: "document", + title: "remote", + source: { type: "url", url: "https://example.com/doc.pdf" }, + }))); + expect(content).toBe("[document: remote]"); + }); + + test("a reference with no payload is still refused rather than answered", () => { + expect(() => chatCompletionsToResponsesBody(chatRequest({ + type: "file", + file: { file_id: "file-123" }, + }))).toThrow(ChatCompletionsRequestError); + expect(() => chatCompletionsToResponsesBody(chatRequest({ + type: "input_audio", + input_audio: { data: "AA==", format: "wav" }, + }))).toThrow(ChatCompletionsRequestError); + }); +}); + +describe("inline document bytes reach a wire that can hold them", () => { + function chatOutbound(body: Record): Record { + const parsed = parseRequest(body as never) as OcxParsedRequest; + return JSON.parse(createOpenAIChatAdapter(chatProvider).buildRequest(parsed).body) as Record; + } + + test("an Anthropic document reaches the OpenAI Chat wire as a file part", () => { + const outbound = chatOutbound(anthropicToResponsesBody(claudeRequest(DOCUMENT_BLOCK))); + const messages = outbound.messages as Array<{ role: string; content: unknown }>; + expect(messages.at(-1)).toEqual({ + role: "user", + content: [{ type: "file", file: { file_data: PDF_DATA_URL, filename: "spec" } }], + }); + }); + + test("a Chat file part reaches the Anthropic wire as a document block", async () => { + const parsed = parseRequest(chatCompletionsToResponsesBody(chatRequest({ + type: "file", + file: { filename: "doc.pdf", file_data: PDF_DATA_URL }, + })) as never) as OcxParsedRequest; + const { body } = await createAnthropicAdapter(anthropicProvider).buildRequest(parsed); + const sent = JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as { + messages: Array<{ role: string; content: unknown }>; + }; + expect(sent.messages.at(-1)).toEqual({ + role: "user", + content: [{ + type: "document", + source: { type: "base64", media_type: "application/pdf", data: PDF_BYTES }, + title: "doc.pdf", + }], + }); + }); + + test("a document beside text keeps both on the Chat wire", () => { + const outbound = chatOutbound(anthropicToResponsesBody({ + model: "anthropic/claude-sonnet-4.5", + max_tokens: 64, + messages: [{ role: "user", content: [{ type: "text", text: "Summarize it." }, DOCUMENT_BLOCK] }], + })); + const messages = outbound.messages as Array<{ role: string; content: unknown[] }>; + expect(messages.at(-1)!.content).toEqual([ + { type: "text", text: "Summarize it." }, + { type: "file", file: { file_data: PDF_DATA_URL, filename: "spec" } }, + ]); + }); +}); diff --git a/tests/responses/chat-media-translation.test.ts b/tests/responses/chat-media-translation.test.ts index a3042d4f3d7..d37c27ec871 100644 --- a/tests/responses/chat-media-translation.test.ts +++ b/tests/responses/chat-media-translation.test.ts @@ -16,9 +16,10 @@ const provider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https:// const media = [ { type: "input_audio", input_audio: { data: "YWJj", format: "wav" } }, { type: "input_audio", audio_url: "data:audio/wav;base64,YWJj" }, - { type: "file", file: { filename: "private.pdf", file_data: "data:application/pdf;base64,JVBERi0=" } }, { type: "input_file", file_id: "file-private" }, ]; +// An inline document is carried in user content and still has no carrier anywhere else (#5212). +const INLINE_FILE = { type: "file", file: { filename: "private.pdf", file_data: "data:application/pdf;base64,JVBERi0=" } }; function chat(part: unknown, role = "user") { return { model: "model", messages: [{ role, tool_call_id: "call1", content: [{ type: "text", text: "read this" }, part] }] }; @@ -34,9 +35,24 @@ describe("Chat media stays native or fails explicitly at translation", () => { } }); + test("an inline document is carried in user content and refused where nothing carries it", () => { + const translated = chatCompletionsToResponsesBody(chat(INLINE_FILE)); + expect(translated.input).toEqual([{ + type: "message", + role: "user", + content: [ + { type: "input_text", text: "read this" }, + { type: "input_file", file_data: "data:application/pdf;base64,JVBERi0=", filename: "private.pdf" }, + ], + }]); + for (const role of ["tool", "system", "assistant"]) { + expect(() => chatCompletionsToResponsesBody(chat(INLINE_FILE, role))).toThrow("OpenCodex cannot translate"); + } + }); + test("the native Chat route retains the caller's exact media blocks", () => { const route = { provider, providerName: "gateway", modelId: "model" } as RouteResult; - for (const part of media) { + for (const part of [...media, INLINE_FILE]) { const raw = chat(part); expect(isNativeChatRouteEligible(route, raw)).toBe(true); const wire = JSON.parse(buildOpenAIChatPassthroughRequest(provider, raw, "model", false).body); @@ -59,8 +75,9 @@ describe("Chat media stays native or fails explicitly at translation", () => { }); test("plain text mentioning an attachment is not treated as one", () => { - const out = chatCompletionsToResponsesBody({ model: "model", messages: [{ role: "user", content: JSON.stringify(media) }] }); - expect(out.input).toEqual([{ type: "message", role: "user", content: [{ type: "input_text", text: JSON.stringify(media) }] }]); + const text = JSON.stringify([...media, INLINE_FILE]); + const out = chatCompletionsToResponsesBody({ model: "model", messages: [{ role: "user", content: text }] }); + expect(out.input).toEqual([{ type: "message", role: "user", content: [{ type: "input_text", text }] }]); }); }); @@ -93,7 +110,7 @@ test("real HTTP translation refuses media before sending to the selected upstrea server = startServer(0); for (const part of [ { type: "input_audio", audio_url: "data:audio/wav;base64,YWJj" }, - { type: "input_file", filename: "private.pdf", file_data: "data:application/pdf;base64,JVBERi0=" }, + { type: "input_file", filename: "private.pdf", file_id: "file-private" }, ]) { const response = await fetch(new URL("/v1/responses", server.url), { method: "POST", headers: { "Content-Type": "application/json" }, From 70798bb87edd67794889079691da3e2d30b326e7 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 14:52:35 +0900 Subject: [PATCH 06/11] fix(adapters): refuse unrepresentable declarations by default, not per adapter Adversarial review of the whole branch found the same shape of hole in two of its fixes: a constraint the normalized request now carries still reached wires that rebuild the declaration or the message without it, and answered normally. tools[].allowed_callers was refused by the OpenAI Chat and Gemini builders because those are the two the report named. Cursor, Devin, Kiro, Command Code, Ollama and the coding-agent wires rebuild tools from name, description and schema, so a caller-restricted tool reached those upstreams unrestricted. Inline document bytes had the same problem from the other direction: admission exempted every user-content document without knowing the destination, and a wire with no carrier replaced the bytes with the marker and continued. Both are now default-deny allowlists in adapters/declaration-carrier.ts, enforced at the single guard in adapters/input-media-guard.ts that every registered adapter passes through. allowed_callers reaches the anthropic wire; document bytes reach anthropic, openai-chat and google. Adding an AdapterWire member makes the omission visible in those lists rather than at a customer's upstream, which a per-adapter opt-in could never do. The Responses passthrough stays exempt from the whole guard because it forwards the original body. The refusal no longer names the tool, which is caller-controlled and put client metadata into an error body. An allowed_tools entry whose selector kind is neither function, custom, nor a hosted type is refused rather than flattened to a bare name. Token admission counts a document's payload arithmetically instead of rebuilding a request-sized data URL to measure it. --- docs-site/src/content/docs/guides/pi.md | 19 ++++---- src/adapters/declaration-carrier.ts | 39 ++++++++++++++++ src/adapters/google.ts | 2 - src/adapters/input-media-guard.ts | 30 ++++++++---- src/adapters/openai-chat/tool-schema.ts | 2 - src/adapters/registry.ts | 5 +- src/adapters/tool-declaration-constraints.ts | 28 ----------- src/chat/inbound.ts | 7 ++- src/server/responses/input-admission.ts | 11 ++++- src/types.ts | 1 + structure/providers-and-adapters.md | 2 +- .../adapter-input-media-guard.test.ts | 20 +++++++- ...ropic-tool-declaration-constraints.test.ts | 46 ++++++++++--------- .../chat-tool-choice-allowed-tools.test.ts | 7 +++ 14 files changed, 141 insertions(+), 78 deletions(-) create mode 100644 src/adapters/declaration-carrier.ts delete mode 100644 src/adapters/tool-declaration-constraints.ts diff --git a/docs-site/src/content/docs/guides/pi.md b/docs-site/src/content/docs/guides/pi.md index d7b9e3c3edf..99f44144f9b 100644 --- a/docs-site/src/content/docs/guides/pi.md +++ b/docs-site/src/content/docs/guides/pi.md @@ -146,14 +146,17 @@ An explicit reasoning effort of `none` survives Chat conversion. Output limits a controls are preserved for generic API-key Responses targets; the canonical ChatGPT target still applies its own restrictions. This does not make all providers' controls equivalent. -**Audio and files need a native input wire that supports them.** OpenCodex does not yet have -a lossless audio/file carrier for translated requests. When Chat requires projection, or a -Responses request targets a translated adapter, recognized audio/file attachments return an -explicit error rather than succeeding without the attachment. File-ID-only images have the -same restriction because translated adapters cannot resolve those IDs. Convert the attachment -to text first, or use a native wire and model that support it. Native Chat and raw Responses -(including Azure) retain their existing behavior; this is not a promise of every model's -upstream media support. Video conversion limits remain adapter-specific. +**Audio and most file attachments need a native input wire that supports them.** A document +that carries its own base64 bytes in a user message is the exception: it survives translation +and reaches the Anthropic, OpenAI Chat and Google wires as a native document, file part and +inline data part. Everything else still returns an explicit error rather than succeeding +without the attachment — audio, a file-ID or remote reference the proxy cannot dereference, an +attachment in a tool output or a system message, and a document routed to a wire with no byte +carrier. File-ID-only images have the same restriction because translated adapters cannot +resolve those IDs. Convert the attachment to text first, or use a native wire and model that +support it. Native Chat and raw Responses (including Azure) retain their existing behavior; +this is not a promise of every model's upstream media support. Video conversion limits remain +adapter-specific. ## Schema status diff --git a/src/adapters/declaration-carrier.ts b/src/adapters/declaration-carrier.ts new file mode 100644 index 00000000000..0720405f169 --- /dev/null +++ b/src/adapters/declaration-carrier.ts @@ -0,0 +1,39 @@ +// Type-only: erased at compile time, so this does not create an import cycle with the registry. +import type { AdapterWire } from "./registry"; +import type { OcxContentPart, OcxParsedRequest } from "../types"; +import { toolRestrictsCallers } from "../types"; + +/** + * Wires that can actually carry a constraint, as a default-deny allowlist. + * + * A per-adapter opt-in is the wrong shape for this: an adapter that never learned about a + * carrier rebuilds the declaration or the message without it and returns a normal completion, + * which is exactly the silent widening this batch exists to remove. Listing the wires that CAN + * carry it means a new wire refuses until someone teaches it, and adding a member to + * `AdapterWire` makes the omission visible here rather than at a customer's upstream. + */ +const CALLER_RESTRICTION_WIRES: ReadonlySet = new Set(["anthropic"]); +const INLINE_DOCUMENT_WIRES: ReadonlySet = new Set([ + "anthropic", + "openai-chat", + "google", +]); + +/** A fixed-vocabulary refusal, or `undefined` when this wire can hold everything the request carries. */ +export function unrepresentableDeclaration(parsed: OcxParsedRequest, wire: AdapterWire): string | undefined { + if (!CALLER_RESTRICTION_WIRES.has(wire) && parsed.context.tools?.some(toolRestrictsCallers)) { + // The name is deliberately absent: it is caller-controlled and would put client metadata + // into an error body. + return "OpenCodex cannot express tools[].allowed_callers on this route. " + + "Route the request to an Anthropic-protocol provider, or remove the caller restriction."; + } + if (!INLINE_DOCUMENT_WIRES.has(wire) && parsed.context.messages.some(carriesDocument)) { + return "OpenCodex cannot translate document input on this route. " + + "Use a native input wire that supports the attachment, or convert it to text first."; + } + return undefined; +} + +function carriesDocument(message: { content: string | readonly OcxContentPart[] }): boolean { + return typeof message.content !== "string" && message.content.some(part => part.type === "document"); +} diff --git a/src/adapters/google.ts b/src/adapters/google.ts index e9025004df8..a82c240a1d9 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -16,7 +16,6 @@ import type { } from "../types"; import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../types"; import type { OcxTool } from "../types"; -import { assertToolCallerRestrictionsRepresentable } from "./tool-declaration-constraints"; import { contentPartsToText, parseDataUrl } from "./image"; import { getVertexAccessToken } from "../lib/gcp-adc"; import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http"; @@ -476,7 +475,6 @@ function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined { if (!parsed.context.tools?.length) return undefined; const tools = advertisedGeminiTools(parsed); if (tools.length === 0) return undefined; - assertToolCallerRestrictionsRepresentable(tools, "the Gemini generateContent wire"); return [{ functionDeclarations: tools.map(t => ({ name: namespacedToolName(t.namespace, t.name), diff --git a/src/adapters/input-media-guard.ts b/src/adapters/input-media-guard.ts index ad8f468a95b..96c554782d1 100644 --- a/src/adapters/input-media-guard.ts +++ b/src/adapters/input-media-guard.ts @@ -1,31 +1,45 @@ import type { ProviderAdapter } from "./base"; import { untranslatedInputMediaMessage, untranslatedResponsesInputMedia } from "../responses/input-media"; +import { unrepresentableDeclaration } from "./declaration-carrier"; +import type { AdapterWire } from "./registry"; +import type { OcxParsedRequest } from "../types"; /** * Refuse unrepresentable input at the final translated-adapter boundary. The registry * applies this after wire resolution; Responses passthrough (including Azure) opts * out because it uses the original body rather than the lossy normalized content. + * + * Two of the three checks are wire-scoped, and that is the point. A constraint the normalized + * request CAN carry — a caller restriction on a tool, an attached document's bytes — still has + * to reach a wire that can express it. Leaving that to each adapter means an adapter that never + * learned about the carrier rebuilds without it and answers normally, so the allowlist in + * `declaration-carrier.ts` is default-deny and this is the one place every registered adapter + * passes through. */ -export function withInputMediaGuard(adapter: T): T { +export function withInputMediaGuard(adapter: T, wire: AdapterWire): T { + const refusal = (parsed: OcxParsedRequest): string | undefined => { + const kind = untranslatedResponsesInputMedia(parsed._rawBody); + return kind ? untranslatedInputMediaMessage(kind) : unrepresentableDeclaration(parsed, wire); + }; const build = adapter.buildRequest.bind(adapter); adapter.buildRequest = (parsed, incoming) => { - const kind = untranslatedResponsesInputMedia(parsed._rawBody); - if (kind) throw new Error(untranslatedInputMediaMessage(kind)); + const message = refusal(parsed); + if (message) throw new Error(message); return build(parsed, incoming); }; const runTurn = adapter.runTurn?.bind(adapter); if (runTurn) { adapter.runTurn = async (parsed, incoming, emit) => { - const kind = untranslatedResponsesInputMedia(parsed._rawBody); - if (kind) { + const message = refusal(parsed); + if (message) { emit({ type: "error", status: 400, errorType: "invalid_request_error", code: "unsupported_input_modality", retryable: false, - message: untranslatedInputMediaMessage(kind), + message, }); return; } @@ -37,9 +51,7 @@ export function withInputMediaGuard(adapter: T): T { if (localTerminal) { // This hook is outside the builder's error catch. Decline its success shortcut; // the ordinary buildRequest path then returns the established client-safe 400. - adapter.localTerminal = parsed => untranslatedResponsesInputMedia(parsed._rawBody) - ? undefined - : localTerminal(parsed); + adapter.localTerminal = parsed => refusal(parsed) ? undefined : localTerminal(parsed); } return adapter; } diff --git a/src/adapters/openai-chat/tool-schema.ts b/src/adapters/openai-chat/tool-schema.ts index 02994bc8e9a..23f77121bd5 100644 --- a/src/adapters/openai-chat/tool-schema.ts +++ b/src/adapters/openai-chat/tool-schema.ts @@ -4,7 +4,6 @@ import { isXaiSchemaTarget, lookupLocalJsonPointer, normalizeXaiToolParameters } import { stripResponsesOnlyEncryptedMarker, stripUnicodePropertyPatterns } from "../responses-tool-schema"; import { isAllowedToolChoice, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../../types"; import type { OcxParsedRequest, OcxProviderConfig } from "../../types"; -import { assertToolCallerRestrictionsRepresentable } from "../tool-declaration-constraints"; const ZEN_SCHEMA_MAP_KEYS = new Set(["properties", "$defs", "definitions"]); const ZEN_DROPPED_SCHEMA_KEYS = new Set(["encrypted"]); @@ -419,7 +418,6 @@ export function toolsToChatFormat( if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined; const tools = parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice, parsed.context.tools)); if (tools.length === 0) return undefined; - assertToolCallerRestrictionsRepresentable(tools, "the OpenAI Chat Completions wire"); const xaiTarget = isXaiSchemaTarget(provider); const moonshotTarget = !xaiTarget && isMoonshotSchemaTarget(provider); const formatted = tools.flatMap(t => { diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts index 8d6e8bc63d7..f077f92c2c5 100644 --- a/src/adapters/registry.ts +++ b/src/adapters/registry.ts @@ -181,8 +181,9 @@ export function createRegisteredAdapter( const definition = getAdapterDefinition(provider.adapter); if (!definition) throw new Error(`Unknown adapter: ${provider.adapter}`); const adapter = definition.create(provider, context); - if (effectiveAdapterContract(provider.adapter).wire !== "openai-responses") { - withInputMediaGuard(adapter); + const wire = effectiveAdapterContract(provider.adapter).wire; + if (wire !== "openai-responses") { + withInputMediaGuard(adapter, wire); } const buildRequest = adapter.buildRequest.bind(adapter); adapter.buildRequest = (parsed, incoming) => { diff --git a/src/adapters/tool-declaration-constraints.ts b/src/adapters/tool-declaration-constraints.ts deleted file mode 100644 index e2e4eb84f60..00000000000 --- a/src/adapters/tool-declaration-constraints.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { namespacedToolName, toolRestrictsCallers, type OcxTool } from "../types"; - -/** - * Refuse a request whose tool declarations carry a restriction this wire cannot express. - * - * A declaration field is not decoration: `allowed_callers` says which callers may invoke the - * tool, and a wire with no counterpart rebuilds the declaration without it. The model is then - * offered a tool the caller had fenced off, and the caller gets an ordinary completion with no - * way to tell the fence is gone (#5210). Refusing turns a silent widening into a 400 the caller - * can act on, the same shape `ollama-native` and `kiro` already use for a tool_choice they - * cannot enforce. - * - * Anthropic is the wire that defines the field and carries it; this guard is for the wires that - * do not. It is a per-wire opt-in rather than a global check because only the adapter knows - * whether its own target has a counterpart. - */ -export function assertToolCallerRestrictionsRepresentable( - tools: readonly OcxTool[] | undefined, - wire: string, -): void { - const restricted = tools?.find(toolRestrictsCallers); - if (!restricted) return; - const name = namespacedToolName(restricted.namespace, restricted.name); - throw new Error( - `${wire} cannot express tools[].allowed_callers, declared on "${name}". ` - + "Route this request to an Anthropic-protocol provider, or remove the caller restriction.", - ); -} diff --git a/src/chat/inbound.ts b/src/chat/inbound.ts index f900d1dfe6c..1669207c447 100644 --- a/src/chat/inbound.ts +++ b/src/chat/inbound.ts @@ -270,7 +270,7 @@ function allowedToolsChoiceToResponses(choice: Rec): Rec { }; } -/** Hosted entries are named by their type alone; a function/custom entry must carry a name. */ +/** Hosted entries are named by their type alone; a function or custom entry must carry a name. */ const HOSTED_ALLOWED_TOOL_TYPES = new Set([ "web_search", "web_search_preview", @@ -278,12 +278,17 @@ const HOSTED_ALLOWED_TOOL_TYPES = new Set([ "image_gen", "tool_search", ]); +const NAMED_ALLOWED_TOOL_TYPES = new Set(["function", "custom"]); function allowedToolEntryToResponses(raw: unknown): Rec { if (!isRec(raw)) { throw new ChatCompletionsRequestError("tool_choice.allowed_tools.tools entries must be objects"); } const type = typeof raw.type === "string" && raw.type.length > 0 ? raw.type : "function"; + if (!NAMED_ALLOWED_TOOL_TYPES.has(type) && !HOSTED_ALLOWED_TOOL_TYPES.has(type)) { + // An unknown selector kind is not a narrower subset, it is a subset nobody can evaluate. + throw new ChatCompletionsRequestError(`unsupported tool_choice.allowed_tools.tools entry type: ${type}`); + } const nested = isRec(raw[type]) ? raw[type] as Rec : undefined; const name = typeof raw.name === "string" && raw.name.length > 0 ? raw.name diff --git a/src/server/responses/input-admission.ts b/src/server/responses/input-admission.ts index 595716417ff..950a3d90505 100644 --- a/src/server/responses/input-admission.ts +++ b/src/server/responses/input-admission.ts @@ -86,11 +86,18 @@ function contentPartTokens(part: OcxContentPart, modelId: string): number { if (part.type === "image") return imageTokens(part.imageUrl); if (part.type === "video") return imageTokens(part.videoUrl); // An inline document is a base64 payload, not a sentence: estimating it from its marker would - // admit a request whose real input is orders of magnitude larger. - if (part.type === "document") return imageTokens(`data:${part.mediaType};base64,${part.data}`); + // admit a request whose real input is orders of magnitude larger. Counted arithmetically — + // rebuilding the data URL here would materialize a second request-sized string just to measure it. + if (part.type === "document") return base64PayloadTokens(part.data); return estimateTokens(part.text, modelId); } +function base64PayloadTokens(base64: string): number { + if (base64.length === 0) return 0; + const decoded = Math.floor((base64.length * 3) / 4); + return Math.max(1, Math.ceil(decoded / IMAGE_BYTES_PER_TOKEN)); +} + function contentTokens(content: string | readonly OcxContentPart[], modelId: string): number { if (typeof content === "string") return estimateTokens(content, modelId); let total = 0; diff --git a/src/types.ts b/src/types.ts index 4cbd4494d79..747fc17c756 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,7 @@ export { isAllowedToolChoice, toolChoiceToolPredicate, declaresCodeModeExec, + toolRestrictsCallers, NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES, } from "./types/tools"; diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index bb4e7c2bfd7..de68ef9f7ae 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -18,7 +18,7 @@ the [bounded ingestion contract](transports/inventory.md#bounded-response-ingest | `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `tool-call-validation.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | | `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. | | `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. A caller-declared strict tool selects `functionCallingConfig.mode: "VALIDATED"` in place of the absent-choice default; `NONE`, `ANY` and a forced-name choice are stronger constraints the caller asked for and are never overwritten. | -| `src/adapters/tool-declaration-constraints.ts` | Refusal for tool-declaration fields a wire cannot express. `allowed_callers` fences a tool off from callers, so a wire with no counterpart would rebuild the declaration without it and answer normally; the OpenAI Chat and Gemini builders refuse instead. The unrestricted `["direct"]` default is not a restriction. Adoption is per wire, because only the adapter knows whether its target has a counterpart. | +| `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | | `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog and JWT support RPCs remain outside inference-send accounting. | diff --git a/tests/adapters/adapter-input-media-guard.test.ts b/tests/adapters/adapter-input-media-guard.test.ts index c6dc9f8c0a8..ff98aa347d8 100644 --- a/tests/adapters/adapter-input-media-guard.test.ts +++ b/tests/adapters/adapter-input-media-guard.test.ts @@ -18,7 +18,7 @@ function request(content: unknown[]): OcxParsedRequest { return parseRequest({ model: "test-model", input: [{ type: "message", role: "user", content }] }); } -function fakeAdapter() { +function fakeAdapter(wire: "openai-chat" | "cursor" = "openai-chat") { const seen = { builds: 0, runs: 0, terminals: 0 }; const adapter: ProviderAdapter = { name: "stub", @@ -30,7 +30,7 @@ function fakeAdapter() { async runTurn(_parsed, _incoming, emit) { seen.runs++; emit({ type: "done", endTurn: true }); }, localTerminal() { seen.terminals++; return { reason: "already answered" }; }, }; - return { adapter: withInputMediaGuard(adapter), seen }; + return { adapter: withInputMediaGuard(adapter, wire), seen }; } describe("typed input media inspection", () => { @@ -60,6 +60,22 @@ describe("typed input media inspection", () => { } }); + test("a document still has to reach a wire that can hold its bytes", () => { + const incoming = { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }; + const carrier = fakeAdapter("openai-chat"); + carrier.adapter.buildRequest(request([INLINE_FILE]), incoming); + expect(carrier.seen.builds).toBe(1); + + // Cursor rebuilds user content as text, so admitting the bytes there would put the request + // upstream with only the marker and return a normal completion. + const nonCarrier = fakeAdapter("cursor"); + let failure: unknown; + try { nonCarrier.adapter.buildRequest(request([INLINE_FILE]), incoming); } catch (error) { failure = error; } + expect((failure as Error).message).toContain("OpenCodex cannot translate document input"); + expect((failure as Error).message).not.toContain("private.pdf"); + expect(nonCarrier.seen.builds).toBe(0); + }); + test("a base64 look-alike parameter is not an inline payload", () => { for (const fileData of ["data:text/plain;notbase64,abc", "data:text/plain;x=base64,abc", "data:text/plain,abc"]) { expect(untranslatedResponsesInputMedia(request([{ type: "input_file", file_data: fileData }])._rawBody)) diff --git a/tests/adapters/anthropic/anthropic-tool-declaration-constraints.test.ts b/tests/adapters/anthropic/anthropic-tool-declaration-constraints.test.ts index 02047e10140..85a299b0305 100644 --- a/tests/adapters/anthropic/anthropic-tool-declaration-constraints.test.ts +++ b/tests/adapters/anthropic/anthropic-tool-declaration-constraints.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; import { createAnthropicAdapter } from "../../../src/adapters/anthropic"; -import { createGoogleAdapter } from "../../../src/adapters/google"; -import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import { createRegisteredAdapter } from "../../../src/adapters/registry"; import { anthropicToResponsesBody } from "../../../src/claude/inbound"; import { parseRequest } from "../../../src/responses/parser"; +import { createTestTranslatorBudget } from "../../helpers/translator-budget"; import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; /** @@ -75,34 +75,38 @@ describe("anthropic tool declarations carry their caller-supplied constraints", describe("wires without an allowed_callers counterpart refuse rather than widen", () => { const restricted = claudeTool({ allowed_callers: ["code_execution_20260120"] }); const unrestricted = claudeTool({ allowed_callers: ["direct"] }); + const incoming = { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }; - test("the OpenAI Chat wire refuses a caller-restricted declaration", () => { - const adapter = createOpenAIChatAdapter({ - adapter: "openai-chat", - baseUrl: "https://gateway.example.internal/v1", - apiKey: "k", - }); - expect(() => adapter.buildRequest(parsedFromClaude(restricted))) - .toThrow(/cannot express tools\[\]\.allowed_callers/); + // The refusal is default-deny at the single guard every registered adapter passes through, so + // a wire that never learned about the field cannot quietly rebuild the declaration without it. + test.each([ + ["openai-chat", { adapter: "openai-chat", baseUrl: "https://gateway.example.internal/v1", apiKey: "k" }], + ["google", { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "key" }], + ["cursor", { adapter: "cursor", baseUrl: "https://api2.cursor.sh", apiKey: "k" }], + ["devin", { adapter: "devin", baseUrl: "https://api.devin.ai", apiKey: "k" }], + ["ollama-native", { adapter: "ollama-native", baseUrl: "http://127.0.0.1:11434", keyOptional: true }], + ])("the %s wire refuses a caller-restricted declaration", async (_name, config) => { + const adapter = createRegisteredAdapter(config as unknown as OcxProviderConfig); + await expect(Promise.resolve().then(() => adapter.buildRequest(parsedFromClaude(restricted), incoming))) + .rejects.toThrow(/cannot express tools\[\]\.allowed_callers/); }); - test("the Gemini wire refuses a caller-restricted declaration", async () => { - const adapter = createGoogleAdapter({ - adapter: "google", - baseUrl: "https://generativelanguage.googleapis.com", - apiKey: "key", - } as unknown as OcxProviderConfig); - await expect(adapter.buildRequest(parsedFromClaude(restricted))) - .rejects.toThrow(/cannot express tools\[\]\.allowed_callers/); + test("the Anthropic wire is the one that carries it", async () => { + const adapter = createRegisteredAdapter(anthropicProvider); + const { body } = await adapter.buildRequest(parsedFromClaude(restricted), incoming); + const sent = JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as { + tools: Array>; + }; + expect(sent.tools[0]!.allowed_callers).toEqual(["code_execution_20260120"]); }); test('the unrestricted ["direct"] default is not treated as a restriction', () => { - const adapter = createOpenAIChatAdapter({ + const adapter = createRegisteredAdapter({ adapter: "openai-chat", baseUrl: "https://gateway.example.internal/v1", apiKey: "k", - }); - const built = JSON.parse(adapter.buildRequest(parsedFromClaude(unrestricted)).body) as { + } as unknown as OcxProviderConfig); + const built = JSON.parse((adapter.buildRequest(parsedFromClaude(unrestricted), incoming) as { body: string }).body) as { tools: Array<{ function: { name: string } }>; }; expect(built.tools.map(tool => tool.function.name)).toEqual(["tool_a"]); diff --git a/tests/responses/chat-tool-choice-allowed-tools.test.ts b/tests/responses/chat-tool-choice-allowed-tools.test.ts index 8ef7c26c6d6..181aaa325b0 100644 --- a/tests/responses/chat-tool-choice-allowed-tools.test.ts +++ b/tests/responses/chat-tool-choice-allowed-tools.test.ts @@ -101,6 +101,13 @@ describe("chat tool_choice allowed_tools reaches the outbound request", () => { }))).toThrow(ChatCompletionsRequestError); }); + test("a selector kind nobody can evaluate is refused", () => { + expect(() => chatCompletionsToResponsesBody(chatBody({ + type: "allowed_tools", + allowed_tools: { mode: "required", tools: [{ type: "mcp", name: "tool_a" }] }, + }))).toThrow(/unsupported tool_choice.allowed_tools.tools entry type/); + }); + test("the existing named and string choices are unchanged", () => { expect(chatCompletionsToResponsesBody(chatBody("required")).tool_choice).toBe("required"); expect(chatCompletionsToResponsesBody(chatBody({ type: "function", function: { name: "tool_a" } })).tool_choice) From 8cf66e7d7219c24a08b1d3e4294a2ebd18fb8263 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 15:02:47 +0900 Subject: [PATCH 07/11] test(document): derive the attachment marker from its source constant A restated literal is the union-defect class AGENTS.md records: the next change to the marker breaks a test for the wording rather than for the contract. Every assertion about it now reads inlineDocumentMarker, and the data URL spelling comes from inlineDocumentDataUrl. --- .../claude-integration/claude-inbound.test.ts | 7 +++-- .../claude-source-envelope.test.ts | 3 +- .../chat-inline-document-bytes.test.ts | 28 ++++++++++++++++--- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/tests/claude-integration/claude-inbound.test.ts b/tests/claude-integration/claude-inbound.test.ts index 3db1a4a6275..5631ec1271a 100644 --- a/tests/claude-integration/claude-inbound.test.ts +++ b/tests/claude-integration/claude-inbound.test.ts @@ -4,6 +4,7 @@ import { AnthropicRequestError as LeafAnthropicRequestError } from "../../src/cl import { repoPath } from "../helpers/repo-root"; import { AnthropicRequestError, anthropicToResponsesBody, anthropicToResponsesTranslation, effortForThinkingBudget, extractOcxEffortDirective, resolveInboundModel } from "../../src/claude/inbound"; import { parseRequest } from "../../src/responses/parser"; +import { inlineDocumentMarker } from "../../src/responses/inline-document"; import { responsesRequestSchema } from "../../src/responses/schema"; import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; @@ -180,7 +181,7 @@ describe("claude inbound translation", () => { { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content }] }, ], }); - const marker = [{ type: "input_text", text: "[document: report.pdf]" }]; + const marker = [{ type: "input_text", text: inlineDocumentMarker("report.pdf") }]; expect(body.input).toEqual(carrier === "user" ? [{ type: "message", role: "user", content: marker }] : [ @@ -437,11 +438,11 @@ describe("claude inbound translation", () => { }) as any; expect(body.input[1].output).toEqual([ { type: "input_text", text: "3 pages" }, - { type: "input_text", text: "[document: report.pdf]" }, + { type: "input_text", text: inlineDocumentMarker("report.pdf") }, ]); // An untitled document still leaves a marker rather than the empty output that // read as "the tool returned nothing". - expect(body.input[3].output).toEqual([{ type: "input_text", text: "[document]" }]); + expect(body.input[3].output).toEqual([{ type: "input_text", text: inlineDocumentMarker(undefined) }]); expect(() => parseRequest(body)).not.toThrow(); }); diff --git a/tests/claude-integration/claude-source-envelope.test.ts b/tests/claude-integration/claude-source-envelope.test.ts index a78c9f1a152..a08b8f5e826 100644 --- a/tests/claude-integration/claude-source-envelope.test.ts +++ b/tests/claude-integration/claude-source-envelope.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { anthropicToResponsesBody } from "../../src/claude/inbound"; +import { inlineDocumentMarker } from "../../src/responses/inline-document"; describe("Claude source envelope boundaries", () => { test("nested tool results retain only bounded structured content", () => { @@ -17,7 +18,7 @@ describe("Claude source envelope boundaries", () => { expect(body.input.map((item: any) => item.type)).toEqual(["function_call", "function_call_output"]); expect(body.input[1].output).toEqual([ { type: "input_text", text: "ok" }, - { type: "input_text", text: "[document: report]" }, + { type: "input_text", text: inlineDocumentMarker("report") }, ]); expect(JSON.stringify(body)).not.toContain("secret-payload"); }); diff --git a/tests/responses/chat-inline-document-bytes.test.ts b/tests/responses/chat-inline-document-bytes.test.ts index 970f5d9891e..4b90bf35fdb 100644 --- a/tests/responses/chat-inline-document-bytes.test.ts +++ b/tests/responses/chat-inline-document-bytes.test.ts @@ -4,6 +4,7 @@ import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; import { chatCompletionsToResponsesBody, ChatCompletionsRequestError } from "../../src/chat/inbound"; import { anthropicToResponsesBody } from "../../src/claude/inbound"; import { parseRequest } from "../../src/responses/parser"; +import { inlineDocumentDataUrl, inlineDocumentMarker } from "../../src/responses/inline-document"; import type { OcxParsedRequest, OcxProviderConfig } from "../../src/types"; /** @@ -14,7 +15,14 @@ import type { OcxParsedRequest, OcxProviderConfig } from "../../src/types"; */ const PDF_BYTES = "JVBERi0xLjQK"; -const PDF_DATA_URL = `data:application/pdf;base64,${PDF_BYTES}`; +// Derived, not restated: the wire spelling is the module's to define, and a test that repeats it +// fails for the wrong reason the next time it changes. +const PDF_DATA_URL = inlineDocumentDataUrl({ + type: "document", + text: "", + mediaType: "application/pdf", + data: PDF_BYTES, +}); const chatProvider: OcxProviderConfig = { adapter: "openai-chat", @@ -63,14 +71,26 @@ describe("inline document bytes survive the inbound parse", () => { file: { filename: "doc.pdf", file_data: PDF_DATA_URL }, }))); expect(content).toEqual([ - { type: "document", text: "[document: doc.pdf]", mediaType: "application/pdf", data: PDF_BYTES, filename: "doc.pdf" }, + { + type: "document", + text: inlineDocumentMarker("doc.pdf"), + mediaType: "application/pdf", + data: PDF_BYTES, + filename: "doc.pdf", + }, ]); }); test("an Anthropic base64 document keeps its bytes and its title", () => { const content = parsedContent(anthropicToResponsesBody(claudeRequest(DOCUMENT_BLOCK))); expect(content).toEqual([ - { type: "document", text: "[document: spec]", mediaType: "application/pdf", data: PDF_BYTES, filename: "spec" }, + { + type: "document", + text: inlineDocumentMarker("spec"), + mediaType: "application/pdf", + data: PDF_BYTES, + filename: "spec", + }, ]); }); @@ -80,7 +100,7 @@ describe("inline document bytes survive the inbound parse", () => { title: "remote", source: { type: "url", url: "https://example.com/doc.pdf" }, }))); - expect(content).toBe("[document: remote]"); + expect(content).toBe(inlineDocumentMarker("remote")); }); test("a reference with no payload is still refused rather than answered", () => { From a915b9f33dbdfc34ce9584644be1911ce823f449 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 15:04:11 +0900 Subject: [PATCH 08/11] fix(adapters): type the document scan against OcxMessage and keep a developer document's role Two defects from a final adversarial pass over the branch. The document scan took content shaped as OcxContentPart[], but context.messages is OcxMessage[] and an assistant turn carries OcxAssistantContentPart[], which is not assignable to the user-content union. It now takes OcxMessage and reads the discriminant structurally, which is all it ever needed. A developer message carrying a document reached the structured-content branch and was emitted as role user, undoing the role preservation the same adapter had just established. A developer message with images keeps the user-compatible shape it has always had on this wire; a document has no such precedent and keeps its role. --- src/adapters/declaration-carrier.ts | 12 +++++++--- src/adapters/openai-chat/messages.ts | 8 ++++++- .../chat-inline-document-bytes.test.ts | 22 +++++++++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/adapters/declaration-carrier.ts b/src/adapters/declaration-carrier.ts index 0720405f169..a1f99739c97 100644 --- a/src/adapters/declaration-carrier.ts +++ b/src/adapters/declaration-carrier.ts @@ -1,6 +1,6 @@ // Type-only: erased at compile time, so this does not create an import cycle with the registry. import type { AdapterWire } from "./registry"; -import type { OcxContentPart, OcxParsedRequest } from "../types"; +import type { OcxMessage, OcxParsedRequest } from "../types"; import { toolRestrictsCallers } from "../types"; /** @@ -34,6 +34,12 @@ export function unrepresentableDeclaration(parsed: OcxParsedRequest, wire: Adapt return undefined; } -function carriesDocument(message: { content: string | readonly OcxContentPart[] }): boolean { - return typeof message.content !== "string" && message.content.some(part => part.type === "document"); +/** + * Typed structurally rather than as `OcxContentPart[]`: an assistant turn's content is + * `OcxAssistantContentPart[]`, which carries thinking and tool-call members and is not + * assignable to the user-content union. Only the discriminant is read here. + */ +function carriesDocument(message: OcxMessage): boolean { + const content: unknown = message.content; + return Array.isArray(content) && (content as ReadonlyArray<{ type: string }>).some(part => part.type === "document"); } diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts index 16952b959d2..c30072e1ee6 100644 --- a/src/adapters/openai-chat/messages.ts +++ b/src/adapters/openai-chat/messages.ts @@ -204,7 +204,13 @@ export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProv if (p.type === "video") return { type: "text", text: VIDEO_UNSUPPORTED_MARKER }; return { type: "text", text: (p as OcxTextContent).text }; }); - chatMsg = { role: "user", content: chatParts }; + // A developer message with images keeps the user-compatible shape it has always had on + // this wire. One carrying only a document has no such precedent, and demoting it would + // undo the role this adapter just finished preserving. + chatMsg = { + role: msg.role === "developer" && !hasImages ? developerWireRole : "user", + content: chatParts, + }; } if (pendingToolCalls.length > 0) deferredBarrierMessages.push(chatMsg); else out.push(chatMsg); diff --git a/tests/responses/chat-inline-document-bytes.test.ts b/tests/responses/chat-inline-document-bytes.test.ts index 4b90bf35fdb..dd88fa05788 100644 --- a/tests/responses/chat-inline-document-bytes.test.ts +++ b/tests/responses/chat-inline-document-bytes.test.ts @@ -161,4 +161,26 @@ describe("inline document bytes reach a wire that can hold them", () => { { type: "file", file: { file_data: PDF_DATA_URL, filename: "spec" } }, ]); }); + + test("a developer turn carrying a document keeps its role", () => { + const parsed = { + modelId: "local-model", + context: { + messages: [{ + role: "developer", + content: [{ type: "document", text: inlineDocumentMarker("spec"), mediaType: "application/pdf", data: PDF_BYTES, filename: "spec" }], + timestamp: 0, + }], + }, + stream: false, + options: {}, + } as unknown as OcxParsedRequest; + const outbound = JSON.parse(createOpenAIChatAdapter(chatProvider).buildRequest(parsed).body) as { + messages: Array<{ role: string; content: unknown }>; + }; + expect(outbound.messages).toEqual([{ + role: "developer", + content: [{ type: "file", file: { file_data: PDF_DATA_URL, filename: "spec" } }], + }]); + }); }); From 7d0fd20865fd009166b8b162de353134e10e2a5a Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 15:04:17 +0900 Subject: [PATCH 09/11] docs(devlog): record lane A meaning preservation What each of the four contracts restores, the review findings that changed the shape of the fix, the union-defect check run before push, and the one gap left open. --- .../010_lane_a.md | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 devlog/_plan/260920_meaning_preservation_batch/010_lane_a.md diff --git a/devlog/_plan/260920_meaning_preservation_batch/010_lane_a.md b/devlog/_plan/260920_meaning_preservation_batch/010_lane_a.md new file mode 100644 index 00000000000..a0892345d86 --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/010_lane_a.md @@ -0,0 +1,100 @@ +# Lane A — meaning preservation on the request path + +Status: OPEN. Branch `codex/260920-lane-a-meaning-preservation`, cut from `origin/dev` +`0613aaec17`. One branch, five ordered commits, one pull request against `dev`. + +## What each commit restores + +### #5211 — tool choice policy on the Chat Completions path + +Two constraints reached the parser and were dropped on the way out, both under a normal 200. + +A `tool_choice` of type `allowed_tools` is a record, is not `type: "function"`, and carries no +`function` member, so it fell past every branch of `toolChoiceToResponses` and `body.tool_choice` +was never assigned. Chat nests the subset under `allowed_tools` and names each entry under a +member keyed by its own type; the Responses shape `mapToolChoice` reads carries `mode` and +`tools` on the choice itself with a flat `name`. Both levels are now flattened. An entry that +cannot be named is refused rather than skipped, because skipping one widens the subset. + +`parallel_tool_calls` had three provider states and two branches, in two places. The unset state +is the default for every provider that never configured the knob, and it dropped the caller's own +explicit `false` — on the translated path and, from a second copy of the same branch, on the +native Chat passthrough. The decision now lives in `src/adapters/openai-chat/parallel-tool-calls.ts`, +which both builders read. An explicit `true` still omits the key, matching the configured opt-out. + +The passthrough half was found by adversarial review, not by the original report. + +### #5210 — tool declaration fields on the outbound adapters + +`strict` was kept deliberately by the Messages inbound and forwarded by the OpenAI Chat adapter, +and dropped by Anthropic — the target that defines it. It is now emitted when it is explicitly +`true`. An unstated `strict` stays absent, because the inbound records it as `false` and a +`false` on the wire cannot be told apart from silence. + +`allowed_callers` had no carrier at all. It now rides `OcxTool.allowedCallers` from the Messages +inbound, through the Responses tool schema — where an undeclared key is stripped, which is why it +never reached `buildTools` — to the Anthropic wire. The OpenAI Chat and Gemini builders have no +counterpart and refuse with a 400 rather than rebuild the declaration without the fence. The +unrestricted `["direct"]` default is not a restriction. + +Gemini's `functionCallingConfig.mode: "VALIDATED"` was plumbed to the wire compiler but only +reachable by matching a model name. A caller-declared strict tool now selects it in place of the +absent-choice default; `NONE`, `ANY` and a forced-name choice are never overwritten. + +### #5213 — developer message position, then role + +Delivered as two commits because they are two acceptance conditions. + +Position carries #5237 by Yum-wu with a `Co-authored-by` trailer. The upstream branch had the +right idea and a broken patch (a stray `];` and an assertion that put the deferred reminder +before the tool result), so the change was reimplemented and the attribution kept. One +destination already had chronological placement, keyed to a model id and a registry entry; that +is a property of prompt-prefix caching rather than of that destination, so it is now universal +and the model/registry test is gone. + +Role is separate. `developer` is part of the Chat Completions role set and is now forwarded as +sent. A destination that genuinely rejects it sets `foldDeveloperRoleToSystem`, which converts +the role in place and never moves the message, so the placement contract holds on both paths. + +### #5212 — inline document bytes + +Both inbound parsers reduced an attachment to its name before any adapter ran. +`OcxContentPart` gains a document member carrying the media type and the base64 payload; +Anthropic emits the document block, OpenAI Chat the file part, Gemini `inline_data`. + +Widening that union is the hazard, so the part also carries the marker every text-only consumer +already falls back to, which keeps a wire with no document representation byte-identical to +before. Six consumers needed more than the fallback: `ollama-native` and the Cursor tool-result +decoder would have read a nonexistent `imageUrl`, and the Kiro, Devin, Cursor and coding-agent +text serializers would have produced an empty turn. All were found by adversarial review. + +The untranslated-media refusal is narrowed only where a converter actually builds the part: +user content on the Chat projection, user and developer messages on the Responses one. A file in +a tool output, a system message or an assistant message is still refused. The scanner and the +decoder share one predicate, so a request cannot be exempted in one and reduced to a marker in +the other. + +## Known remaining gap + +Tool-result documents keep the #939 marker. The Responses tool-output vocabulary has no file +block and every adapter's tool-result path flattens to text, so carrying bytes there is a +separate change rather than a half-done one. + +## Union-defect check before push + +- File-size ratchet: `src/adapters/openai-chat.ts` was the only capped file in the touch set + (cap 822). The `parallel_tool_calls` decision moved to a sibling module and the file is 811 + lines. No cap was raised. +- `PROVIDER_CONFIG_FIELD_POLICY` in `src/server/auth-cors.ts` is + `satisfies Record`, so `foldDeveloperRoleToSystem` is classified + there and in `providerConfigSchema`. +- Every new test file is registered in both `scripts/test-layout/layout.json` and + `tests/fixtures/test-layout-expected.json`, which the layout guard asserts are equal. +- No count is restated: the provider reference tables gained a row rather than a number. + +## Verification + +Static source review plus exact-head hosted CI. Local suites, individual tests, typecheck, +build, install and live `ocx` execution were NOT RUN, per the lane constraints. Adversarial +source review ran on every commit and produced the passthrough, Kiro/Devin/Cursor/coding-agent, +role-aware-refusal and base64-predicate findings listed above. From b2a30fd8215ee8be97fc9630975f77ffbc6a54a4 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 15:14:12 +0900 Subject: [PATCH 10/11] docs(structure): repoint the instruction-ordering links at the renamed heading Renaming the section from the OpenCode Go exception to the universal contract left five documents linking a heading anchor that no longer exists, which is what the SSOT gate is for. The link text now describes the contract rather than the destination it used to be scoped to. --- structure/adapters/registry.md | 5 +++-- structure/data-planes/inbound-compat.md | 6 +++--- structure/providers/cursor.md | 2 +- structure/runtime.md | 7 ++++--- structure/transports/inventory.md | 5 +++-- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index fcf65bf5c7c..a21be43a466 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -16,8 +16,9 @@ Shared parsing and streaming follow the [request-copy](../transports/byte-accoun Runtime adapter construction has one authority: `src/adapters/registry.ts`. -The OpenCode Go [chronological instruction exception](../providers/chat-compat.md#opencode-go-chronological-instructions) -uses the provider registry's destination identity inside the Chat adapter; it adds no adapter factory. +[Chronological instruction ordering](../providers/chat-compat.md#chronological-in-conversation-instructions) +is now uniform across destinations, so the Chat adapter no longer consults the provider registry's +destination identity for it; it adds no adapter factory. `src/server/adapter-resolve.ts` may resolve a provider/model onto an adapter id, but it does not maintain a second adapter factory inventory. The selected persisted/configured adapter id remains an untrusted string until the registry lookup succeeds. Unknown ids fail with the existing `Unknown adapter: ` error instead of widening configuration types around a closed compile-time union. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 21b23f28406..90dacb24366 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -51,9 +51,9 @@ separate. Coverage lives in `tests/server/audio-client.test.ts`, `tests/server/audio-dictation.test.ts` and `tests/server/live-call-bindings.test.ts`. Translated Claude timeline reminders use the Chat adapter's -[OpenCode Go instruction ordering](../providers/chat-compat.md#opencode-go-chronological-instructions) -on its exact supported route. This is separate from trailing-notice stabilization -and from native Chat message passthrough. +[chronological instruction ordering](../providers/chat-compat.md#chronological-in-conversation-instructions) +on every destination. This is separate from trailing-notice stabilization and from +native Chat message passthrough. Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](../transports/responses.md#passthrough-sse-stream-shapes-314). diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 3f047315203..7252130843a 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -8,7 +8,7 @@ namespace handling retain their provider contract; the bounded native scope live [the shared catalog](../catalog.md#shared-catalog). Cursor's direct adapter does not enter the OpenAI Chat serializer's -[OpenCode Go instruction ordering](chat-compat.md#opencode-go-chronological-instructions). +[chronological instruction ordering](chat-compat.md#chronological-in-conversation-instructions). Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. diff --git a/structure/runtime.md b/structure/runtime.md index 5c19599c4f5..9d7316cfc4e 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -30,9 +30,10 @@ OAuth refresh coordination follows the [refresh-lock identity contract](catalog. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Cursor's localized native-shell names follow the [routing-commentary guard contract](providers/cursor.md#cursor-native-exec). -Chat request serialization owns the destination-scoped -[OpenCode Go instruction ordering](providers/chat-compat.md#opencode-go-chronological-instructions); -it requires no runtime lifecycle change or new configuration option. +Chat request serialization owns +[chronological instruction ordering](providers/chat-compat.md#chronological-in-conversation-instructions) +and the developer wire role; it requires no runtime lifecycle change, and its one +configuration option is a per-provider role opt-out. Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index e81ec55dc57..ddfe0a022c2 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -12,8 +12,9 @@ The existing Responses transport is divided by responsibility in the The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Cursor's localized native-shell names follow the [routing-commentary guard contract](../providers/cursor.md#cursor-native-exec). -The Chat adapter's [OpenCode Go instruction ordering](../providers/chat-compat.md#opencode-go-chronological-instructions) -changes translated message placement only; endpoint selection and transport stay with their existing owners. +The Chat adapter's [chronological instruction ordering](../providers/chat-compat.md#chronological-in-conversation-instructions) +changes translated message placement and the developer wire role only; endpoint selection and transport +stay with their existing owners. Shared parsing and streaming follow the [request-copy](byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](responses.md#passthrough-sse-stream-shapes-314). From 6a2316ece392b3d92dc73b585fe63dafced7d2bd Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 15:20:01 +0900 Subject: [PATCH 11/11] test(anthropic): await the registered adapter build createRegisteredAdapter wraps openai-chat in withClinePassDeepSeekV4ToolReplayCompatibility, whose buildRequest is async, so reading .body off the returned promise parsed undefined. The refusal cases in the same file already tolerated both shapes. --- .../anthropic-tool-declaration-constraints.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/adapters/anthropic/anthropic-tool-declaration-constraints.test.ts b/tests/adapters/anthropic/anthropic-tool-declaration-constraints.test.ts index 85a299b0305..841fe1fa933 100644 --- a/tests/adapters/anthropic/anthropic-tool-declaration-constraints.test.ts +++ b/tests/adapters/anthropic/anthropic-tool-declaration-constraints.test.ts @@ -100,13 +100,15 @@ describe("wires without an allowed_callers counterpart refuse rather than widen" expect(sent.tools[0]!.allowed_callers).toEqual(["code_execution_20260120"]); }); - test('the unrestricted ["direct"] default is not treated as a restriction', () => { + test('the unrestricted ["direct"] default is not treated as a restriction', async () => { const adapter = createRegisteredAdapter({ adapter: "openai-chat", baseUrl: "https://gateway.example.internal/v1", apiKey: "k", } as unknown as OcxProviderConfig); - const built = JSON.parse((adapter.buildRequest(parsedFromClaude(unrestricted), incoming) as { body: string }).body) as { + // Registered adapters may wrap buildRequest in a promise; await rather than assume a shape. + const { body } = await adapter.buildRequest(parsedFromClaude(unrestricted), incoming); + const built = JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as { tools: Array<{ function: { name: string } }>; }; expect(built.tools.map(tool => tool.function.name)).toEqual(["tool_a"]);