From 2e6dfa6c54344f3b12773326852b72245b0a13bc Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 11:23:23 +0900 Subject: [PATCH 1/2] refactor(claude): split records, model options, and content options out of the inbound translator (split S08 L1/2) --- src/claude/inbound-content-options.ts | 60 ++++++++ src/claude/inbound-model-options.ts | 142 ++++++++++++++++++ src/claude/inbound-records.ts | 7 + src/claude/inbound.ts | 207 +------------------------- 4 files changed, 214 insertions(+), 202 deletions(-) create mode 100644 src/claude/inbound-content-options.ts create mode 100644 src/claude/inbound-model-options.ts create mode 100644 src/claude/inbound-records.ts diff --git a/src/claude/inbound-content-options.ts b/src/claude/inbound-content-options.ts new file mode 100644 index 0000000000..f6b762aa83 --- /dev/null +++ b/src/claude/inbound-content-options.ts @@ -0,0 +1,60 @@ +import { isClaudeWebSearchToolName } from "./outbound"; +import { AnthropicRequestError, isRec, type Rec } from "./inbound-records"; + +export function systemToInstructions(system: unknown): string | undefined { + if (typeof system === "string") return system.length > 0 ? system : undefined; + if (Array.isArray(system)) { + const parts: string[] = []; + for (const block of system) { + if (isRec(block) && block.type === "text" && typeof block.text === "string") parts.push(block.text); + } + return parts.length > 0 ? parts.join("\n\n") : undefined; + } + return undefined; +} + +export function toolsToResponses(tools: unknown): Rec[] | undefined { + if (!Array.isArray(tools) || tools.length === 0) return undefined; + const out: Rec[] = []; + for (const raw of tools) { + if (!isRec(raw)) continue; + const type = typeof raw.type === "string" ? raw.type : ""; + if (type.startsWith("web_search")) { + out.push({ type: "web_search" }); // hosted sidecar path + continue; + } + if (typeof raw.name === "string" && raw.name.length > 0 && isRec(raw.input_schema)) { + out.push({ + type: "function", + name: raw.name, + ...(typeof raw.description === "string" ? { description: raw.description } : {}), + parameters: raw.input_schema as Record, + }); + continue; + } + // Other server tools (bash_*, text_editor_*, ...) have no routed equivalent: drop. + } + return out.length > 0 ? out : undefined; +} + +export function toolChoiceToResponses(choice: unknown, body: Rec): void { + if (!isRec(choice)) return; + if (choice.disable_parallel_tool_use === true) body.parallel_tool_calls = false; + switch (choice.type) { + case "auto": body.tool_choice = "auto"; break; + case "none": body.tool_choice = "none"; break; + case "any": body.tool_choice = "required"; break; + case "tool": + if (typeof choice.name !== "string" || choice.name.length === 0) { + throw new AnthropicRequestError("tool_choice.tool requires a name"); + } + // Anthropic represents hosted WebSearch as a named tool choice, while + // Responses requires the choice type to match the hosted declaration. + // Preserve forced-tool intent rather than weakening it to `auto`. + body.tool_choice = isClaudeWebSearchToolName(choice.name) + ? { type: "web_search" } + : { type: "function", name: choice.name }; + break; + default: break; + } +} diff --git a/src/claude/inbound-model-options.ts b/src/claude/inbound-model-options.ts new file mode 100644 index 0000000000..e6ff9dbce7 --- /dev/null +++ b/src/claude/inbound-model-options.ts @@ -0,0 +1,142 @@ +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"; +import { isRec, type Rec } from "./inbound-records"; + +function isClaudeClassifierModel(model: string): boolean { + const stripped = model.replace(/-\d{8}$/, ""); + return /^claude-opus-[45]/.test(stripped); +} + +/** + * Explicitly configured classifier route for Claude Code Auto Mode safety checks (#1697). + * + * Only OPERATOR-DECLARED targets are used: `classifierModel`, then the ordered + * `classifierFallbacks`. Both are qualified `provider/model` strings the operator chose, so + * routing them crosses no boundary the operator did not ask for. + * + * Deliberately NOT here: inferring a provider from `claudeCode.model`. That value is the + * injected/default config slot, not the provider the live session actually selected, so it goes + * stale the moment the user changes the model picker -- and acting on it would silently move a + * classifier turn onto a provider with its own privacy and billing consequences. Live session + * affinity needs the request/session state this function does not have; it is tracked as + * follow-up work rather than approximated from static config. + */ +function configuredClassifierRoute(cc?: OcxClaudeCodeConfig): string | undefined { + const explicit = typeof cc?.classifierModel === "string" ? cc.classifierModel.trim() : ""; + if (explicit.length > 0) return explicit; + if (Array.isArray(cc?.classifierFallbacks)) { + for (const candidate of cc.classifierFallbacks) { + if (typeof candidate === "string" && candidate.trim().length > 0) return candidate.trim(); + } + } + return undefined; +} + +/** Alias first, then modelMap: exact id, then date-suffix-stripped (`-\d{8}$`), then classifier affinity/config, else passthrough. */ +export function resolveInboundModel(model: string, cc?: OcxClaudeCodeConfig): string { + // Defensive: Desktop/CLI strip the [1m] context-variant marker client-side, but a + // leaking build must not break alias decode (devlog 138 — the 1M signal is the + // anthropic-beta header, never the id). Case-insensitive: the CLI matches /\[1m\]/i. + model = stripOneMillionMarker(model); + const aliased = resolveAlias(model); + if (aliased) return aliased; + // Desktop 3P aliases: claude-opus-4-{code} → provider/model route key + const desktop3p = resolveDesktop3pAlias(model); + if (desktop3p) { + // Native pseudo-provider returns bare slug; routed returns provider/model + const sep = desktop3p.indexOf("/"); + if (sep > 0 && desktop3p.slice(0, sep) === "native") return desktop3p.slice(sep + 1); + return desktop3p; + } + const map = cc?.modelMap ?? {}; + const exact = map[model]; + if (typeof exact === "string" && exact.length > 0) return exact; + const stripped = model.replace(/-\d{8}$/, ""); + const dateless = map[stripped]; + if (typeof dateless === "string" && dateless.length > 0) return dateless; + + // Claude Code Auto Mode classifier routing (#1697). Bare classifier checks such as + // `claude-opus-5` carry no provider, so without this they fall through to defaultProvider -- + // which may not speak Anthropic at all. Only an operator-declared target is used. + if (isClaudeClassifierModel(model)) { + const configured = configuredClassifierRoute(cc); + if (configured) return configured; + } + return model; +} + +/** budget_tokens ladder -> Responses reasoning effort (003: real API min is 1024; never forward raw). */ +export function effortForThinkingBudget(budget: number): string { + if (budget <= 4096) return "low"; + if (budget <= 16384) return "medium"; + return "high"; +} + +/** + * Adaptive-thinking wire (devlog 080): Claude Code /effort sends + * `thinking:{type:"adaptive"}` + `output_config:{effort:"..."}` (verified by local + * capture of claude 2.1.207 and CLIProxyAPI#1540). Forward the level verbatim when it + * is a known Responses effort; unknown strings are dropped so downstream defaults win. + */ +const OUTPUT_CONFIG_EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]); +export function effortFromOutputConfig(outputConfig: unknown): string | undefined { + if (!isRec(outputConfig)) return undefined; + const effort = outputConfig.effort; + return typeof effort === "string" && OUTPUT_CONFIG_EFFORTS.has(effort) ? effort : undefined; +} + +export 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 }; +} + +/** + * ocx-route directive (devlog 072): injected agent-definition bodies carry + * `` because Claude Code 2.1.207 ignores custom + * gateway ids in agent frontmatter (live-proven fallback to sonnet). The body + * rides the subagent's system prompt, so the proxy re-routes here. Only the + * FIRST directive wins; the scan is bounded to the system field. + */ +const OCX_ROUTE_RE = //; +const OCX_EFFORT_RE = //; + +function systemText(body: unknown): string | null { + if (!isRec(body)) return null; + const system = body.system; + if (typeof system === "string") return system || null; + if (!Array.isArray(system)) return null; + const text = system + .filter((b): b is Rec => isRec(b) && b.type === "text" && typeof b.text === "string") + .map(b => b.text as string) + .join("\n"); + return text || null; +} + +export function extractOcxRouteDirective(body: unknown): string | null { + const text = systemText(body); + if (!text) return null; + const match = OCX_ROUTE_RE.exec(text); + return match ? match[1]! : null; +} + +/** + * Claude Code 2.1.220 collapses custom-agent frontmatter `effort: max` and + * `effort: xhigh` into the legacy `thinking.budget_tokens` shape. Preserve the + * exact generated-agent setting through the same trusted system-body channel as + * ocx-route so the inbound translator can restore `output_config.effort`. + */ +export function extractOcxEffortDirective(body: unknown): NonNullable | null { + const text = systemText(body); + if (!text) return null; + const match = OCX_EFFORT_RE.exec(text); + return match ? match[1] as NonNullable : null; +} diff --git a/src/claude/inbound-records.ts b/src/claude/inbound-records.ts new file mode 100644 index 0000000000..a39dd88c41 --- /dev/null +++ b/src/claude/inbound-records.ts @@ -0,0 +1,7 @@ +export class AnthropicRequestError extends Error {} + +export type Rec = Record; + +export function isRec(v: unknown): v is Rec { + return !!v && typeof v === "object" && !Array.isArray(v); +} diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 90c4652cb1..f70fc92905 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -10,126 +10,15 @@ * - 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"; -import { isClaudeWebSearchToolName } from "./outbound"; import { createHash } from "node:crypto"; -export class AnthropicRequestError extends Error {} +export { AnthropicRequestError } from "./inbound-records"; +export { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, extractOcxRouteDirective, extractOcxEffortDirective } from "./inbound-model-options"; +import { AnthropicRequestError, isRec, type Rec } from "./inbound-records"; +import { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, formatFromOutputConfig } from "./inbound-model-options"; +import { systemToInstructions, toolsToResponses, toolChoiceToResponses } from "./inbound-content-options"; -type Rec = Record; -function isRec(v: unknown): v is Rec { - return !!v && typeof v === "object" && !Array.isArray(v); -} - -function isClaudeClassifierModel(model: string): boolean { - const stripped = model.replace(/-\d{8}$/, ""); - return /^claude-opus-[45]/.test(stripped); -} - -/** - * Explicitly configured classifier route for Claude Code Auto Mode safety checks (#1697). - * - * Only OPERATOR-DECLARED targets are used: `classifierModel`, then the ordered - * `classifierFallbacks`. Both are qualified `provider/model` strings the operator chose, so - * routing them crosses no boundary the operator did not ask for. - * - * Deliberately NOT here: inferring a provider from `claudeCode.model`. That value is the - * injected/default config slot, not the provider the live session actually selected, so it goes - * stale the moment the user changes the model picker -- and acting on it would silently move a - * classifier turn onto a provider with its own privacy and billing consequences. Live session - * affinity needs the request/session state this function does not have; it is tracked as - * follow-up work rather than approximated from static config. - */ -function configuredClassifierRoute(cc?: OcxClaudeCodeConfig): string | undefined { - const explicit = typeof cc?.classifierModel === "string" ? cc.classifierModel.trim() : ""; - if (explicit.length > 0) return explicit; - if (Array.isArray(cc?.classifierFallbacks)) { - for (const candidate of cc.classifierFallbacks) { - if (typeof candidate === "string" && candidate.trim().length > 0) return candidate.trim(); - } - } - return undefined; -} - -/** Alias first, then modelMap: exact id, then date-suffix-stripped (`-\d{8}$`), then classifier affinity/config, else passthrough. */ -export function resolveInboundModel(model: string, cc?: OcxClaudeCodeConfig): string { - // Defensive: Desktop/CLI strip the [1m] context-variant marker client-side, but a - // leaking build must not break alias decode (devlog 138 — the 1M signal is the - // anthropic-beta header, never the id). Case-insensitive: the CLI matches /\[1m\]/i. - model = stripOneMillionMarker(model); - const aliased = resolveAlias(model); - if (aliased) return aliased; - // Desktop 3P aliases: claude-opus-4-{code} → provider/model route key - const desktop3p = resolveDesktop3pAlias(model); - if (desktop3p) { - // Native pseudo-provider returns bare slug; routed returns provider/model - const sep = desktop3p.indexOf("/"); - if (sep > 0 && desktop3p.slice(0, sep) === "native") return desktop3p.slice(sep + 1); - return desktop3p; - } - const map = cc?.modelMap ?? {}; - const exact = map[model]; - if (typeof exact === "string" && exact.length > 0) return exact; - const stripped = model.replace(/-\d{8}$/, ""); - const dateless = map[stripped]; - if (typeof dateless === "string" && dateless.length > 0) return dateless; - - // Claude Code Auto Mode classifier routing (#1697). Bare classifier checks such as - // `claude-opus-5` carry no provider, so without this they fall through to defaultProvider -- - // which may not speak Anthropic at all. Only an operator-declared target is used. - if (isClaudeClassifierModel(model)) { - const configured = configuredClassifierRoute(cc); - if (configured) return configured; - } - return model; -} - -/** budget_tokens ladder -> Responses reasoning effort (003: real API min is 1024; never forward raw). */ -export function effortForThinkingBudget(budget: number): string { - if (budget <= 4096) return "low"; - if (budget <= 16384) return "medium"; - return "high"; -} - -/** - * Adaptive-thinking wire (devlog 080): Claude Code /effort sends - * `thinking:{type:"adaptive"}` + `output_config:{effort:"..."}` (verified by local - * capture of claude 2.1.207 and CLIProxyAPI#1540). Forward the level verbatim when it - * is a known Responses effort; unknown strings are dropped so downstream defaults win. - */ -const OUTPUT_CONFIG_EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]); -export function effortFromOutputConfig(outputConfig: unknown): string | undefined { - if (!isRec(outputConfig)) return undefined; - const effort = outputConfig.effort; - 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)) { - const parts: string[] = []; - for (const block of system) { - if (isRec(block) && block.type === "text" && typeof block.text === "string") parts.push(block.text); - } - return parts.length > 0 ? parts.join("\n\n") : undefined; - } - return undefined; -} function imageBlockToInputImage(block: Rec): Rec | null { const source = block.source; @@ -194,47 +83,6 @@ export function effectiveBlockedSkillNames(cc?: Pick name.length > 0))]; } -/** - * ocx-route directive (devlog 072): injected agent-definition bodies carry - * `` because Claude Code 2.1.207 ignores custom - * gateway ids in agent frontmatter (live-proven fallback to sonnet). The body - * rides the subagent's system prompt, so the proxy re-routes here. Only the - * FIRST directive wins; the scan is bounded to the system field. - */ -const OCX_ROUTE_RE = //; -const OCX_EFFORT_RE = //; - -function systemText(body: unknown): string | null { - if (!isRec(body)) return null; - const system = body.system; - if (typeof system === "string") return system || null; - if (!Array.isArray(system)) return null; - const text = system - .filter((b): b is Rec => isRec(b) && b.type === "text" && typeof b.text === "string") - .map(b => b.text as string) - .join("\n"); - return text || null; -} - -export function extractOcxRouteDirective(body: unknown): string | null { - const text = systemText(body); - if (!text) return null; - const match = OCX_ROUTE_RE.exec(text); - return match ? match[1]! : null; -} - -/** - * Claude Code 2.1.220 collapses custom-agent frontmatter `effort: max` and - * `effort: xhigh` into the legacy `thinking.budget_tokens` shape. Preserve the - * exact generated-agent setting through the same trusted system-body channel as - * ocx-route so the inbound translator can restore `output_config.effort`. - */ -export function extractOcxEffortDirective(body: unknown): NonNullable | null { - const text = systemText(body); - if (!text) return null; - const match = OCX_EFFORT_RE.exec(text); - return match ? match[1] as NonNullable : null; -} /** Injected-skill payloads below this size are never stubbed (not worth it). */ const SKILL_ELISION_MIN_CHARS = 10_000; @@ -391,51 +239,6 @@ function assistantMessageToItems(content: unknown, input: Rec[]): void { flush(); } -function toolsToResponses(tools: unknown): Rec[] | undefined { - if (!Array.isArray(tools) || tools.length === 0) return undefined; - const out: Rec[] = []; - for (const raw of tools) { - if (!isRec(raw)) continue; - const type = typeof raw.type === "string" ? raw.type : ""; - if (type.startsWith("web_search")) { - out.push({ type: "web_search" }); // hosted sidecar path - continue; - } - if (typeof raw.name === "string" && raw.name.length > 0 && isRec(raw.input_schema)) { - out.push({ - type: "function", - name: raw.name, - ...(typeof raw.description === "string" ? { description: raw.description } : {}), - parameters: raw.input_schema as Record, - }); - continue; - } - // Other server tools (bash_*, text_editor_*, ...) have no routed equivalent: drop. - } - return out.length > 0 ? out : undefined; -} - -function toolChoiceToResponses(choice: unknown, body: Rec): void { - if (!isRec(choice)) return; - if (choice.disable_parallel_tool_use === true) body.parallel_tool_calls = false; - switch (choice.type) { - case "auto": body.tool_choice = "auto"; break; - case "none": body.tool_choice = "none"; break; - case "any": body.tool_choice = "required"; break; - case "tool": - if (typeof choice.name !== "string" || choice.name.length === 0) { - throw new AnthropicRequestError("tool_choice.tool requires a name"); - } - // Anthropic represents hosted WebSearch as a named tool choice, while - // Responses requires the choice type to match the hosted declaration. - // Preserve forced-tool intent rather than weakening it to `auto`. - body.tool_choice = isClaudeWebSearchToolName(choice.name) - ? { type: "web_search" } - : { type: "function", name: choice.name }; - break; - default: break; - } -} /** Recursive canonical JSON (keys sorted at every depth) — stable cache-cohort input. */ function canonicalJson(value: unknown): string { From c0fab2d74b977092884ea817c274ef2f3f4021a7 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 11:25:04 +0900 Subject: [PATCH 2/2] test(claude): cover the inbound leaf seams and the moved tool_choice error (split S08 L1/2) --- tests/claude-integration/claude-inbound.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/claude-integration/claude-inbound.test.ts b/tests/claude-integration/claude-inbound.test.ts index b1d537055d..8636128c9c 100644 --- a/tests/claude-integration/claude-inbound.test.ts +++ b/tests/claude-integration/claude-inbound.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { AnthropicRequestError as LeafAnthropicRequestError } from "../../src/claude/inbound-records"; +import { repoPath } from "../helpers/repo-root"; import { AnthropicRequestError, anthropicToResponsesBody, anthropicToResponsesTranslation, effortForThinkingBudget, extractOcxEffortDirective, resolveInboundModel } from "../../src/claude/inbound"; import { parseRequest } from "../../src/responses/parser"; import { responsesRequestSchema } from "../../src/responses/schema"; @@ -538,3 +541,14 @@ describe("ocx-route directive (devlog 072)", () => { expect(extractOcxEffortDirective(null)).toBeNull(); }); }); + +test("inbound leaves preserve the tool_choice error identity and avoid facade back-edges", () => { + const base = { model: "m", max_tokens: 10, messages: [{ role: "user", content: "hi" }] }; + expect(() => anthropicToResponsesBody({ ...base, tool_choice: { type: "tool" } })) + .toThrow(AnthropicRequestError); + expect(AnthropicRequestError).toBe(LeafAnthropicRequestError); + for (const leaf of ["inbound-records.ts", "inbound-model-options.ts", "inbound-content-options.ts"]) { + expect(readFileSync(repoPath("src", "claude", leaf), "utf8")) + .not.toMatch(/from\s+["']\.\/inbound["']/); + } +});