From c4cb8b35b311c8ab1c52690654be10847248f479 Mon Sep 17 00:00:00 2001 From: Lami <154405627+Lqm1@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:27:57 +0900 Subject: [PATCH 1/8] fix(anthropic): forward structured output schemas --- src/adapters/anthropic-output-schema.ts | 119 ++++++++++++++++++++++++ src/adapters/anthropic.ts | 15 +++ tests/anthropic-reasoning.test.ts | 49 ++++++++++ 3 files changed, 183 insertions(+) create mode 100644 src/adapters/anthropic-output-schema.ts diff --git a/src/adapters/anthropic-output-schema.ts b/src/adapters/anthropic-output-schema.ts new file mode 100644 index 000000000..f84a026d9 --- /dev/null +++ b/src/adapters/anthropic-output-schema.ts @@ -0,0 +1,119 @@ +const SUPPORTED_STRING_FORMATS = new Set([ + "date-time", + "time", + "date", + "duration", + "email", + "hostname", + "uri", + "ipv4", + "ipv6", + "uuid", +]); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function take(schema: Record, key: string): unknown { + const value = schema[key]; + delete schema[key]; + return value; +} + +function normalizeSubschema(value: unknown): unknown { + return isRecord(value) ? normalizeSchema(value) : value; +} + +function normalizeSchema(schema: Record): Record { + const normalized: Record = {}; + + const ref = take(schema, "$ref"); + if (ref !== undefined) { + return { $ref: ref }; + } + + const defs = take(schema, "$defs"); + if (isRecord(defs)) { + normalized.$defs = Object.fromEntries( + Object.entries(defs).map(([name, definition]) => [name, normalizeSubschema(definition)]), + ); + } + + const type = take(schema, "type"); + const anyOf = take(schema, "anyOf"); + const oneOf = take(schema, "oneOf"); + const allOf = take(schema, "allOf"); + + if (Array.isArray(anyOf)) { + normalized.anyOf = anyOf.map(normalizeSubschema); + } else if (Array.isArray(oneOf)) { + normalized.anyOf = oneOf.map(normalizeSubschema); + } else if (Array.isArray(allOf)) { + normalized.allOf = allOf.map(normalizeSubschema); + } else if (type !== undefined) { + normalized.type = type; + } + + const description = take(schema, "description"); + if (description !== undefined) { + normalized.description = description; + } + + const title = take(schema, "title"); + if (title !== undefined) { + normalized.title = title; + } + + if (type === "object") { + const properties = take(schema, "properties"); + normalized.properties = isRecord(properties) + ? Object.fromEntries( + Object.entries(properties).map(([name, property]) => [name, normalizeSubschema(property)]), + ) + : {}; + take(schema, "additionalProperties"); + normalized.additionalProperties = false; + + const required = take(schema, "required"); + if (required !== undefined) { + normalized.required = required; + } + } else if (type === "string") { + const format = take(schema, "format"); + if (typeof format === "string" && SUPPORTED_STRING_FORMATS.has(format)) { + normalized.format = format; + } else if (format !== undefined) { + schema.format = format; + } + } else if (type === "array") { + const items = take(schema, "items"); + if (items !== undefined) { + normalized.items = normalizeSubschema(items); + } + + const minItems = take(schema, "minItems"); + if (minItems === 0 || minItems === 1) { + normalized.minItems = minItems; + } else if (minItems !== undefined) { + schema.minItems = minItems; + } + } + + const unsupported = Object.entries(schema); + if (unsupported.length > 0) { + const existingDescription = + typeof normalized.description === "string" ? `${normalized.description}\n\n` : ""; + normalized.description = `${existingDescription}{${unsupported + .map(([key, value]) => `${key}: ${JSON.stringify(value)}`) + .join(", ")}}`; + } + + return normalized; +} + +export function normalizeAnthropicOutputSchema( + schema: Record, +): Record { + return normalizeSchema(structuredClone(schema)); +} diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 4a6377850..85b9b5833 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -18,6 +18,7 @@ import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM_INSTRUCTION, applyClaudeToolPr import { parseDataUrl } from "./image"; import { enforceAnthropicImageLimits } from "./anthropic-image-guard"; import { normalizeAnthropicImages } from "./anthropic-image-normalize"; +import { normalizeAnthropicOutputSchema } from "./anthropic-output-schema"; import { identifyRoutedModel } from "./identity"; import { redactSecretString } from "../lib/redact"; import { CLAUDE_CODE_HEADERS, claudeCodeSessionId } from "./client-fingerprint"; @@ -898,6 +899,20 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti delete body.top_p; } + const textFormat = parsed.options.textFormat; + if (textFormat?.type === "json_schema" && textFormat.schema) { + const outputConfig = body.output_config; + body.output_config = { + ...(outputConfig && typeof outputConfig === "object" && !Array.isArray(outputConfig) + ? outputConfig + : {}), + format: { + type: "json_schema", + schema: normalizeAnthropicOutputSchema(textFormat.schema), + }, + }; + } + if (parsed.options.toolChoice && (tools || parsed.options.toolChoice === "none")) { const tc = parsed.options.toolChoice; if (tc === "auto") body.tool_choice = { type: "auto" }; diff --git a/tests/anthropic-reasoning.test.ts b/tests/anthropic-reasoning.test.ts index 03ea3809d..99fb460c7 100644 --- a/tests/anthropic-reasoning.test.ts +++ b/tests/anthropic-reasoning.test.ts @@ -69,6 +69,55 @@ describe("anthropic extended-thinking gate", () => { expect(b.output_config).toEqual({ effort: "low" }); }); + test("forwards Responses JSON Schema output format to Anthropic", async () => { + const schema = { + type: "object", + properties: { score: { type: "integer", minimum: 1, maximum: 10 } }, + required: ["score"], + additionalProperties: false, + }; + const b = await bodyOf(parseRequest({ + model: "claude-sonnet-5", + input: [{ role: "user", content: [{ type: "input_text", text: "score this" }] }], + text: { format: { type: "json_schema", name: "score", schema, strict: true } }, + })); + + expect(b.output_config).toEqual({ + format: { + type: "json_schema", + schema: { + ...schema, + properties: { + score: { + type: "integer", + description: "{minimum: 1, maximum: 10}", + }, + }, + }, + }, + }); + }); + + test("merges JSON Schema output format with adaptive thinking effort", async () => { + const schema = { + type: "object", + properties: { summary: { type: "string" } }, + required: ["summary"], + additionalProperties: false, + }; + const b = await bodyOf(parseRequest({ + model: "claude-sonnet-5", + input: [{ role: "user", content: [{ type: "input_text", text: "summarize this" }] }], + reasoning: { effort: "high" }, + text: { format: { type: "json_schema", name: "summary", schema, strict: true } }, + })); + + expect(b.output_config).toEqual({ + effort: "high", + format: { type: "json_schema", schema }, + }); + }); + test("adaptive-thinking model resizes max_tokens for high effort (issue #246)", async () => { const b = await bodyOf(parsed("max", {}, "claude-fable-5")); // Exact regression: effort=max budget is 32000; adaptive ceiling adds OUTPUT_HEADROOM (8192) From ba04fb07a9f7d6868ccc98cf66d8ec0e8d2ca9a4 Mon Sep 17 00:00:00 2001 From: Lami <154405627+Lqm1@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:38:47 +0900 Subject: [PATCH 2/8] test(anthropic): align schema transform with SDK --- src/adapters/anthropic-output-schema.ts | 7 ++++++- tests/anthropic-reasoning.test.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/adapters/anthropic-output-schema.ts b/src/adapters/anthropic-output-schema.ts index f84a026d9..9d7e097e0 100644 --- a/src/adapters/anthropic-output-schema.ts +++ b/src/adapters/anthropic-output-schema.ts @@ -1,3 +1,5 @@ +// Mirrors Anthropic SDK's transformJSONSchema semantics with local unknown narrowing: +// https://github.com/anthropics/anthropic-sdk-typescript/blob/main/src/lib/transform-json-schema.ts const SUPPORTED_STRING_FORMATS = new Set([ "date-time", "time", @@ -51,7 +53,10 @@ function normalizeSchema(schema: Record): Record { }); }); + test("rejects a JSON Schema without a type or composition keyword", async () => { + const request = parseRequest({ + model: "claude-sonnet-5", + input: [{ role: "user", content: [{ type: "input_text", text: "summarize this" }] }], + text: { format: { type: "json_schema", name: "summary", schema: { description: "summary" } } }, + }); + + await expect(bodyOf(request)).rejects.toThrow( + "JSON schema must have a type defined if anyOf/oneOf/allOf are not used", + ); + }); + test("adaptive-thinking model resizes max_tokens for high effort (issue #246)", async () => { const b = await bodyOf(parsed("max", {}, "claude-fable-5")); // Exact regression: effort=max budget is 32000; adaptive ceiling adds OUTPUT_HEADROOM (8192) From f74b65bcae76e6822a7ceb5ec6fcdcf4f8bce1de Mon Sep 17 00:00:00 2001 From: Lami <154405627+Lqm1@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:45:56 +0900 Subject: [PATCH 3/8] fix(anthropic): preserve root schema definitions --- src/adapters/anthropic-output-schema.ts | 13 +++++++------ tests/anthropic-reasoning.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/adapters/anthropic-output-schema.ts b/src/adapters/anthropic-output-schema.ts index 9d7e097e0..6eba2d523 100644 --- a/src/adapters/anthropic-output-schema.ts +++ b/src/adapters/anthropic-output-schema.ts @@ -1,4 +1,4 @@ -// Mirrors Anthropic SDK's transformJSONSchema semantics with local unknown narrowing: +// Based on Anthropic SDK's transformJSONSchema, preserving root $defs required by root $ref: // https://github.com/anthropics/anthropic-sdk-typescript/blob/main/src/lib/transform-json-schema.ts const SUPPORTED_STRING_FORMATS = new Set([ "date-time", @@ -30,11 +30,6 @@ function normalizeSubschema(value: unknown): unknown { function normalizeSchema(schema: Record): Record { const normalized: Record = {}; - const ref = take(schema, "$ref"); - if (ref !== undefined) { - return { $ref: ref }; - } - const defs = take(schema, "$defs"); if (isRecord(defs)) { normalized.$defs = Object.fromEntries( @@ -42,6 +37,12 @@ function normalizeSchema(schema: Record): Record { }); }); + test("preserves root definitions used by a root JSON Schema reference", async () => { + const schema = { + $ref: "#/$defs/answer", + $defs: { + answer: { + type: "object", + properties: { ok: { type: "boolean" } }, + required: ["ok"], + additionalProperties: false, + }, + }, + }; + const b = await bodyOf(parseRequest({ + model: "claude-sonnet-5", + input: [{ role: "user", content: [{ type: "input_text", text: "answer this" }] }], + text: { format: { type: "json_schema", name: "answer", schema } }, + })); + + expect(b.output_config).toEqual({ + format: { type: "json_schema", schema }, + }); + }); + test("merges JSON Schema output format with adaptive thinking effort", async () => { const schema = { type: "object", From a65d73f70923b8a94a4e24b9e0ec29b23d8ba44d Mon Sep 17 00:00:00 2001 From: Lami <154405627+Lqm1@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:27:42 +0900 Subject: [PATCH 4/8] docs(anthropic): complete structured output review --- .../src/content/docs/reference/adapters.md | 8 +++++ structure/04_transports-and-sidecars.md | 17 ++++++++++ tests/anthropic-reasoning.test.ts | 31 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 7119ba7d7..9058f5822 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -74,6 +74,14 @@ of the HTTP retry loop. maps reasoning effort to a budget (minimal 1024 … max 32000), then computes a safe `max_tokens` with output headroom, and **drops `temperature`/`top_p`** when thinking is enabled (Anthropic forbids them there). +- **Structured output:** Responses `text.format` and Chat Completions `response_format` requests + with `type: "json_schema"` become Anthropic `output_config.format`. The adapter mirrors the + Anthropic TypeScript SDK's supported JSON Schema subset: unsupported constraints are moved into + `description` as model guidance, `oneOf` becomes `anyOf`, and object schemas receive + `additionalProperties: false`. A root `$ref` retains its adjacent `$defs` so the local reference + remains resolvable. OpenAI envelope fields such as schema `name`, envelope `description`, and + `strict` are not part of the Anthropic wire format. JSON object mode without a schema has no + Anthropic equivalent and is not translated. - Always sends `anthropic-version: 2023-06-01`. Streams `content_block_delta` (`text_delta`, `thinking_delta`, compatible `reasoning_delta`, `input_json_delta`). The SSE decoder preserves event state across fetch chunks and accepts a terminal `message_stop` without a trailing newline. diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index e3b052d72..52613dee0 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -546,6 +546,23 @@ family shared by unrelated upstreams. - 다른 대안 대신 이 방식을 선택한 이유: Global or heuristic rules regress supported providers and make custom gateway names part of the wire contract. - 장점, 단점 및 영향: Compatible siblings retain schema enforcement and explicitly incompatible models avoid the upstream 400; operators must classify each unsupported model they route. +## Anthropic structured-output compatibility + +The Anthropic adapter lowers Responses `text.format` and Chat Completions `response_format` JSON +Schema requests to `output_config.format`. The local transform follows Anthropic's TypeScript SDK +subset so upstream rejects neither OpenAI-only envelope fields nor unsupported schema constraints. +Unsupported constraints remain in `description` as model guidance instead of disappearing. Root +`$defs` stay beside a root `$ref`, intentionally differing from the current SDK transform's early +`$ref` return so local references remain resolvable. + +[Decision Log] +- 목적과 의도: Preserve schema-constrained output when OpenAI-shaped Responses or Chat Completions requests route to Anthropic Messages. +- 기존 구현 및 제약 조건: The parser retained the requested schema, but the Anthropic adapter dropped it; forwarding the OpenAI schema unchanged fails when it includes constraints outside Anthropic's supported subset. +- 검토한 주요 대안: Keep tool-call emulation; forward the raw schema; depend on the full Anthropic SDK; maintain a local compatibility transform based on the SDK. +- 선택한 방식: Emit Anthropic `output_config.format`, mirror the SDK transform locally with strict `unknown` narrowing, move unsupported constraints into descriptions, and preserve root `$defs` before returning a root `$ref`. +- 다른 대안 대신 이 방식을 선택한 이유: Native structured output avoids synthetic tools, raw forwarding produces upstream 400s, and importing the full SDK only for a small wire transform would duplicate the adapter's direct HTTP ownership. +- 장점, 단점 및 영향: Both OpenAI-shaped input surfaces gain native Anthropic schema enforcement and unsupported intent remains visible to the model; the copied subset must track upstream SDK changes, description-carried constraints are guidance rather than hard validation, and the root-reference fix is an intentional divergence to keep definitions reachable. + ## Reasoning display parity (hideThinkingSummary) `hideThinkingSummary` (request reasoning summary absent/"none" — the routed catalog default) is diff --git a/tests/anthropic-reasoning.test.ts b/tests/anthropic-reasoning.test.ts index ab889c273..50d028f2e 100644 --- a/tests/anthropic-reasoning.test.ts +++ b/tests/anthropic-reasoning.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { createAnthropicAdapter as createAnthropicAdapterProduction } from "../src/adapters/anthropic"; +import { chatCompletionsToResponsesBody } from "../src/chat/inbound"; import { parseRequest } from "../src/responses/parser"; import { anthropicToResponsesBody } from "../src/claude/inbound"; import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; @@ -141,6 +142,36 @@ describe("anthropic extended-thinking gate", () => { }); }); + test("translates Chat Completions JSON Schema output to Anthropic", async () => { + const schema = { + type: "object", + properties: { summary: { type: "string" } }, + required: ["summary"], + additionalProperties: false, + }; + const responsesBody = chatCompletionsToResponsesBody({ + model: "claude-sonnet-5", + messages: [{ role: "user", content: "summarize this" }], + reasoning_effort: "high", + response_format: { + type: "json_schema", + json_schema: { + name: "summary", + description: "One summary object.", + schema, + strict: true, + }, + }, + }); + + const b = await bodyOf(parseRequest(responsesBody)); + + expect(b.output_config).toEqual({ + effort: "high", + format: { type: "json_schema", schema }, + }); + }); + test("rejects a JSON Schema without a type or composition keyword", async () => { const request = parseRequest({ model: "claude-sonnet-5", From 730b5867f2f9f1e168f993bb79af0aed0ba48ae0 Mon Sep 17 00:00:00 2001 From: Lami <154405627+Lqm1@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:52:29 +0900 Subject: [PATCH 5/8] fix(anthropic): preserve composition guidance --- .../src/content/docs/reference/adapters.md | 15 +++++----- src/adapters/anthropic-output-schema.ts | 9 ++++-- structure/04_transports-and-sidecars.md | 4 ++- tests/anthropic-reasoning.test.ts | 30 +++++++++++++++++++ 4 files changed, 47 insertions(+), 11 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 9058f5822..a36e4a1c2 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -75,13 +75,14 @@ of the HTTP retry loop. output headroom, and **drops `temperature`/`top_p`** when thinking is enabled (Anthropic forbids them there). - **Structured output:** Responses `text.format` and Chat Completions `response_format` requests - with `type: "json_schema"` become Anthropic `output_config.format`. The adapter mirrors the - Anthropic TypeScript SDK's supported JSON Schema subset: unsupported constraints are moved into - `description` as model guidance, `oneOf` becomes `anyOf`, and object schemas receive - `additionalProperties: false`. A root `$ref` retains its adjacent `$defs` so the local reference - remains resolvable. OpenAI envelope fields such as schema `name`, envelope `description`, and - `strict` are not part of the Anthropic wire format. JSON object mode without a schema has no - Anthropic equivalent and is not translated. + with `type: "json_schema"` become Anthropic `output_config.format`. The format merges into an + existing adaptive-thinking output configuration, preserving a compatible `output_config.effort`. + The adapter mirrors the Anthropic TypeScript SDK's supported JSON Schema subset: unsupported + constraints are moved into `description` as model guidance, `oneOf` becomes `anyOf`, and object + schemas receive `additionalProperties: false`. A root `$ref` retains its adjacent `$defs` so the + local reference remains resolvable. OpenAI envelope fields such as schema `name`, envelope + `description`, and `strict` are not part of the Anthropic wire format. JSON object mode without a + schema has no Anthropic equivalent and is not translated. - Always sends `anthropic-version: 2023-06-01`. Streams `content_block_delta` (`text_delta`, `thinking_delta`, compatible `reasoning_delta`, `input_json_delta`). The SSE decoder preserves event state across fetch chunks and accepts a terminal `message_stop` without a trailing newline. diff --git a/src/adapters/anthropic-output-schema.ts b/src/adapters/anthropic-output-schema.ts index 6eba2d523..d20178cd9 100644 --- a/src/adapters/anthropic-output-schema.ts +++ b/src/adapters/anthropic-output-schema.ts @@ -44,15 +44,18 @@ function normalizeSchema(schema: Record): Record { }); }); + test("preserves unselected composition keywords as model guidance", async () => { + const oneOf = [{ type: "number", minimum: 0 }]; + const allOf = [{ type: "string", minLength: 1 }]; + const b = await bodyOf(parseRequest({ + model: "claude-sonnet-5", + input: [{ role: "user", content: [{ type: "input_text", text: "answer this" }] }], + text: { + format: { + type: "json_schema", + name: "answer", + schema: { + anyOf: [{ type: "boolean" }], + oneOf, + allOf, + }, + }, + }, + })); + + expect(b.output_config).toEqual({ + format: { + type: "json_schema", + schema: { + anyOf: [{ type: "boolean" }], + description: `{oneOf: ${JSON.stringify(oneOf)}, allOf: ${JSON.stringify(allOf)}}`, + }, + }, + }); + }); + test("translates Chat Completions JSON Schema output to Anthropic", async () => { const schema = { type: "object", From a093091a14fb6e6d166a0af9e0813f2c0e9a90b9 Mon Sep 17 00:00:00 2001 From: Lami <154405627+Lqm1@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:02:09 +0900 Subject: [PATCH 6/8] fix(anthropic): preserve Messages output format --- .../src/content/docs/reference/adapters.md | 1 + src/claude/inbound.ts | 9 +++++ structure/04_transports-and-sidecars.md | 2 ++ tests/anthropic-reasoning.test.ts | 35 +++++++++++++++++-- tests/claude-inbound.test.ts | 18 ++++++++++ 5 files changed, 63 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index a36e4a1c2..b36b04d55 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -77,6 +77,7 @@ of the HTTP retry loop. - **Structured output:** Responses `text.format` and Chat Completions `response_format` requests with `type: "json_schema"` become Anthropic `output_config.format`. The format merges into an existing adaptive-thinking output configuration, preserving a compatible `output_config.effort`. + Routed Anthropic Messages requests preserve the same format through stored-OAuth translation. The adapter mirrors the Anthropic TypeScript SDK's supported JSON Schema subset: unsupported constraints are moved into `description` as model guidance, `oneOf` becomes `anyOf`, and object schemas receive `additionalProperties: false`. A root `$ref` retains its adjacent `$defs` so the diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index a4830f6d7..db333ead4 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -69,6 +69,13 @@ export function effortFromOutputConfig(outputConfig: unknown): string | undefine return typeof effort === "string" && OUTPUT_CONFIG_EFFORTS.has(effort) ? effort : undefined; } +function formatFromOutputConfig(outputConfig: unknown): Rec | undefined { + if (!isRec(outputConfig) || !isRec(outputConfig.format)) return undefined; + const format = outputConfig.format; + if (format.type !== "json_schema" || !isRec(format.schema)) return undefined; + return { type: "json_schema", schema: format.schema }; +} + function systemToInstructions(system: unknown): string | undefined { if (typeof system === "string") return system.length > 0 ? system : undefined; if (Array.isArray(system)) { @@ -467,6 +474,8 @@ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCode if (Array.isArray(raw.stop_sequences) && raw.stop_sequences.length > 0) { body.stop = raw.stop_sequences.filter((s): s is string => typeof s === "string"); } + const outputConfigFormat = formatFromOutputConfig(raw.output_config); + if (outputConfigFormat) body.text = { format: outputConfigFormat }; let cacheKeySource: ClaudeCacheKeySource = null; if (isRec(raw.metadata) && typeof raw.metadata.user_id === "string") { body.user = raw.metadata.user_id; diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index e93e1f62b..285419095 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -553,6 +553,8 @@ Schema requests to `output_config.format`. The local transform follows Anthropic subset so upstream rejects neither OpenAI-only envelope fields nor unsupported schema constraints. The adapter merges `format` into an existing adaptive-thinking `output_config` rather than replacing it, so a compatible `output_config.effort` remains alongside the structured-output format. +Routed Anthropic Messages input carries `output_config.format` through internal `text.format`, so +stored-OAuth requests regain the same native format when the Anthropic adapter rebuilds the wire body. Unsupported constraints remain in `description` as model guidance instead of disappearing. Root `$defs` stay beside a root `$ref`, intentionally differing from the current SDK transform's early `$ref` return so local references remain resolvable. diff --git a/tests/anthropic-reasoning.test.ts b/tests/anthropic-reasoning.test.ts index f4f520bb7..ab91ee8f0 100644 --- a/tests/anthropic-reasoning.test.ts +++ b/tests/anthropic-reasoning.test.ts @@ -20,8 +20,8 @@ function parsed(reasoning?: string, extraOpts: Record = {}, mod } as unknown as OcxParsedRequest; } -async function bodyOf(p: OcxParsedRequest): Promise> { - const { body } = await createAnthropicAdapter(provider).buildRequest(p); +async function bodyOf(p: OcxParsedRequest, configuredProvider = provider): Promise> { + const { body } = await createAnthropicAdapter(configuredProvider).buildRequest(p); return JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as Record; } @@ -377,6 +377,37 @@ describe("anthropic extended-thinking gate", () => { }); }); +describe("Anthropic Messages stored-OAuth round trip", () => { + test("Messages structured output survives the stored OAuth round trip", async () => { + const schema = { + type: "object", + properties: { answer: { type: "string" } }, + required: ["answer"], + additionalProperties: false, + }; + const inbound = anthropicToResponsesBody({ + model: "claude-sonnet-5", + max_tokens: 256, + messages: [{ role: "user", content: "Return JSON" }], + thinking: { type: "adaptive" }, + output_config: { + effort: "high", + format: { type: "json_schema", schema }, + }, + }); + + const body = await bodyOf(parseRequest(inbound), { + ...provider, + authMode: "oauth", + }); + + expect(body.output_config).toEqual({ + effort: "high", + format: { type: "json_schema", schema }, + }); + }); +}); + describe("Claude Desktop classifier round trip (#545)", () => { test("thinking:disabled survives inbound translation to the outbound Anthropic body", async () => { // The reporter's exact shape: a permission classifier with a 64-token budget that must diff --git a/tests/claude-inbound.test.ts b/tests/claude-inbound.test.ts index f9f608529..79e549000 100644 --- a/tests/claude-inbound.test.ts +++ b/tests/claude-inbound.test.ts @@ -141,6 +141,24 @@ describe("claude inbound translation", () => { }))).toEqual({ summary: "auto" }); }); + test("structured output maps output_config.format to text.format", () => { + const schema = { + type: "object", + properties: { answer: { type: "string" } }, + required: ["answer"], + additionalProperties: false, + }; + const body = anthropicToResponsesBody({ + model: "claude-sonnet-5", + max_tokens: 256, + messages: [{ role: "user", content: "Return JSON" }], + output_config: { format: { type: "json_schema", schema } }, + }); + + expect(body.text).toEqual({ format: { type: "json_schema", schema } }); + expect(parseRequest(body).options.textFormat).toEqual({ type: "json_schema", schema }); + }); + test("tool_choice any/tool/none", () => { const base = { model: "m", max_tokens: 10, messages: [{ role: "user", content: "hi" }] }; expect((anthropicToResponsesBody({ ...base, tool_choice: { type: "any" } }) as any).tool_choice).toBe("required"); From 08b1dc1d4693c10c4c2059e3d7bf796b642c5f28 Mon Sep 17 00:00:00 2001 From: Lami <154405627+Lqm1@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:06:30 +0900 Subject: [PATCH 7/8] fix(anthropic): validate inbound output schemas --- src/adapters/anthropic-output-schema.ts | 9 +++++++++ src/claude/inbound.ts | 7 ++++++- tests/claude-inbound.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/adapters/anthropic-output-schema.ts b/src/adapters/anthropic-output-schema.ts index d20178cd9..4d2040d5c 100644 --- a/src/adapters/anthropic-output-schema.ts +++ b/src/adapters/anthropic-output-schema.ts @@ -126,3 +126,12 @@ export function normalizeAnthropicOutputSchema( ): Record { return normalizeSchema(structuredClone(schema)); } + +export function isAnthropicOutputSchema(schema: Record): boolean { + try { + normalizeAnthropicOutputSchema(schema); + return true; + } catch { + return false; + } +} diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index db333ead4..d248be4da 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -10,6 +10,7 @@ * - top_k is accepted and silently dropped (no Responses equivalent, CCR parity). */ import type { OcxClaudeCodeConfig } from "../types"; +import { isAnthropicOutputSchema } from "../adapters/anthropic-output-schema"; import { resolveAlias } from "./alias"; import { stripOneMillionMarker } from "./context-windows"; import { resolveDesktop3pAlias } from "./desktop-3p"; @@ -72,7 +73,11 @@ export function effortFromOutputConfig(outputConfig: unknown): string | undefine function formatFromOutputConfig(outputConfig: unknown): Rec | undefined { if (!isRec(outputConfig) || !isRec(outputConfig.format)) return undefined; const format = outputConfig.format; - if (format.type !== "json_schema" || !isRec(format.schema)) return undefined; + if ( + format.type !== "json_schema" + || !isRec(format.schema) + || !isAnthropicOutputSchema(format.schema) + ) return undefined; return { type: "json_schema", schema: format.schema }; } diff --git a/tests/claude-inbound.test.ts b/tests/claude-inbound.test.ts index 79e549000..607262c52 100644 --- a/tests/claude-inbound.test.ts +++ b/tests/claude-inbound.test.ts @@ -159,6 +159,31 @@ describe("claude inbound translation", () => { expect(parseRequest(body).options.textFormat).toEqual({ type: "json_schema", schema }); }); + test("structured output rejects unsupported schemas and preserves root references", () => { + const base = { + model: "claude-sonnet-5", + max_tokens: 256, + messages: [{ role: "user", content: "Return JSON" }], + }; + const invalid = anthropicToResponsesBody({ + ...base, + output_config: { + format: { type: "json_schema", schema: { description: "answer" } }, + }, + }); + const refSchema = { + $defs: { answer: { type: "object", properties: { value: { type: "string" } } } }, + $ref: "#/$defs/answer", + }; + const referenced = anthropicToResponsesBody({ + ...base, + output_config: { format: { type: "json_schema", schema: refSchema } }, + }); + + expect(invalid.text).toBeUndefined(); + expect(referenced.text).toEqual({ format: { type: "json_schema", schema: refSchema } }); + }); + test("tool_choice any/tool/none", () => { const base = { model: "m", max_tokens: 10, messages: [{ role: "user", content: "hi" }] }; expect((anthropicToResponsesBody({ ...base, tool_choice: { type: "any" } }) as any).tool_choice).toBe("required"); From 3e42026fd562bbf6de234eb7944075a460b2ea26 Mon Sep 17 00:00:00 2001 From: Lami <154405627+Lqm1@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:19:37 +0900 Subject: [PATCH 8/8] fix(claude): name inbound output schemas --- src/claude/inbound.ts | 2 +- tests/claude-inbound.test.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index d248be4da..4b72da674 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -78,7 +78,7 @@ function formatFromOutputConfig(outputConfig: unknown): Rec | undefined { || !isRec(format.schema) || !isAnthropicOutputSchema(format.schema) ) return undefined; - return { type: "json_schema", schema: format.schema }; + return { type: "json_schema", name: "response", schema: format.schema }; } function systemToInstructions(system: unknown): string | undefined { diff --git a/tests/claude-inbound.test.ts b/tests/claude-inbound.test.ts index 607262c52..74d120aa1 100644 --- a/tests/claude-inbound.test.ts +++ b/tests/claude-inbound.test.ts @@ -155,8 +155,8 @@ describe("claude inbound translation", () => { output_config: { format: { type: "json_schema", schema } }, }); - expect(body.text).toEqual({ format: { type: "json_schema", schema } }); - expect(parseRequest(body).options.textFormat).toEqual({ type: "json_schema", schema }); + expect(body.text).toEqual({ format: { type: "json_schema", name: "response", schema } }); + expect(parseRequest(body).options.textFormat).toEqual({ type: "json_schema", name: "response", schema }); }); test("structured output rejects unsupported schemas and preserves root references", () => { @@ -181,7 +181,9 @@ describe("claude inbound translation", () => { }); expect(invalid.text).toBeUndefined(); - expect(referenced.text).toEqual({ format: { type: "json_schema", schema: refSchema } }); + expect(referenced.text).toEqual({ + format: { type: "json_schema", name: "response", schema: refSchema }, + }); }); test("tool_choice any/tool/none", () => {