Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
137 changes: 137 additions & 0 deletions src/adapters/anthropic-output-schema.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

function take(schema: Record<string, unknown>, 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<string, unknown>): Record<string, unknown> {
const normalized: Record<string, unknown> = {};

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<string, unknown>,
): Record<string, unknown> {
return normalizeSchema(structuredClone(schema));
}

export function isAnthropicOutputSchema(schema: Record<string, unknown>): boolean {
try {
normalizeAnthropicOutputSchema(schema);
return true;
} catch {
return false;
}
}
15 changes: 15 additions & 0 deletions src/adapters/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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" };
Expand Down
14 changes: 14 additions & 0 deletions src/claude/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading