diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 7119ba7d7..b36b04d55 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -74,6 +74,16 @@ 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 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 + 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 new file mode 100644 index 000000000..4d2040d5c --- /dev/null +++ b/src/adapters/anthropic-output-schema.ts @@ -0,0 +1,137 @@ +// 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", + "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 defs = take(schema, "$defs"); + if (isRecord(defs)) { + normalized.$defs = Object.fromEntries( + Object.entries(defs).map(([name, definition]) => [name, normalizeSubschema(definition)]), + ); + } + + const ref = take(schema, "$ref"); + if (ref !== undefined) { + normalized.$ref = ref; + return normalized; + } + + const type = take(schema, "type"); + const anyOf = schema.anyOf; + const oneOf = schema.oneOf; + const allOf = schema.allOf; + + if (Array.isArray(anyOf)) { + take(schema, "anyOf"); + normalized.anyOf = anyOf.map(normalizeSubschema); + } else if (Array.isArray(oneOf)) { + take(schema, "oneOf"); + normalized.anyOf = oneOf.map(normalizeSubschema); + } else if (Array.isArray(allOf)) { + take(schema, "allOf"); + normalized.allOf = allOf.map(normalizeSubschema); + } else { + if (type === undefined) { + throw new Error("JSON schema must have a type defined if anyOf/oneOf/allOf are not used"); + } + 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)); +} + +export function isAnthropicOutputSchema(schema: Record): boolean { + try { + normalizeAnthropicOutputSchema(schema); + return true; + } catch { + return false; + } +} 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/src/claude/inbound.ts b/src/claude/inbound.ts index a4830f6d7..4b72da674 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"; @@ -69,6 +70,17 @@ 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) + || !isAnthropicOutputSchema(format.schema) + ) return undefined; + return { type: "json_schema", name: "response", 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 +479,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 e3b052d72..285419095 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -546,6 +546,27 @@ 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. +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. + +[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. +- 선택한 방식: Merge Anthropic `output_config.format` into compatible adaptive-thinking configuration, 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 03ea3809d..ab91ee8f0 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"; @@ -19,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; } @@ -69,6 +70,150 @@ 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("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", + 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("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", + 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", + 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) @@ -232,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..74d120aa1 100644 --- a/tests/claude-inbound.test.ts +++ b/tests/claude-inbound.test.ts @@ -141,6 +141,51 @@ 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", 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", () => { + 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", name: "response", 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");