From eb4bad72960bbb76ed67a6d5dc9b106e8c4524d7 Mon Sep 17 00:00:00 2001 From: Yumi Date: Sat, 5 Sep 2026 18:58:04 -0600 Subject: [PATCH 1/9] feat(08-02): gate Claude routed compatibility at Messages boundary Add an opt-in compatibility mode consumed by the Messages route. Keep unset behavior unchanged, fail closed only for explicitly enforced unmappable protocol features, and bound diagnostic reasons. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- scripts/test-layout/layout.json | 1 + src/claude/compatibility.ts | 439 ++++++++++++++++++ src/server/claude-messages.ts | 25 + src/types/config.ts | 2 + .../claude-compatibility.test.ts | 52 +++ tests/fixtures/test-layout-expected.json | 1 + 6 files changed, 520 insertions(+) create mode 100644 src/claude/compatibility.ts create mode 100644 tests/claude-integration/claude-compatibility.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0fbe7cf746b..3c2109685b7 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -164,6 +164,7 @@ } }, "explicit": { + "claude-compatibility.test.ts": "claude-integration", "abort-idle-deadline.test.ts": "lib", "abort-race.test.ts": "adapters", "account-import.test.ts": "server", diff --git a/src/claude/compatibility.ts b/src/claude/compatibility.ts new file mode 100644 index 00000000000..aa220bb4845 --- /dev/null +++ b/src/claude/compatibility.ts @@ -0,0 +1,439 @@ +/** + * Claude compatibility analyzer (pure, no Lab imports). + * + * Detects Anthropic-specific feature usage in the sanitized source envelope + * and decides whether a routed adapter can safely serve the request. Feature + * codes are stable identifiers used in the bounded debug ring and (in enforce + * mode) as a pre-network gate. + * + * Feature codes (Milestone 2 precise set): + * - cache_control: any block with a cache_control field (positional prompt caching) + * - thinking_block: thinking param or thinking/redacted_thinking blocks (genuine signed thinking) + * - documents: document content blocks in messages + * - unknown_content_block: content block type not in known Anthropic vocabulary + * - web_search_tool: hosted web_search tool/block (has lossless Responses mapping) + * - code_execution: code_execution tool/block (no lossless routed mapping) + * - computer_use: computer tool/block (no lossless routed mapping) + * - mcp_tool: mcp tool declarations (no lossless routed mapping) + * - server_tool: generic fallback for other hosted/server tool types + * - tool_search: tool_search declaration/call (lossless via tool_search) + * - deferred_tools: tools with defer/defer_loading or deferred beta markers + * - structured_output: output_config.format json_schema/json_object (lossless via text.format) + * - service_tier: top-level service_tier (lossless via Responses option) + * - context_management: top-level context_management field (no lossless routed mapping) + * - input_examples: tool input_examples (Anthropic-only, preserved via source envelope) + * - beta_*: each anthropic-beta token as beta_ + */ + +export type ClaudeCompatibilityMode = "shadow" | "enforce"; + +export const CLAUDE_COMPATIBILITY_MODES = ["shadow", "enforce"] as const; + +export function isClaudeCompatibilityMode(value: unknown): value is ClaudeCompatibilityMode { + return typeof value === "string" && (CLAUDE_COMPATIBILITY_MODES as readonly string[]).includes(value); +} + +export function resolveClaudeCompatibilityMode( + cc?: { compatibility?: unknown }, +): ClaudeCompatibilityMode { + return isClaudeCompatibilityMode(cc?.compatibility) ? cc.compatibility : "enforce"; +} + +export type ClaudeCompatibilityDecision = "allow" | "reject" | "shadow"; + +export interface ClaudeCompatibilityResult { + featureCodes: string[]; + compatible: boolean; + decision: ClaudeCompatibilityDecision; + /** Human-readable reason when rejected, otherwise undefined. */ + reason?: string; +} + +type Rec = Record; + +function isRec(v: unknown): v is Rec { + return !!v && typeof v === "object" && !Array.isArray(v); +} + +function sanitizeBetaToken(raw: string): string { + const trimmed = raw.trim().toLowerCase(); + if (!trimmed) return ""; + return trimmed.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); +} + +function walkForCacheControl(value: unknown): boolean { + if (!value || typeof value !== "object") return false; + if (Array.isArray(value)) return value.some(walkForCacheControl); + const rec = value as Rec; + if (Object.prototype.hasOwnProperty.call(rec, "cache_control")) return true; + return Object.values(rec).some(walkForCacheControl); +} + +function hasThinkingBlock(body: Rec): boolean { + if (isRec(body.thinking)) return true; + const msgs = body.messages; + if (!Array.isArray(msgs)) return false; + for (const m of msgs) { + if (!isRec(m)) continue; + const content = m.content; + if (Array.isArray(content)) { + for (const b of content) { + if (!isRec(b)) continue; + if (b.type === "thinking" || b.type === "redacted_thinking") return true; + } + } + } + return false; +} + +const KNOWN_CONTENT_TYPES = new Set([ + "text", "image", "tool_use", "tool_result", "thinking", "redacted_thinking", + "document", "server_tool_use", "web_search_tool_result", "code_execution_tool_result", + "tool_search_tool_result", +]); + +function hasDocuments(body: Rec): boolean { + const msgs = body.messages; + if (!Array.isArray(msgs)) return false; + for (const m of msgs) { + if (!isRec(m)) continue; + const content = m.content; + if (!Array.isArray(content)) continue; + for (const b of content) { + if (!isRec(b)) continue; + if (b.type === "document") return true; + } + } + return false; +} + +function hasUnknownContentBlock(body: Rec): boolean { + const msgs = body.messages; + if (!Array.isArray(msgs)) return false; + for (const m of msgs) { + if (!isRec(m)) continue; + const content = m.content; + if (!Array.isArray(content)) continue; + for (const b of content) { + if (!isRec(b)) continue; + const t = typeof b.type === "string" ? b.type : ""; + if (t && !KNOWN_CONTENT_TYPES.has(t)) return true; + } + } + return false; +} + +function hasCodeExecution(body: Rec): boolean { + const tools = body.tools; + if (Array.isArray(tools)) { + for (const t of tools) { + if (!isRec(t)) continue; + const type = typeof t.type === "string" ? t.type : ""; + if (type.includes("code_execution")) return true; + if (typeof t.name === "string" && t.name.includes("code_execution")) return true; + } + } + const msgs = body.messages; + if (Array.isArray(msgs)) { + for (const m of msgs) { + if (!isRec(m)) continue; + const content = m.content; + if (!Array.isArray(content)) continue; + for (const b of content) { + if (!isRec(b)) continue; + if (b.type === "code_execution_tool_result") return true; + if (b.type === "server_tool_use" && typeof b.name === "string" && b.name.includes("code_execution")) return true; + } + } + } + return false; +} + +function hasComputerUse(body: Rec): boolean { + const tools = body.tools; + if (Array.isArray(tools)) { + for (const t of tools) { + if (!isRec(t)) continue; + const type = typeof t.type === "string" ? t.type : ""; + if (type.includes("computer")) return true; + } + } + const msgs = body.messages; + if (!Array.isArray(msgs)) return false; + for (const m of msgs) { + if (!isRec(m)) continue; + const content = m.content; + if (!Array.isArray(content)) continue; + for (const b of content) { + if (!isRec(b)) continue; + if (typeof b.type === "string" && b.type.includes("computer")) return true; + } + } + return false; +} + +function hasMcpTool(body: Rec): boolean { + const tools = body.tools; + if (Array.isArray(tools)) { + for (const t of tools) { + if (!isRec(t)) continue; + const type = typeof t.type === "string" ? t.type : ""; + if (type.startsWith("mcp")) return true; + if (typeof t.name === "string" && t.name.startsWith("mcp")) return true; + } + } + return false; +} + +function hasWebSearchTool(body: Rec): boolean { + const tools = body.tools; + if (Array.isArray(tools)) { + for (const t of tools) { + if (!isRec(t)) continue; + const type = typeof t.type === "string" ? t.type : ""; + if (type.includes("web_search")) return true; + } + } + const msgs = body.messages; + if (Array.isArray(msgs)) { + for (const m of msgs) { + if (!isRec(m)) continue; + const content = m.content; + if (!Array.isArray(content)) continue; + for (const b of content) { + if (!isRec(b)) continue; + if (b.type === "web_search_tool_result") return true; + if (b.type === "server_tool_use" && typeof b.name === "string" && b.name.includes("web_search")) return true; + } + } + } + return false; +} + +function hasGenericServerTool(body: Rec): boolean { + const tools = body.tools; + if (!Array.isArray(tools)) { + const msgs = body.messages; + if (Array.isArray(msgs)) { + for (const m of msgs) { + if (!isRec(m)) continue; + const content = m.content; + if (!Array.isArray(content)) continue; + for (const b of content) { + if (!isRec(b)) continue; + if (b.type !== "server_tool_use") continue; + const name = typeof b.name === "string" ? b.name : ""; + if (name.includes("web_search") || name.includes("code_execution") || name.includes("computer") || name.startsWith("tool_search_tool_")) continue; + return true; + } + } + } + return false; + } + for (const t of tools) { + if (!isRec(t)) continue; + // Exclude known function tools, tool_search, and already-classified server tools + const type = typeof t.type === "string" ? t.type : ""; + const name = typeof t.name === "string" ? t.name : ""; + if (name === "tool_search" || name.startsWith("tool_search_tool_")) continue; + if (type === "tool_search" || type.startsWith("tool_search_tool_")) continue; + if (type.includes("web_search") || type.includes("code_execution") || type.includes("computer") || type.startsWith("mcp")) continue; + if (type && type !== "function") return true; + if (type && typeof t.name !== "string") return true; + } + const msgs2 = body.messages; + if (Array.isArray(msgs2)) { + for (const m of msgs2) { + if (!isRec(m)) continue; + const content = m.content; + if (!Array.isArray(content)) continue; + for (const b of content) { + if (!isRec(b)) continue; + if (b.type === "server_tool_use") { + const n = typeof b.name === "string" ? b.name : ""; + if (n.includes("web_search") || n.includes("code_execution") || n.includes("computer") || n.startsWith("tool_search_tool_")) continue; + return true; + } + } + } + } + return false; +} + +function hasToolSearch(body: Rec): boolean { + const tools = body.tools; + if (Array.isArray(tools)) { + for (const t of tools) { + if (!isRec(t)) continue; + if (typeof t.type === "string" && (t.type === "tool_search" || t.type.startsWith("tool_search_tool_"))) return true; + if (typeof t.name === "string" && (t.name === "tool_search" || t.name.startsWith("tool_search_tool_"))) return true; + } + } + const msgs = body.messages; + if (Array.isArray(msgs)) { + for (const m of msgs) { + if (!isRec(m)) continue; + const content = m.content; + if (!Array.isArray(content)) continue; + for (const b of content) { + if (!isRec(b)) continue; + if (b.type === "tool_search_tool_result") return true; + if ((b.type === "tool_use" || b.type === "server_tool_use") + && typeof b.name === "string" + && (b.name === "tool_search" || b.name.startsWith("tool_search_tool_"))) return true; + } + } + } + return false; +} + +function hasDeferredTools(body: Rec): boolean { + const tools = body.tools; + if (Array.isArray(tools)) { + for (const t of tools) { + if (!isRec(t)) continue; + if (t.defer === true) return true; + if ((t as Rec).defer_loading === true) return true; + if (Object.hasOwn(t, "defer") || Object.hasOwn(t, "defer_loading")) { + // presence with truthy already handled; presence with explicit true is deferred + } + } + } + if (Object.hasOwn(body, "defer_tools") || Object.hasOwn(body, "deferred_tools")) return true; + return false; +} + +function hasInputExamples(body: Rec): boolean { + const tools = body.tools; + if (!Array.isArray(tools)) return false; + for (const t of tools) { + if (!isRec(t)) continue; + if (Object.hasOwn(t, "input_examples")) return true; + } + return false; +} + +function hasStructuredOutput(body: Rec): boolean { + const oc = body.output_config; + if (!isRec(oc)) return false; + const fmt = (oc as Rec).format ?? (oc as Rec).output_format; + if (!isRec(fmt as unknown)) return false; + const f = fmt as Rec; + if (f.type === "json_schema" || f.type === "json_object") return true; + if (isRec(f.schema)) return true; + return false; +} + +function hasServiceTier(body: Rec): boolean { + return typeof body.service_tier === "string" && (body.service_tier as string).length > 0; +} + +function hasContextManagement(body: Rec): boolean { + return Object.prototype.hasOwnProperty.call(body, "context_management"); +} + +const KNOWN_BODY_FIELDS = new Set([ + "model", "max_tokens", "messages", "system", "tools", "tool_choice", "thinking", + "output_config", "metadata", "service_tier", "stop_sequences", "stream", + "temperature", "top_p", "top_k", "cache_control", "context_management", + "container", "inference_geo", "user_profile_id", "defer_tools", "deferred_tools", +]); + +function hasUnknownBodyField(body: Rec): boolean { + return Object.keys(body).some(field => !KNOWN_BODY_FIELDS.has(field)); +} + +/** + * Collect feature codes from a sanitized Anthropic body and anthropic-beta header. + * Pure — no config or Lab state. + */ +export function collectClaudeFeatureCodes( + body: unknown, + anthropicBeta?: string, +): string[] { + const codes: string[] = []; + const rec = isRec(body) ? (body as Rec) : null; + if (rec) { + if (walkForCacheControl(body)) codes.push("cache_control"); + if (hasContextManagement(rec)) codes.push("context_management"); + if (Object.hasOwn(rec, "container")) codes.push("container"); + if (Object.hasOwn(rec, "inference_geo")) codes.push("inference_geo"); + if (Object.hasOwn(rec, "user_profile_id")) codes.push("user_profile"); + if (hasUnknownBodyField(rec)) codes.push("unknown_body_field"); + if (hasThinkingBlock(rec)) codes.push("thinking_block"); + if (hasDocuments(rec)) codes.push("documents"); + if (hasUnknownContentBlock(rec)) codes.push("unknown_content_block"); + if (hasWebSearchTool(rec)) codes.push("web_search_tool"); + if (hasCodeExecution(rec)) codes.push("code_execution"); + if (hasComputerUse(rec)) codes.push("computer_use"); + if (hasMcpTool(rec)) codes.push("mcp_tool"); + if (hasGenericServerTool(rec)) codes.push("server_tool"); + if (hasToolSearch(rec)) codes.push("tool_search"); + if (hasDeferredTools(rec)) codes.push("deferred_tools"); + if (hasInputExamples(rec)) codes.push("input_examples"); + if (hasStructuredOutput(rec)) codes.push("structured_output"); + if (hasServiceTier(rec)) codes.push("service_tier"); + } + if (typeof anthropicBeta === "string" && anthropicBeta.trim().length > 0) { + for (const raw of anthropicBeta.split(",")) { + const sanitized = sanitizeBetaToken(raw); + if (!sanitized) continue; + codes.push(`beta_${sanitized}`); + } + } + return [...new Set(codes)].sort(); +} + +/** + * Analyze compatibility for a given body/header/adapter/mode. + * Enforce rejects when incompatible features require native Anthropic. + * Shadow never rejects — it only records. + */ +export function analyzeClaudeCompatibility( + body: unknown, + opts: { mode: ClaudeCompatibilityMode; adapter?: string; anthropicBeta?: string }, +): ClaudeCompatibilityResult { + const featureCodes = collectClaudeFeatureCodes(body, opts.anthropicBeta); + if (opts.adapter === "anthropic") { + return { featureCodes, compatible: true, decision: "allow" }; + } + // Incompatible set for routed adapters (non-anthropic native). + // Compatible (translated or lossless): cache_control, thinking_block, web_search_tool, + // tool_search, structured_output, service_tier, input_examples (deferred? no), beta_*. + // Incompatible: features without lossless Responses mapping — they require Anthropic + // source preservation and must be rejected on routed targets. + const INCOMPATIBLE = new Set([ + "cache_control", + "context_management", + "container", + "inference_geo", + "user_profile", + "unknown_body_field", + "documents", + "unknown_content_block", + "code_execution", + "computer_use", + "mcp_tool", + "server_tool", + "input_examples", + ]); + if (opts.adapter !== "openai-responses") INCOMPATIBLE.add("deferred_tools"); + const incompatible = featureCodes.filter(c => INCOMPATIBLE.has(c)).slice(0, 32); + if (opts.mode === "shadow") { + return { + featureCodes, + compatible: true, + decision: incompatible.length > 0 ? "shadow" : "allow", + ...(incompatible.length > 0 ? { reason: `shadow: would reject for ${incompatible.join(", ")}` } : {}), + }; + } + if (incompatible.length > 0) { + return { + featureCodes, + compatible: false, + decision: "reject", + reason: `unsupported features for routed adapter ${opts.adapter ?? "unknown"}: ${incompatible.join(", ")}. Select an Anthropic route, remove the feature, or begin a fresh reasoning turn`.slice(0, 512), + }; + } + return { featureCodes, compatible: true, decision: "allow" }; +} diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index cda2e3e45d6..1aa2ace836b 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -16,6 +16,7 @@ import { resolveAlias, claudeCodeNativeAlias } from "../claude/alias"; import { recordDesktopRequest } from "../claude/desktop-health"; import { stripOneMillionMarker } from "../claude/context-windows"; import { captureClaudeInbound } from "../claude/inbound-debug"; +import { analyzeClaudeCompatibility } from "../claude/compatibility"; import { isTransientUpstreamStatus } from "../lib/upstream-retry"; import { resolveClientRetryAfter } from "../lib/retry-after"; import { @@ -64,6 +65,22 @@ import { type Rec = Record; +/** Apply the opt-in Claude protocol gate without exposing request content. */ +export function enforceClaudeCompatibility( + body: unknown, + opts: { mode?: unknown; adapter: string; anthropicBeta?: string }, +): void { + if (opts.mode !== "shadow" && opts.mode !== "enforce") return; + const result = analyzeClaudeCompatibility(body, { + mode: opts.mode, + adapter: opts.adapter, + anthropicBeta: opts.anthropicBeta, + }); + if (result.decision === "reject") { + throw new AnthropicRequestError(result.reason ?? "unsupported Claude protocol feature"); + } +} + /** * Decode a Claude selector that may carry the fast marker. * @@ -764,6 +781,14 @@ async function handleClaudeMessagesWithBudget( // adapter rather than the provider-wide default (#404). route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic"); logCtx.routeDecision = route.routeDecision; + // Opt-in protocol gate: native passthrough has already returned above. Keep the + // legacy translator behavior unchanged when unset; enforce only an explicitly + // requested mode and never include request data in the bounded error reason. + enforceClaudeCompatibility(anthropicBody, { + mode: config.claudeCode?.compatibility, + adapter: route.provider.adapter, + anthropicBeta: req.headers.get("anthropic-beta") ?? undefined, + }); if (route.provider.adapter === "openai-responses") { nativeRoute = true; delete internalBody.max_output_tokens; diff --git a/src/types/config.ts b/src/types/config.ts index 27b1a8ec81d..35df27788aa 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -6,6 +6,8 @@ import type { CodexAccount } from "./accounts"; * /v1/messages surface, the `ocx claude` launcher, and the GUI Claude page. */ export interface OcxClaudeCodeConfig { + /** Optional routed compatibility gate. Unset preserves legacy translation behavior. */ + compatibility?: "shadow" | "enforce"; /** Kill switch for the /v1/messages inbound (GUI "Claude ON" toggle). Default: enabled. */ enabled?: boolean; /** diff --git a/tests/claude-integration/claude-compatibility.test.ts b/tests/claude-integration/claude-compatibility.test.ts new file mode 100644 index 00000000000..c8f40dd5b58 --- /dev/null +++ b/tests/claude-integration/claude-compatibility.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { + analyzeClaudeCompatibility, + collectClaudeFeatureCodes, + isClaudeCompatibilityMode, + resolveClaudeCompatibilityMode, +} from "../../src/claude/compatibility"; +import { AnthropicRequestError } from "../../src/claude/inbound"; +import { enforceClaudeCompatibility } from "../../src/server/claude-messages"; + +describe("Claude routed compatibility foundation", () => { + test("collects stable feature codes without reading credentials", () => { + expect(collectClaudeFeatureCodes({ + model: "claude-sonnet", + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + tools: [{ type: "web_search_20250305", name: "web_search" }], + }, "context-1m-2025-08-07")).toEqual(["beta_context_1m_2025_08_07", "web_search_tool"]); + }); + + test("fails closed for unmappable routed content while native Anthropic stays allowed", () => { + const body = { messages: [{ role: "user", content: [{ type: "document", title: "x" }] }] }; + expect(analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "openai-chat" }).decision).toBe("reject"); + expect(analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "anthropic" })).toMatchObject({ compatible: true, decision: "allow" }); + }); + + test("shadow mode reports, but does not reject, an incompatible feature", () => { + const result = analyzeClaudeCompatibility({ context_management: { edits: [{ op: "remove" }] } }, { mode: "shadow", adapter: "openai-chat" }); + expect(result).toMatchObject({ compatible: true, decision: "shadow" }); + expect(result.reason).toContain("context_management"); + }); + + test("mode resolution is strict and defaults to enforce", () => { + expect(isClaudeCompatibilityMode("shadow")).toBe(true); + expect(isClaudeCompatibilityMode("invalid")).toBe(false); + expect(resolveClaudeCompatibilityMode()).toBe("enforce"); + expect(resolveClaudeCompatibilityMode({ compatibility: "shadow" })).toBe("shadow"); + }); + + test("the Messages endpoint gate is opt-in, fail-closed, and reason-bounded", () => { + const body = { messages: [{ role: "user", content: [{ type: "document", title: "secret-name" }] }] }; + expect(() => enforceClaudeCompatibility(body, { adapter: "openai-chat" })).not.toThrow(); + expect(() => enforceClaudeCompatibility(body, { mode: "shadow", adapter: "openai-chat" })).not.toThrow(); + expect(() => enforceClaudeCompatibility(body, { mode: "enforce", adapter: "openai-chat" })).toThrow(AnthropicRequestError); + try { + enforceClaudeCompatibility(body, { mode: "enforce", adapter: "openai-chat" }); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message.length).toBeLessThanOrEqual(512); + expect((error as Error).message).not.toContain("secret-name"); + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index db2583b00b7..9fea85d8ab7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -123,6 +123,7 @@ "claude-authmode-migration.test.ts": "claude-integration", "claude-cli.test.ts": "claude-integration", "claude-code-thought-signature-scope.test.ts": "claude-integration", + "claude-compatibility.test.ts": "claude-integration", "claude-context-windows.test.ts": "claude-integration", "claude-desktop-1m.test.ts": "claude-integration", "claude-desktop-cli.test.ts": "claude-integration", From 17b663024cd5cca47a526daa9396d0a7b00b9137 Mon Sep 17 00:00:00 2001 From: Yumi Date: Sat, 5 Sep 2026 18:58:20 -0600 Subject: [PATCH 2/9] docs(08-02): document Claude compatibility mode --- docs-site/src/content/docs/reference/configuration/server.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index b793f159c1e..15afabe2eb6 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -317,6 +317,7 @@ These settings govern `/v1/messages`, `/v1/messages/count_tokens`, the `ocx clau | --- | --- | --- | --- | | `claudeCode.bodyStallSec?` | `number` | `90` | Native-passthrough body inactivity budget in seconds while a read is pending, not total duration. Minimum 1; exactly `0` disables. | | `claudeCode.bodyMaxBytes?` | `number` | `67108864` | Cumulative native-passthrough body cap for streamed and buffered responses. Exactly `0` disables. | +| `claudeCode.compatibility?` | `"shadow" | "enforce"` | unset | Optional routed-protocol compatibility gate. `shadow` reports compatibility decisions without rejecting; `enforce` rejects Anthropic features that cannot be represented losslessly by the selected routed adapter. Unset preserves legacy translation behavior. | | `claudeCode.authMode?` | `"proxy" \| "subscription"` | auto | How launch handles `ANTHROPIC_AUTH_TOKEN`. Auto detects auth each launch; an explicit value is never overridden. | | `claudeCode.authModeMigratedAt?` | `string` | unset | Internal one-time upgrade marker. Do not set manually. | | `claudeCode.classifierModel?` | `string` | unset | Explicit target for Claude Code Auto Mode classifier turns, as a qualified `provider/model` (for example `RelayA/claude-opus-5`). Auto Mode sends bare safety checks such as `claude-opus-5` with no provider, so without this they fall through to `defaultProvider` — which may not speak Anthropic at all. Nothing is inferred automatically: only a target you declare here is used. | From 168a8fa717678d31715a2d7d723fdee346bd28e7 Mon Sep 17 00:00:00 2001 From: Yumi Date: Sat, 5 Sep 2026 19:00:24 -0600 Subject: [PATCH 3/9] fix(08-02): avoid false MCP compatibility rejects --- src/claude/compatibility.ts | 12 ++++++++++-- .../claude-integration/claude-compatibility.test.ts | 6 ++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/claude/compatibility.ts b/src/claude/compatibility.ts index aa220bb4845..b9f96b13374 100644 --- a/src/claude/compatibility.ts +++ b/src/claude/compatibility.ts @@ -178,8 +178,16 @@ function hasMcpTool(body: Rec): boolean { for (const t of tools) { if (!isRec(t)) continue; const type = typeof t.type === "string" ? t.type : ""; - if (type.startsWith("mcp")) return true; - if (typeof t.name === "string" && t.name.startsWith("mcp")) return true; + // A client-executed function may legitimately be named `mcp_*`; only the + // protocol-declared server toolset is an MCP admission feature. + if (type === "mcp_toolset") return true; + } + } + const messages = body.messages; + if (Array.isArray(messages)) { + for (const message of messages) { + if (!isRec(message) || !Array.isArray(message.content)) continue; + if (message.content.some(block => isRec(block) && (block.type === "mcp_tool_use" || block.type === "mcp_tool_result"))) return true; } } return false; diff --git a/tests/claude-integration/claude-compatibility.test.ts b/tests/claude-integration/claude-compatibility.test.ts index c8f40dd5b58..378b327c7b0 100644 --- a/tests/claude-integration/claude-compatibility.test.ts +++ b/tests/claude-integration/claude-compatibility.test.ts @@ -23,6 +23,12 @@ describe("Claude routed compatibility foundation", () => { expect(analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "anthropic" })).toMatchObject({ compatible: true, decision: "allow" }); }); + test("does not mistake a client function name for MCP admission", () => { + const result = analyzeClaudeCompatibility({ tools: [{ type: "function", name: "mcp_lookup", input_schema: { type: "object" } }] }, { mode: "enforce", adapter: "openai-chat" }); + expect(result).toMatchObject({ compatible: true, decision: "allow" }); + expect(result.featureCodes).not.toContain("mcp_tool"); + }); + test("shadow mode reports, but does not reject, an incompatible feature", () => { const result = analyzeClaudeCompatibility({ context_management: { edits: [{ op: "remove" }] } }, { mode: "shadow", adapter: "openai-chat" }); expect(result).toMatchObject({ compatible: true, decision: "shadow" }); From 6b1db141f38bdbdf40073bb65d42b727b267d87a Mon Sep 17 00:00:00 2001 From: Yumi Date: Sat, 5 Sep 2026 19:42:39 -0600 Subject: [PATCH 4/9] fix(08-02): enforce Claude compatibility at endpoint Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- .../docs/reference/configuration/server.md | 2 +- src/claude/compatibility.ts | 43 ++++++++----------- src/server/claude-messages.ts | 4 ++ .../claude-compatibility.test.ts | 39 +++++++++++++++-- .../claude-messages-endpoint.test.ts | 38 ++++++++++++++++ 5 files changed, 97 insertions(+), 29 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 15afabe2eb6..f22cc01e3f9 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -317,7 +317,7 @@ These settings govern `/v1/messages`, `/v1/messages/count_tokens`, the `ocx clau | --- | --- | --- | --- | | `claudeCode.bodyStallSec?` | `number` | `90` | Native-passthrough body inactivity budget in seconds while a read is pending, not total duration. Minimum 1; exactly `0` disables. | | `claudeCode.bodyMaxBytes?` | `number` | `67108864` | Cumulative native-passthrough body cap for streamed and buffered responses. Exactly `0` disables. | -| `claudeCode.compatibility?` | `"shadow" | "enforce"` | unset | Optional routed-protocol compatibility gate. `shadow` reports compatibility decisions without rejecting; `enforce` rejects Anthropic features that cannot be represented losslessly by the selected routed adapter. Unset preserves legacy translation behavior. | +| `claudeCode.compatibility?` | `"shadow" \| "enforce"` | unset | Optional routed-protocol compatibility gate. `shadow` reports compatibility decisions without rejecting; `enforce` rejects Anthropic features that cannot be represented losslessly by the selected routed adapter. Unset preserves legacy translation behavior. | | `claudeCode.authMode?` | `"proxy" \| "subscription"` | auto | How launch handles `ANTHROPIC_AUTH_TOKEN`. Auto detects auth each launch; an explicit value is never overridden. | | `claudeCode.authModeMigratedAt?` | `string` | unset | Internal one-time upgrade marker. Do not set manually. | | `claudeCode.classifierModel?` | `string` | unset | Explicit target for Claude Code Auto Mode classifier turns, as a qualified `provider/model` (for example `RelayA/claude-opus-5`). Auto Mode sends bare safety checks such as `claude-opus-5` with no provider, so without this they fall through to `defaultProvider` — which may not speak Anthropic at all. Nothing is inferred automatically: only a target you declare here is used. | diff --git a/src/claude/compatibility.ts b/src/claude/compatibility.ts index b9f96b13374..3625c639904 100644 --- a/src/claude/compatibility.ts +++ b/src/claude/compatibility.ts @@ -17,7 +17,7 @@ * - mcp_tool: mcp tool declarations (no lossless routed mapping) * - server_tool: generic fallback for other hosted/server tool types * - tool_search: tool_search declaration/call (lossless via tool_search) - * - deferred_tools: tools with defer/defer_loading or deferred beta markers + * - deferred_tools: tools with active defer/defer_loading or a non-empty deferred-tools declaration * - structured_output: output_config.format json_schema/json_object (lossless via text.format) * - service_tier: top-level service_tier (lossless via Responses option) * - context_management: top-level context_management field (no lossless routed mapping) @@ -33,12 +33,6 @@ export function isClaudeCompatibilityMode(value: unknown): value is ClaudeCompat return typeof value === "string" && (CLAUDE_COMPATIBILITY_MODES as readonly string[]).includes(value); } -export function resolveClaudeCompatibilityMode( - cc?: { compatibility?: unknown }, -): ClaudeCompatibilityMode { - return isClaudeCompatibilityMode(cc?.compatibility) ? cc.compatibility : "enforce"; -} - export type ClaudeCompatibilityDecision = "allow" | "reject" | "shadow"; export interface ClaudeCompatibilityResult { @@ -61,12 +55,17 @@ function sanitizeBetaToken(raw: string): string { return trimmed.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); } -function walkForCacheControl(value: unknown): boolean { - if (!value || typeof value !== "object") return false; - if (Array.isArray(value)) return value.some(walkForCacheControl); - const rec = value as Rec; - if (Object.prototype.hasOwnProperty.call(rec, "cache_control")) return true; - return Object.values(rec).some(walkForCacheControl); +function hasCacheControl(body: Rec): boolean { + if (Object.prototype.hasOwnProperty.call(body, "cache_control")) return true; + const blockHasCacheControl = (value: unknown): boolean => + isRec(value) && Object.prototype.hasOwnProperty.call(value, "cache_control"); + if (Array.isArray(body.system) && body.system.some(blockHasCacheControl)) return true; + if (Array.isArray(body.tools) && body.tools.some(blockHasCacheControl)) return true; + if (!Array.isArray(body.messages)) return false; + return body.messages.some(message => + isRec(message) + && Array.isArray(message.content) + && message.content.some(blockHasCacheControl)); } function hasThinkingBlock(body: Rec): boolean { @@ -130,7 +129,6 @@ function hasCodeExecution(body: Rec): boolean { if (!isRec(t)) continue; const type = typeof t.type === "string" ? t.type : ""; if (type.includes("code_execution")) return true; - if (typeof t.name === "string" && t.name.includes("code_execution")) return true; } } const msgs = body.messages; @@ -302,13 +300,11 @@ function hasDeferredTools(body: Rec): boolean { if (!isRec(t)) continue; if (t.defer === true) return true; if ((t as Rec).defer_loading === true) return true; - if (Object.hasOwn(t, "defer") || Object.hasOwn(t, "defer_loading")) { - // presence with truthy already handled; presence with explicit true is deferred - } } } - if (Object.hasOwn(body, "defer_tools") || Object.hasOwn(body, "deferred_tools")) return true; - return false; + if (body.defer_tools === true || body.deferred_tools === true) return true; + if (Array.isArray(body.deferred_tools)) return body.deferred_tools.length > 0; + return isRec(body.deferred_tools) && Object.keys(body.deferred_tools).length > 0; } function hasInputExamples(body: Rec): boolean { @@ -362,7 +358,7 @@ export function collectClaudeFeatureCodes( const codes: string[] = []; const rec = isRec(body) ? (body as Rec) : null; if (rec) { - if (walkForCacheControl(body)) codes.push("cache_control"); + if (hasCacheControl(rec)) codes.push("cache_control"); if (hasContextManagement(rec)) codes.push("context_management"); if (Object.hasOwn(rec, "container")) codes.push("container"); if (Object.hasOwn(rec, "inference_geo")) codes.push("inference_geo"); @@ -406,12 +402,12 @@ export function analyzeClaudeCompatibility( return { featureCodes, compatible: true, decision: "allow" }; } // Incompatible set for routed adapters (non-anthropic native). - // Compatible (translated or lossless): cache_control, thinking_block, web_search_tool, - // tool_search, structured_output, service_tier, input_examples (deferred? no), beta_*. + // Compatible (translated, safely omitted, or preserved by the source envelope): + // cache_control, thinking_block, web_search_tool, tool_search, structured_output, + // service_tier, input_examples, and beta_*. // Incompatible: features without lossless Responses mapping — they require Anthropic // source preservation and must be rejected on routed targets. const INCOMPATIBLE = new Set([ - "cache_control", "context_management", "container", "inference_geo", @@ -423,7 +419,6 @@ export function analyzeClaudeCompatibility( "computer_use", "mcp_tool", "server_tool", - "input_examples", ]); if (opts.adapter !== "openai-responses") INCOMPATIBLE.add("deferred_tools"); const incompatible = featureCodes.filter(c => INCOMPATIBLE.has(c)).slice(0, 32); diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 1aa2ace836b..11da40cd2b8 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -815,6 +815,10 @@ async function handleClaudeMessagesWithBudget( if (ladder !== undefined && ladder.length === 0) delete internalBody.reasoning; } } catch (err) { + if (err instanceof AnthropicRequestError) { + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 400, { closeReason: "non_stream" }); + return anthropicErrorResponse(400, err.message, "invalid_request_error"); + } if (err instanceof UnknownRoutingPolicyError) { logCtx.requestedModel = requestedModel; if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 404, { closeReason: "non_stream" }); diff --git a/tests/claude-integration/claude-compatibility.test.ts b/tests/claude-integration/claude-compatibility.test.ts index 378b327c7b0..402fccdb730 100644 --- a/tests/claude-integration/claude-compatibility.test.ts +++ b/tests/claude-integration/claude-compatibility.test.ts @@ -3,7 +3,6 @@ import { analyzeClaudeCompatibility, collectClaudeFeatureCodes, isClaudeCompatibilityMode, - resolveClaudeCompatibilityMode, } from "../../src/claude/compatibility"; import { AnthropicRequestError } from "../../src/claude/inbound"; import { enforceClaudeCompatibility } from "../../src/server/claude-messages"; @@ -29,17 +28,49 @@ describe("Claude routed compatibility foundation", () => { expect(result.featureCodes).not.toContain("mcp_tool"); }); + test("does not mistake a client function name for hosted code execution", () => { + const result = analyzeClaudeCompatibility({ + tools: [{ type: "function", name: "safe_code_execution", input_schema: { type: "object" } }], + }, { mode: "enforce", adapter: "openai-chat" }); + expect(result).toMatchObject({ compatible: true, decision: "allow" }); + expect(result.featureCodes).not.toContain("code_execution"); + }); + + test("recognizes cache control only at Anthropic block positions", () => { + expect(collectClaudeFeatureCodes({ + tools: [{ + type: "function", + name: "lookup", + input_schema: { + type: "object", + properties: { cache_control: { type: "string" } }, + }, + }], + })).not.toContain("cache_control"); + expect(collectClaudeFeatureCodes({ + system: [{ type: "text", text: "stable", cache_control: { type: "ephemeral" } }], + })).toContain("cache_control"); + expect(analyzeClaudeCompatibility({ + system: [{ type: "text", text: "stable", cache_control: { type: "ephemeral" } }], + }, { mode: "enforce", adapter: "openai-chat" })).toMatchObject({ compatible: true, decision: "allow" }); + }); + + test("recognizes deferred tools by activation value, not field presence", () => { + expect(collectClaudeFeatureCodes({ defer_tools: false, deferred_tools: [] })).not.toContain("deferred_tools"); + expect(collectClaudeFeatureCodes({ tools: [{ type: "function", name: "later", defer_loading: true }] })).toContain("deferred_tools"); + expect(collectClaudeFeatureCodes({ deferred_tools: ["later"] })).toContain("deferred_tools"); + }); + test("shadow mode reports, but does not reject, an incompatible feature", () => { const result = analyzeClaudeCompatibility({ context_management: { edits: [{ op: "remove" }] } }, { mode: "shadow", adapter: "openai-chat" }); expect(result).toMatchObject({ compatible: true, decision: "shadow" }); expect(result.reason).toContain("context_management"); }); - test("mode resolution is strict and defaults to enforce", () => { + test("mode recognition is strict", () => { expect(isClaudeCompatibilityMode("shadow")).toBe(true); + expect(isClaudeCompatibilityMode("enforce")).toBe(true); expect(isClaudeCompatibilityMode("invalid")).toBe(false); - expect(resolveClaudeCompatibilityMode()).toBe("enforce"); - expect(resolveClaudeCompatibilityMode({ compatibility: "shadow" })).toBe("shadow"); }); test("the Messages endpoint gate is opt-in, fail-closed, and reason-bounded", () => { diff --git a/tests/claude-integration/claude-messages-endpoint.test.ts b/tests/claude-integration/claude-messages-endpoint.test.ts index 558555a43e1..80ef6d4fa5e 100644 --- a/tests/claude-integration/claude-messages-endpoint.test.ts +++ b/tests/claude-integration/claude-messages-endpoint.test.ts @@ -1385,6 +1385,44 @@ test("claudeCode.enabled=false -> 403 permission_error on both routes", async () } }); +test("Claude compatibility enforcement is opt-in at the Messages endpoint", async () => { + const { server: upstream, captured } = mockChatUpstreamCapturing(); + const baseUrl = `${upstream.url.toString().replace(/\/$/, "")}/v1`; + const body = { + model: "mock/test-model", + max_tokens: 64, + stream: true, + messages: [{ + role: "user", + content: [{ type: "document", source: { type: "text", media_type: "text/plain", data: "fixture" } }], + }], + }; + + saveConfig(mockConfig(baseUrl)); + const legacyServer = startServer(0); + try { + const response = await postMessages(legacyServer.url.toString(), body); + expect(response.status).toBe(200); + await response.text(); + expect(captured).toHaveLength(1); + } finally { + await legacyServer.stop(true); + } + + saveConfig(mockConfig(baseUrl, { compatibility: "enforce" })); + const enforcingServer = startServer(0); + try { + const response = await postMessages(enforcingServer.url.toString(), body); + expect(response.status).toBe(400); + const payload = await response.json() as Record; + expect(payload.error.type).toBe("invalid_request_error"); + expect(captured).toHaveLength(1); + } finally { + await enforcingServer.stop(true); + await upstream.stop(true); + } +}); + async function postMessages(serverUrl: string, body: Record): Promise { return fetch(new URL("/v1/messages", serverUrl), { method: "POST", From 0ad0f5479b9d798235ea6a79a2c4bbae112ded24 Mon Sep 17 00:00:00 2001 From: Yumi Date: Sat, 5 Sep 2026 20:21:12 -0600 Subject: [PATCH 5/9] fix(claude): retain bounded shadow evidence Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- .../docs/reference/configuration/server.md | 2 +- src/claude/compatibility.ts | 9 +++-- src/server/claude-messages.ts | 16 ++++++--- src/server/request-log.ts | 34 ++++++++++++++++++- .../claude-compatibility.test.ts | 16 +++++++++ .../claude-messages-endpoint.test.ts | 29 ++++++++++++++++ 6 files changed, 95 insertions(+), 11 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index f22cc01e3f9..08eb6e38d84 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -317,7 +317,7 @@ These settings govern `/v1/messages`, `/v1/messages/count_tokens`, the `ocx clau | --- | --- | --- | --- | | `claudeCode.bodyStallSec?` | `number` | `90` | Native-passthrough body inactivity budget in seconds while a read is pending, not total duration. Minimum 1; exactly `0` disables. | | `claudeCode.bodyMaxBytes?` | `number` | `67108864` | Cumulative native-passthrough body cap for streamed and buffered responses. Exactly `0` disables. | -| `claudeCode.compatibility?` | `"shadow" \| "enforce"` | unset | Optional routed-protocol compatibility gate. `shadow` reports compatibility decisions without rejecting; `enforce` rejects Anthropic features that cannot be represented losslessly by the selected routed adapter. Unset preserves legacy translation behavior. | +| `claudeCode.compatibility?` | `"shadow" \| "enforce"` | unset | Optional routed-protocol compatibility gate. `shadow` records bounded decisions in the request-log ring without rejecting; `enforce` rejects Anthropic features that cannot be represented losslessly by the selected routed adapter. Unset preserves legacy translation behavior. | | `claudeCode.authMode?` | `"proxy" \| "subscription"` | auto | How launch handles `ANTHROPIC_AUTH_TOKEN`. Auto detects auth each launch; an explicit value is never overridden. | | `claudeCode.authModeMigratedAt?` | `string` | unset | Internal one-time upgrade marker. Do not set manually. | | `claudeCode.classifierModel?` | `string` | unset | Explicit target for Claude Code Auto Mode classifier turns, as a qualified `provider/model` (for example `RelayA/claude-opus-5`). Auto Mode sends bare safety checks such as `claude-opus-5` with no provider, so without this they fall through to `defaultProvider` — which may not speak Anthropic at all. Nothing is inferred automatically: only a target you declare here is used. | diff --git a/src/claude/compatibility.ts b/src/claude/compatibility.ts index 3625c639904..9d11b1def89 100644 --- a/src/claude/compatibility.ts +++ b/src/claude/compatibility.ts @@ -39,7 +39,7 @@ export interface ClaudeCompatibilityResult { featureCodes: string[]; compatible: boolean; decision: ClaudeCompatibilityDecision; - /** Human-readable reason when rejected, otherwise undefined. */ + /** Bounded human-readable reason when rejected or shadowed. */ reason?: string; } @@ -52,7 +52,7 @@ function isRec(v: unknown): v is Rec { function sanitizeBetaToken(raw: string): string { const trimmed = raw.trim().toLowerCase(); if (!trimmed) return ""; - return trimmed.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); + return trimmed.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 48); } function hasCacheControl(body: Rec): boolean { @@ -272,7 +272,6 @@ function hasToolSearch(body: Rec): boolean { for (const t of tools) { if (!isRec(t)) continue; if (typeof t.type === "string" && (t.type === "tool_search" || t.type.startsWith("tool_search_tool_"))) return true; - if (typeof t.name === "string" && (t.name === "tool_search" || t.name.startsWith("tool_search_tool_"))) return true; } } const msgs = body.messages; @@ -284,7 +283,7 @@ function hasToolSearch(body: Rec): boolean { for (const b of content) { if (!isRec(b)) continue; if (b.type === "tool_search_tool_result") return true; - if ((b.type === "tool_use" || b.type === "server_tool_use") + if (b.type === "server_tool_use" && typeof b.name === "string" && (b.name === "tool_search" || b.name.startsWith("tool_search_tool_"))) return true; } @@ -385,7 +384,7 @@ export function collectClaudeFeatureCodes( codes.push(`beta_${sanitized}`); } } - return [...new Set(codes)].sort(); + return [...new Set(codes)].sort().slice(0, 32); } /** diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 11da40cd2b8..8ab9f23e151 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -16,7 +16,7 @@ import { resolveAlias, claudeCodeNativeAlias } from "../claude/alias"; import { recordDesktopRequest } from "../claude/desktop-health"; import { stripOneMillionMarker } from "../claude/context-windows"; import { captureClaudeInbound } from "../claude/inbound-debug"; -import { analyzeClaudeCompatibility } from "../claude/compatibility"; +import { analyzeClaudeCompatibility, type ClaudeCompatibilityResult } from "../claude/compatibility"; import { isTransientUpstreamStatus } from "../lib/upstream-retry"; import { resolveClientRetryAfter } from "../lib/retry-after"; import { @@ -69,8 +69,8 @@ type Rec = Record; export function enforceClaudeCompatibility( body: unknown, opts: { mode?: unknown; adapter: string; anthropicBeta?: string }, -): void { - if (opts.mode !== "shadow" && opts.mode !== "enforce") return; +): ClaudeCompatibilityResult | undefined { + if (opts.mode !== "shadow" && opts.mode !== "enforce") return undefined; const result = analyzeClaudeCompatibility(body, { mode: opts.mode, adapter: opts.adapter, @@ -79,6 +79,7 @@ export function enforceClaudeCompatibility( if (result.decision === "reject") { throw new AnthropicRequestError(result.reason ?? "unsupported Claude protocol feature"); } + return result; } /** @@ -784,11 +785,18 @@ async function handleClaudeMessagesWithBudget( // Opt-in protocol gate: native passthrough has already returned above. Keep the // legacy translator behavior unchanged when unset; enforce only an explicitly // requested mode and never include request data in the bounded error reason. - enforceClaudeCompatibility(anthropicBody, { + const compatibility = enforceClaudeCompatibility(anthropicBody, { mode: config.claudeCode?.compatibility, adapter: route.provider.adapter, anthropicBeta: req.headers.get("anthropic-beta") ?? undefined, }); + if (compatibility?.decision === "shadow") { + logCtx.claudeCompatibility = { + decision: "shadow", + featureCodes: compatibility.featureCodes, + ...(compatibility.reason ? { reason: compatibility.reason } : {}), + }; + } if (route.provider.adapter === "openai-responses") { nativeRoute = true; delete internalBody.max_output_tokens; diff --git a/src/server/request-log.ts b/src/server/request-log.ts index a4c942bd883..8bfaaa5e35a 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -138,6 +138,14 @@ export interface RequestLogContext { terminalSource?: "upstream" | "synthetic"; /** Bounded route-decision trace (RI-01); never contains secrets. */ routeDecision?: RouteDecisionTraceV1; + /** Bounded, non-payload Claude compatibility evidence for opt-in shadow mode. */ + claudeCompatibility?: ClaudeCompatibilityLog; +} + +export interface ClaudeCompatibilityLog { + decision: "shadow"; + featureCodes: string[]; + reason?: string; } export interface RequestLogEntry { @@ -203,6 +211,8 @@ export interface RequestLogEntry { terminalSource?: "upstream" | "synthetic"; /** Bounded route-decision trace (RI-01); never contains secrets. */ routeDecision?: RouteDecisionTraceV1; + /** Bounded, non-payload Claude compatibility evidence for opt-in shadow mode. */ + claudeCompatibility?: ClaudeCompatibilityLog; } const requestLog: RequestLogEntry[] = []; @@ -248,6 +258,20 @@ export function evictOldestRequestLogForBudget(): number { return removeOldestRequestLogEntry(); } +function normalizeClaudeCompatibilityLog(value: ClaudeCompatibilityLog | undefined): ClaudeCompatibilityLog | undefined { + if (value?.decision !== "shadow" || !Array.isArray(value.featureCodes)) return undefined; + const featureCodes = [...new Set(value.featureCodes + .filter(code => typeof code === "string" && /^[a-z0-9_]{1,64}$/.test(code)))] + .sort() + .slice(0, 32); + const reason = sanitizeLogMetadataString(value.reason, 512); + return { + decision: "shadow", + featureCodes, + ...(reason ? { reason } : {}), + }; +} + function asTerminalStatus(value: string | undefined): ResponsesTerminalStatus | undefined { if (value === "completed" || value === "failed" || value === "incomplete") return value; return undefined; @@ -368,10 +392,17 @@ export function addRequestLog(entry: RequestLogEntry) { // line-oriented viewer — while `usage.jsonl` looked clean, which is the worst shape for a // sanitization bug because the safe surface is the one you check. const shadowCallRewrittenFrom = sanitizeLogMetadataString(entry.shadowCallRewrittenFrom); + const claudeCompatibility = normalizeClaudeCompatibilityLog(entry.claudeCompatibility); const retained: RequestLogEntry = shadowCallRewrittenFrom === entry.shadowCallRewrittenFrom + && claudeCompatibility === entry.claudeCompatibility ? entry - : { ...entry, ...(shadowCallRewrittenFrom ? { shadowCallRewrittenFrom } : {}) }; + : { + ...entry, + ...(shadowCallRewrittenFrom ? { shadowCallRewrittenFrom } : {}), + ...(claudeCompatibility ? { claudeCompatibility } : {}), + }; if (!shadowCallRewrittenFrom && retained !== entry) delete retained.shadowCallRewrittenFrom; + if (!claudeCompatibility && retained !== entry) delete retained.claudeCompatibility; entry = retained; retainRequestLogEntry(entry); try { @@ -1034,6 +1065,7 @@ export function addFinalRequestLog( ...(logCtx.transportPhase ? { transportPhase: logCtx.transportPhase } : {}), ...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}), ...(logCtx.routeDecision ? { routeDecision: logCtx.routeDecision } : {}), + ...(logCtx.claudeCompatibility ? { claudeCompatibility: logCtx.claudeCompatibility } : {}), }); if (isUsageDebugEnabled()) { appendUsageDebug({ diff --git a/tests/claude-integration/claude-compatibility.test.ts b/tests/claude-integration/claude-compatibility.test.ts index 402fccdb730..2c9e2531ff5 100644 --- a/tests/claude-integration/claude-compatibility.test.ts +++ b/tests/claude-integration/claude-compatibility.test.ts @@ -36,6 +36,15 @@ describe("Claude routed compatibility foundation", () => { expect(result.featureCodes).not.toContain("code_execution"); }); + test("does not mistake client function names or tool_use blocks for hosted tool search", () => { + const result = analyzeClaudeCompatibility({ + tools: [{ type: "function", name: "tool_search_tool_local", input_schema: { type: "object" } }], + messages: [{ role: "assistant", content: [{ type: "tool_use", name: "tool_search", id: "toolu_1", input: {} }] }], + }, { mode: "enforce", adapter: "openai-chat" }); + expect(result).toMatchObject({ compatible: true, decision: "allow" }); + expect(result.featureCodes).not.toContain("tool_search"); + }); + test("recognizes cache control only at Anthropic block positions", () => { expect(collectClaudeFeatureCodes({ tools: [{ @@ -67,6 +76,13 @@ describe("Claude routed compatibility foundation", () => { expect(result.reason).toContain("context_management"); }); + test("feature evidence is bounded", () => { + const betas = Array.from({ length: 40 }, (_, index) => `feature-${index}-${"x".repeat(100)}`).join(","); + const codes = collectClaudeFeatureCodes({}, betas); + expect(codes).toHaveLength(32); + expect(codes.every(code => code.length <= 53)).toBe(true); + }); + test("mode recognition is strict", () => { expect(isClaudeCompatibilityMode("shadow")).toBe(true); expect(isClaudeCompatibilityMode("enforce")).toBe(true); diff --git a/tests/claude-integration/claude-messages-endpoint.test.ts b/tests/claude-integration/claude-messages-endpoint.test.ts index 80ef6d4fa5e..297e1e9b749 100644 --- a/tests/claude-integration/claude-messages-endpoint.test.ts +++ b/tests/claude-integration/claude-messages-endpoint.test.ts @@ -1423,6 +1423,35 @@ test("Claude compatibility enforcement is opt-in at the Messages endpoint", asyn } }); +test("Claude compatibility shadow decisions reach the bounded request-log ring", async () => { + clearRequestLogsForTests(); + const { server: upstream } = mockChatUpstreamCapturing(); + const baseUrl = `${upstream.url.toString().replace(/\/$/, "")}/v1`; + saveConfig(mockConfig(baseUrl, { compatibility: "shadow" })); + const server = startServer(0); + try { + const response = await postMessages(server.url.toString(), { + model: "mock/test-model", + max_tokens: 64, + stream: true, + messages: [{ + role: "user", + content: [{ type: "document", source: { type: "text", media_type: "text/plain", data: "fixture" } }], + }], + }); + expect(response.status).toBe(200); + await response.text(); + expect(getRequestLogEntries().at(-1)?.claudeCompatibility).toEqual({ + decision: "shadow", + featureCodes: ["documents"], + reason: "shadow: would reject for documents", + }); + } finally { + await server.stop(true); + await upstream.stop(true); + } +}); + async function postMessages(serverUrl: string, body: Record): Promise { return fetch(new URL("/v1/messages", serverUrl), { method: "POST", From 0b64931af5d510d2855f1be5be5fa85dd3ea50de Mon Sep 17 00:00:00 2001 From: Yumi Date: Sat, 5 Sep 2026 21:08:09 -0600 Subject: [PATCH 6/9] fix(claude): keep tool search evidence singular --- src/claude/compatibility.ts | 178 +++++++++++--- .../claude-compatibility.test.ts | 217 ++++++++++++++---- 2 files changed, 316 insertions(+), 79 deletions(-) diff --git a/src/claude/compatibility.ts b/src/claude/compatibility.ts index 9d11b1def89..a066b779a0b 100644 --- a/src/claude/compatibility.ts +++ b/src/claude/compatibility.ts @@ -29,8 +29,13 @@ export type ClaudeCompatibilityMode = "shadow" | "enforce"; export const CLAUDE_COMPATIBILITY_MODES = ["shadow", "enforce"] as const; -export function isClaudeCompatibilityMode(value: unknown): value is ClaudeCompatibilityMode { - return typeof value === "string" && (CLAUDE_COMPATIBILITY_MODES as readonly string[]).includes(value); +export function isClaudeCompatibilityMode( + value: unknown, +): value is ClaudeCompatibilityMode { + return ( + typeof value === "string" && + (CLAUDE_COMPATIBILITY_MODES as readonly string[]).includes(value) + ); } export type ClaudeCompatibilityDecision = "allow" | "reject" | "shadow"; @@ -52,20 +57,28 @@ function isRec(v: unknown): v is Rec { function sanitizeBetaToken(raw: string): string { const trimmed = raw.trim().toLowerCase(); if (!trimmed) return ""; - return trimmed.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 48); + return trimmed + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 48); } function hasCacheControl(body: Rec): boolean { if (Object.prototype.hasOwnProperty.call(body, "cache_control")) return true; const blockHasCacheControl = (value: unknown): boolean => - isRec(value) && Object.prototype.hasOwnProperty.call(value, "cache_control"); - if (Array.isArray(body.system) && body.system.some(blockHasCacheControl)) return true; - if (Array.isArray(body.tools) && body.tools.some(blockHasCacheControl)) return true; + isRec(value) && + Object.prototype.hasOwnProperty.call(value, "cache_control"); + if (Array.isArray(body.system) && body.system.some(blockHasCacheControl)) + return true; + if (Array.isArray(body.tools) && body.tools.some(blockHasCacheControl)) + return true; if (!Array.isArray(body.messages)) return false; - return body.messages.some(message => - isRec(message) - && Array.isArray(message.content) - && message.content.some(blockHasCacheControl)); + return body.messages.some( + (message) => + isRec(message) && + Array.isArray(message.content) && + message.content.some(blockHasCacheControl), + ); } function hasThinkingBlock(body: Rec): boolean { @@ -78,7 +91,8 @@ function hasThinkingBlock(body: Rec): boolean { if (Array.isArray(content)) { for (const b of content) { if (!isRec(b)) continue; - if (b.type === "thinking" || b.type === "redacted_thinking") return true; + if (b.type === "thinking" || b.type === "redacted_thinking") + return true; } } } @@ -86,8 +100,16 @@ function hasThinkingBlock(body: Rec): boolean { } const KNOWN_CONTENT_TYPES = new Set([ - "text", "image", "tool_use", "tool_result", "thinking", "redacted_thinking", - "document", "server_tool_use", "web_search_tool_result", "code_execution_tool_result", + "text", + "image", + "tool_use", + "tool_result", + "thinking", + "redacted_thinking", + "document", + "server_tool_use", + "web_search_tool_result", + "code_execution_tool_result", "tool_search_tool_result", ]); @@ -140,7 +162,12 @@ function hasCodeExecution(body: Rec): boolean { for (const b of content) { if (!isRec(b)) continue; if (b.type === "code_execution_tool_result") return true; - if (b.type === "server_tool_use" && typeof b.name === "string" && b.name.includes("code_execution")) return true; + if ( + b.type === "server_tool_use" && + typeof b.name === "string" && + b.name.includes("code_execution") + ) + return true; } } } @@ -164,7 +191,8 @@ function hasComputerUse(body: Rec): boolean { if (!Array.isArray(content)) continue; for (const b of content) { if (!isRec(b)) continue; - if (typeof b.type === "string" && b.type.includes("computer")) return true; + if (typeof b.type === "string" && b.type.includes("computer")) + return true; } } return false; @@ -185,7 +213,14 @@ function hasMcpTool(body: Rec): boolean { if (Array.isArray(messages)) { for (const message of messages) { if (!isRec(message) || !Array.isArray(message.content)) continue; - if (message.content.some(block => isRec(block) && (block.type === "mcp_tool_use" || block.type === "mcp_tool_result"))) return true; + if ( + message.content.some( + (block) => + isRec(block) && + (block.type === "mcp_tool_use" || block.type === "mcp_tool_result"), + ) + ) + return true; } } return false; @@ -209,7 +244,12 @@ function hasWebSearchTool(body: Rec): boolean { for (const b of content) { if (!isRec(b)) continue; if (b.type === "web_search_tool_result") return true; - if (b.type === "server_tool_use" && typeof b.name === "string" && b.name.includes("web_search")) return true; + if ( + b.type === "server_tool_use" && + typeof b.name === "string" && + b.name.includes("web_search") + ) + return true; } } } @@ -229,7 +269,14 @@ function hasGenericServerTool(body: Rec): boolean { if (!isRec(b)) continue; if (b.type !== "server_tool_use") continue; const name = typeof b.name === "string" ? b.name : ""; - if (name.includes("web_search") || name.includes("code_execution") || name.includes("computer") || name.startsWith("tool_search_tool_")) continue; + if ( + name.includes("web_search") || + name.includes("code_execution") || + name.includes("computer") || + name === "tool_search" || + name.startsWith("tool_search_tool_") + ) + continue; return true; } } @@ -241,9 +288,17 @@ function hasGenericServerTool(body: Rec): boolean { // Exclude known function tools, tool_search, and already-classified server tools const type = typeof t.type === "string" ? t.type : ""; const name = typeof t.name === "string" ? t.name : ""; - if (name === "tool_search" || name.startsWith("tool_search_tool_")) continue; - if (type === "tool_search" || type.startsWith("tool_search_tool_")) continue; - if (type.includes("web_search") || type.includes("code_execution") || type.includes("computer") || type.startsWith("mcp")) continue; + if (name === "tool_search" || name.startsWith("tool_search_tool_")) + continue; + if (type === "tool_search" || type.startsWith("tool_search_tool_")) + continue; + if ( + type.includes("web_search") || + type.includes("code_execution") || + type.includes("computer") || + type.startsWith("mcp") + ) + continue; if (type && type !== "function") return true; if (type && typeof t.name !== "string") return true; } @@ -257,7 +312,14 @@ function hasGenericServerTool(body: Rec): boolean { if (!isRec(b)) continue; if (b.type === "server_tool_use") { const n = typeof b.name === "string" ? b.name : ""; - if (n.includes("web_search") || n.includes("code_execution") || n.includes("computer") || n.startsWith("tool_search_tool_")) continue; + if ( + n.includes("web_search") || + n.includes("code_execution") || + n.includes("computer") || + n === "tool_search" || + n.startsWith("tool_search_tool_") + ) + continue; return true; } } @@ -271,7 +333,11 @@ function hasToolSearch(body: Rec): boolean { if (Array.isArray(tools)) { for (const t of tools) { if (!isRec(t)) continue; - if (typeof t.type === "string" && (t.type === "tool_search" || t.type.startsWith("tool_search_tool_"))) return true; + if ( + typeof t.type === "string" && + (t.type === "tool_search" || t.type.startsWith("tool_search_tool_")) + ) + return true; } } const msgs = body.messages; @@ -283,9 +349,12 @@ function hasToolSearch(body: Rec): boolean { for (const b of content) { if (!isRec(b)) continue; if (b.type === "tool_search_tool_result") return true; - if (b.type === "server_tool_use" - && typeof b.name === "string" - && (b.name === "tool_search" || b.name.startsWith("tool_search_tool_"))) return true; + if ( + b.type === "server_tool_use" && + typeof b.name === "string" && + (b.name === "tool_search" || b.name.startsWith("tool_search_tool_")) + ) + return true; } } } @@ -303,7 +372,9 @@ function hasDeferredTools(body: Rec): boolean { } if (body.defer_tools === true || body.deferred_tools === true) return true; if (Array.isArray(body.deferred_tools)) return body.deferred_tools.length > 0; - return isRec(body.deferred_tools) && Object.keys(body.deferred_tools).length > 0; + return ( + isRec(body.deferred_tools) && Object.keys(body.deferred_tools).length > 0 + ); } function hasInputExamples(body: Rec): boolean { @@ -328,7 +399,10 @@ function hasStructuredOutput(body: Rec): boolean { } function hasServiceTier(body: Rec): boolean { - return typeof body.service_tier === "string" && (body.service_tier as string).length > 0; + return ( + typeof body.service_tier === "string" && + (body.service_tier as string).length > 0 + ); } function hasContextManagement(body: Rec): boolean { @@ -336,14 +410,32 @@ function hasContextManagement(body: Rec): boolean { } const KNOWN_BODY_FIELDS = new Set([ - "model", "max_tokens", "messages", "system", "tools", "tool_choice", "thinking", - "output_config", "metadata", "service_tier", "stop_sequences", "stream", - "temperature", "top_p", "top_k", "cache_control", "context_management", - "container", "inference_geo", "user_profile_id", "defer_tools", "deferred_tools", + "model", + "max_tokens", + "messages", + "system", + "tools", + "tool_choice", + "thinking", + "output_config", + "metadata", + "service_tier", + "stop_sequences", + "stream", + "temperature", + "top_p", + "top_k", + "cache_control", + "context_management", + "container", + "inference_geo", + "user_profile_id", + "defer_tools", + "deferred_tools", ]); function hasUnknownBodyField(body: Rec): boolean { - return Object.keys(body).some(field => !KNOWN_BODY_FIELDS.has(field)); + return Object.keys(body).some((field) => !KNOWN_BODY_FIELDS.has(field)); } /** @@ -394,7 +486,11 @@ export function collectClaudeFeatureCodes( */ export function analyzeClaudeCompatibility( body: unknown, - opts: { mode: ClaudeCompatibilityMode; adapter?: string; anthropicBeta?: string }, + opts: { + mode: ClaudeCompatibilityMode; + adapter?: string; + anthropicBeta?: string; + }, ): ClaudeCompatibilityResult { const featureCodes = collectClaudeFeatureCodes(body, opts.anthropicBeta); if (opts.adapter === "anthropic") { @@ -420,13 +516,17 @@ export function analyzeClaudeCompatibility( "server_tool", ]); if (opts.adapter !== "openai-responses") INCOMPATIBLE.add("deferred_tools"); - const incompatible = featureCodes.filter(c => INCOMPATIBLE.has(c)).slice(0, 32); + const incompatible = featureCodes + .filter((c) => INCOMPATIBLE.has(c)) + .slice(0, 32); if (opts.mode === "shadow") { return { featureCodes, compatible: true, decision: incompatible.length > 0 ? "shadow" : "allow", - ...(incompatible.length > 0 ? { reason: `shadow: would reject for ${incompatible.join(", ")}` } : {}), + ...(incompatible.length > 0 + ? { reason: `shadow: would reject for ${incompatible.join(", ")}` } + : {}), }; } if (incompatible.length > 0) { @@ -434,7 +534,11 @@ export function analyzeClaudeCompatibility( featureCodes, compatible: false, decision: "reject", - reason: `unsupported features for routed adapter ${opts.adapter ?? "unknown"}: ${incompatible.join(", ")}. Select an Anthropic route, remove the feature, or begin a fresh reasoning turn`.slice(0, 512), + reason: + `unsupported features for routed adapter ${opts.adapter ?? "unknown"}: ${incompatible.join(", ")}. Select an Anthropic route, remove the feature, or begin a fresh reasoning turn`.slice( + 0, + 512, + ), }; } return { featureCodes, compatible: true, decision: "allow" }; diff --git a/tests/claude-integration/claude-compatibility.test.ts b/tests/claude-integration/claude-compatibility.test.ts index 2c9e2531ff5..bdfaf643fa3 100644 --- a/tests/claude-integration/claude-compatibility.test.ts +++ b/tests/claude-integration/claude-compatibility.test.ts @@ -9,78 +9,192 @@ import { enforceClaudeCompatibility } from "../../src/server/claude-messages"; describe("Claude routed compatibility foundation", () => { test("collects stable feature codes without reading credentials", () => { - expect(collectClaudeFeatureCodes({ - model: "claude-sonnet", - messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], - tools: [{ type: "web_search_20250305", name: "web_search" }], - }, "context-1m-2025-08-07")).toEqual(["beta_context_1m_2025_08_07", "web_search_tool"]); + expect( + collectClaudeFeatureCodes( + { + model: "claude-sonnet", + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + tools: [{ type: "web_search_20250305", name: "web_search" }], + }, + "context-1m-2025-08-07", + ), + ).toEqual(["beta_context_1m_2025_08_07", "web_search_tool"]); }); test("fails closed for unmappable routed content while native Anthropic stays allowed", () => { - const body = { messages: [{ role: "user", content: [{ type: "document", title: "x" }] }] }; - expect(analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "openai-chat" }).decision).toBe("reject"); - expect(analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "anthropic" })).toMatchObject({ compatible: true, decision: "allow" }); + const body = { + messages: [{ role: "user", content: [{ type: "document", title: "x" }] }], + }; + expect( + analyzeClaudeCompatibility(body, { + mode: "enforce", + adapter: "openai-chat", + }).decision, + ).toBe("reject"); + expect( + analyzeClaudeCompatibility(body, { + mode: "enforce", + adapter: "anthropic", + }), + ).toMatchObject({ compatible: true, decision: "allow" }); }); test("does not mistake a client function name for MCP admission", () => { - const result = analyzeClaudeCompatibility({ tools: [{ type: "function", name: "mcp_lookup", input_schema: { type: "object" } }] }, { mode: "enforce", adapter: "openai-chat" }); + const result = analyzeClaudeCompatibility( + { + tools: [ + { + type: "function", + name: "mcp_lookup", + input_schema: { type: "object" }, + }, + ], + }, + { mode: "enforce", adapter: "openai-chat" }, + ); expect(result).toMatchObject({ compatible: true, decision: "allow" }); expect(result.featureCodes).not.toContain("mcp_tool"); }); test("does not mistake a client function name for hosted code execution", () => { - const result = analyzeClaudeCompatibility({ - tools: [{ type: "function", name: "safe_code_execution", input_schema: { type: "object" } }], - }, { mode: "enforce", adapter: "openai-chat" }); + const result = analyzeClaudeCompatibility( + { + tools: [ + { + type: "function", + name: "safe_code_execution", + input_schema: { type: "object" }, + }, + ], + }, + { mode: "enforce", adapter: "openai-chat" }, + ); expect(result).toMatchObject({ compatible: true, decision: "allow" }); expect(result.featureCodes).not.toContain("code_execution"); }); test("does not mistake client function names or tool_use blocks for hosted tool search", () => { - const result = analyzeClaudeCompatibility({ - tools: [{ type: "function", name: "tool_search_tool_local", input_schema: { type: "object" } }], - messages: [{ role: "assistant", content: [{ type: "tool_use", name: "tool_search", id: "toolu_1", input: {} }] }], - }, { mode: "enforce", adapter: "openai-chat" }); + const result = analyzeClaudeCompatibility( + { + tools: [ + { + type: "function", + name: "tool_search_tool_local", + input_schema: { type: "object" }, + }, + ], + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + name: "tool_search", + id: "toolu_1", + input: {}, + }, + ], + }, + ], + }, + { mode: "enforce", adapter: "openai-chat" }, + ); expect(result).toMatchObject({ compatible: true, decision: "allow" }); expect(result.featureCodes).not.toContain("tool_search"); }); + test("classifies hosted tool search without also adding generic server-tool evidence", () => { + const result = collectClaudeFeatureCodes({ + messages: [ + { + role: "assistant", + content: [ + { + type: "server_tool_use", + name: "tool_search", + id: "srvtoolu_1", + input: {}, + }, + ], + }, + ], + }); + expect(result).toEqual(["tool_search"]); + }); + test("recognizes cache control only at Anthropic block positions", () => { - expect(collectClaudeFeatureCodes({ - tools: [{ - type: "function", - name: "lookup", - input_schema: { - type: "object", - properties: { cache_control: { type: "string" } }, + expect( + collectClaudeFeatureCodes({ + tools: [ + { + type: "function", + name: "lookup", + input_schema: { + type: "object", + properties: { cache_control: { type: "string" } }, + }, + }, + ], + }), + ).not.toContain("cache_control"); + expect( + collectClaudeFeatureCodes({ + system: [ + { + type: "text", + text: "stable", + cache_control: { type: "ephemeral" }, + }, + ], + }), + ).toContain("cache_control"); + expect( + analyzeClaudeCompatibility( + { + system: [ + { + type: "text", + text: "stable", + cache_control: { type: "ephemeral" }, + }, + ], }, - }], - })).not.toContain("cache_control"); - expect(collectClaudeFeatureCodes({ - system: [{ type: "text", text: "stable", cache_control: { type: "ephemeral" } }], - })).toContain("cache_control"); - expect(analyzeClaudeCompatibility({ - system: [{ type: "text", text: "stable", cache_control: { type: "ephemeral" } }], - }, { mode: "enforce", adapter: "openai-chat" })).toMatchObject({ compatible: true, decision: "allow" }); + { mode: "enforce", adapter: "openai-chat" }, + ), + ).toMatchObject({ compatible: true, decision: "allow" }); }); test("recognizes deferred tools by activation value, not field presence", () => { - expect(collectClaudeFeatureCodes({ defer_tools: false, deferred_tools: [] })).not.toContain("deferred_tools"); - expect(collectClaudeFeatureCodes({ tools: [{ type: "function", name: "later", defer_loading: true }] })).toContain("deferred_tools"); - expect(collectClaudeFeatureCodes({ deferred_tools: ["later"] })).toContain("deferred_tools"); + expect( + collectClaudeFeatureCodes({ defer_tools: false, deferred_tools: [] }), + ).not.toContain("deferred_tools"); + expect( + collectClaudeFeatureCodes({ + tools: [{ type: "function", name: "later", defer_loading: true }], + }), + ).toContain("deferred_tools"); + expect(collectClaudeFeatureCodes({ deferred_tools: ["later"] })).toContain( + "deferred_tools", + ); }); test("shadow mode reports, but does not reject, an incompatible feature", () => { - const result = analyzeClaudeCompatibility({ context_management: { edits: [{ op: "remove" }] } }, { mode: "shadow", adapter: "openai-chat" }); + const result = analyzeClaudeCompatibility( + { context_management: { edits: [{ op: "remove" }] } }, + { mode: "shadow", adapter: "openai-chat" }, + ); expect(result).toMatchObject({ compatible: true, decision: "shadow" }); expect(result.reason).toContain("context_management"); }); test("feature evidence is bounded", () => { - const betas = Array.from({ length: 40 }, (_, index) => `feature-${index}-${"x".repeat(100)}`).join(","); + const betas = Array.from( + { length: 40 }, + (_, index) => `feature-${index}-${"x".repeat(100)}`, + ).join(","); const codes = collectClaudeFeatureCodes({}, betas); expect(codes).toHaveLength(32); - expect(codes.every(code => code.length <= 53)).toBe(true); + expect(codes.every((code) => code.length <= 53)).toBe(true); }); test("mode recognition is strict", () => { @@ -90,12 +204,31 @@ describe("Claude routed compatibility foundation", () => { }); test("the Messages endpoint gate is opt-in, fail-closed, and reason-bounded", () => { - const body = { messages: [{ role: "user", content: [{ type: "document", title: "secret-name" }] }] }; - expect(() => enforceClaudeCompatibility(body, { adapter: "openai-chat" })).not.toThrow(); - expect(() => enforceClaudeCompatibility(body, { mode: "shadow", adapter: "openai-chat" })).not.toThrow(); - expect(() => enforceClaudeCompatibility(body, { mode: "enforce", adapter: "openai-chat" })).toThrow(AnthropicRequestError); + const body = { + messages: [ + { role: "user", content: [{ type: "document", title: "secret-name" }] }, + ], + }; + expect(() => + enforceClaudeCompatibility(body, { adapter: "openai-chat" }), + ).not.toThrow(); + expect(() => + enforceClaudeCompatibility(body, { + mode: "shadow", + adapter: "openai-chat", + }), + ).not.toThrow(); + expect(() => + enforceClaudeCompatibility(body, { + mode: "enforce", + adapter: "openai-chat", + }), + ).toThrow(AnthropicRequestError); try { - enforceClaudeCompatibility(body, { mode: "enforce", adapter: "openai-chat" }); + enforceClaudeCompatibility(body, { + mode: "enforce", + adapter: "openai-chat", + }); } catch (error) { expect(error).toBeInstanceOf(Error); expect((error as Error).message.length).toBeLessThanOrEqual(512); From d5a08c3d6e314340a0dd64d15627b38aa3a572f7 Mon Sep 17 00:00:00 2001 From: Yumi Date: Sat, 5 Sep 2026 21:08:21 -0600 Subject: [PATCH 7/9] Revert "fix(claude): keep tool search evidence singular" This reverts commit 42a4fdd0654b7dbea66e3f8053afbf46fffcb37d. --- src/claude/compatibility.ts | 178 +++----------- .../claude-compatibility.test.ts | 217 ++++-------------- 2 files changed, 79 insertions(+), 316 deletions(-) diff --git a/src/claude/compatibility.ts b/src/claude/compatibility.ts index a066b779a0b..9d11b1def89 100644 --- a/src/claude/compatibility.ts +++ b/src/claude/compatibility.ts @@ -29,13 +29,8 @@ export type ClaudeCompatibilityMode = "shadow" | "enforce"; export const CLAUDE_COMPATIBILITY_MODES = ["shadow", "enforce"] as const; -export function isClaudeCompatibilityMode( - value: unknown, -): value is ClaudeCompatibilityMode { - return ( - typeof value === "string" && - (CLAUDE_COMPATIBILITY_MODES as readonly string[]).includes(value) - ); +export function isClaudeCompatibilityMode(value: unknown): value is ClaudeCompatibilityMode { + return typeof value === "string" && (CLAUDE_COMPATIBILITY_MODES as readonly string[]).includes(value); } export type ClaudeCompatibilityDecision = "allow" | "reject" | "shadow"; @@ -57,28 +52,20 @@ function isRec(v: unknown): v is Rec { function sanitizeBetaToken(raw: string): string { const trimmed = raw.trim().toLowerCase(); if (!trimmed) return ""; - return trimmed - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_+|_+$/g, "") - .slice(0, 48); + return trimmed.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 48); } function hasCacheControl(body: Rec): boolean { if (Object.prototype.hasOwnProperty.call(body, "cache_control")) return true; const blockHasCacheControl = (value: unknown): boolean => - isRec(value) && - Object.prototype.hasOwnProperty.call(value, "cache_control"); - if (Array.isArray(body.system) && body.system.some(blockHasCacheControl)) - return true; - if (Array.isArray(body.tools) && body.tools.some(blockHasCacheControl)) - return true; + isRec(value) && Object.prototype.hasOwnProperty.call(value, "cache_control"); + if (Array.isArray(body.system) && body.system.some(blockHasCacheControl)) return true; + if (Array.isArray(body.tools) && body.tools.some(blockHasCacheControl)) return true; if (!Array.isArray(body.messages)) return false; - return body.messages.some( - (message) => - isRec(message) && - Array.isArray(message.content) && - message.content.some(blockHasCacheControl), - ); + return body.messages.some(message => + isRec(message) + && Array.isArray(message.content) + && message.content.some(blockHasCacheControl)); } function hasThinkingBlock(body: Rec): boolean { @@ -91,8 +78,7 @@ function hasThinkingBlock(body: Rec): boolean { if (Array.isArray(content)) { for (const b of content) { if (!isRec(b)) continue; - if (b.type === "thinking" || b.type === "redacted_thinking") - return true; + if (b.type === "thinking" || b.type === "redacted_thinking") return true; } } } @@ -100,16 +86,8 @@ function hasThinkingBlock(body: Rec): boolean { } const KNOWN_CONTENT_TYPES = new Set([ - "text", - "image", - "tool_use", - "tool_result", - "thinking", - "redacted_thinking", - "document", - "server_tool_use", - "web_search_tool_result", - "code_execution_tool_result", + "text", "image", "tool_use", "tool_result", "thinking", "redacted_thinking", + "document", "server_tool_use", "web_search_tool_result", "code_execution_tool_result", "tool_search_tool_result", ]); @@ -162,12 +140,7 @@ function hasCodeExecution(body: Rec): boolean { for (const b of content) { if (!isRec(b)) continue; if (b.type === "code_execution_tool_result") return true; - if ( - b.type === "server_tool_use" && - typeof b.name === "string" && - b.name.includes("code_execution") - ) - return true; + if (b.type === "server_tool_use" && typeof b.name === "string" && b.name.includes("code_execution")) return true; } } } @@ -191,8 +164,7 @@ function hasComputerUse(body: Rec): boolean { if (!Array.isArray(content)) continue; for (const b of content) { if (!isRec(b)) continue; - if (typeof b.type === "string" && b.type.includes("computer")) - return true; + if (typeof b.type === "string" && b.type.includes("computer")) return true; } } return false; @@ -213,14 +185,7 @@ function hasMcpTool(body: Rec): boolean { if (Array.isArray(messages)) { for (const message of messages) { if (!isRec(message) || !Array.isArray(message.content)) continue; - if ( - message.content.some( - (block) => - isRec(block) && - (block.type === "mcp_tool_use" || block.type === "mcp_tool_result"), - ) - ) - return true; + if (message.content.some(block => isRec(block) && (block.type === "mcp_tool_use" || block.type === "mcp_tool_result"))) return true; } } return false; @@ -244,12 +209,7 @@ function hasWebSearchTool(body: Rec): boolean { for (const b of content) { if (!isRec(b)) continue; if (b.type === "web_search_tool_result") return true; - if ( - b.type === "server_tool_use" && - typeof b.name === "string" && - b.name.includes("web_search") - ) - return true; + if (b.type === "server_tool_use" && typeof b.name === "string" && b.name.includes("web_search")) return true; } } } @@ -269,14 +229,7 @@ function hasGenericServerTool(body: Rec): boolean { if (!isRec(b)) continue; if (b.type !== "server_tool_use") continue; const name = typeof b.name === "string" ? b.name : ""; - if ( - name.includes("web_search") || - name.includes("code_execution") || - name.includes("computer") || - name === "tool_search" || - name.startsWith("tool_search_tool_") - ) - continue; + if (name.includes("web_search") || name.includes("code_execution") || name.includes("computer") || name.startsWith("tool_search_tool_")) continue; return true; } } @@ -288,17 +241,9 @@ function hasGenericServerTool(body: Rec): boolean { // Exclude known function tools, tool_search, and already-classified server tools const type = typeof t.type === "string" ? t.type : ""; const name = typeof t.name === "string" ? t.name : ""; - if (name === "tool_search" || name.startsWith("tool_search_tool_")) - continue; - if (type === "tool_search" || type.startsWith("tool_search_tool_")) - continue; - if ( - type.includes("web_search") || - type.includes("code_execution") || - type.includes("computer") || - type.startsWith("mcp") - ) - continue; + if (name === "tool_search" || name.startsWith("tool_search_tool_")) continue; + if (type === "tool_search" || type.startsWith("tool_search_tool_")) continue; + if (type.includes("web_search") || type.includes("code_execution") || type.includes("computer") || type.startsWith("mcp")) continue; if (type && type !== "function") return true; if (type && typeof t.name !== "string") return true; } @@ -312,14 +257,7 @@ function hasGenericServerTool(body: Rec): boolean { if (!isRec(b)) continue; if (b.type === "server_tool_use") { const n = typeof b.name === "string" ? b.name : ""; - if ( - n.includes("web_search") || - n.includes("code_execution") || - n.includes("computer") || - n === "tool_search" || - n.startsWith("tool_search_tool_") - ) - continue; + if (n.includes("web_search") || n.includes("code_execution") || n.includes("computer") || n.startsWith("tool_search_tool_")) continue; return true; } } @@ -333,11 +271,7 @@ function hasToolSearch(body: Rec): boolean { if (Array.isArray(tools)) { for (const t of tools) { if (!isRec(t)) continue; - if ( - typeof t.type === "string" && - (t.type === "tool_search" || t.type.startsWith("tool_search_tool_")) - ) - return true; + if (typeof t.type === "string" && (t.type === "tool_search" || t.type.startsWith("tool_search_tool_"))) return true; } } const msgs = body.messages; @@ -349,12 +283,9 @@ function hasToolSearch(body: Rec): boolean { for (const b of content) { if (!isRec(b)) continue; if (b.type === "tool_search_tool_result") return true; - if ( - b.type === "server_tool_use" && - typeof b.name === "string" && - (b.name === "tool_search" || b.name.startsWith("tool_search_tool_")) - ) - return true; + if (b.type === "server_tool_use" + && typeof b.name === "string" + && (b.name === "tool_search" || b.name.startsWith("tool_search_tool_"))) return true; } } } @@ -372,9 +303,7 @@ function hasDeferredTools(body: Rec): boolean { } if (body.defer_tools === true || body.deferred_tools === true) return true; if (Array.isArray(body.deferred_tools)) return body.deferred_tools.length > 0; - return ( - isRec(body.deferred_tools) && Object.keys(body.deferred_tools).length > 0 - ); + return isRec(body.deferred_tools) && Object.keys(body.deferred_tools).length > 0; } function hasInputExamples(body: Rec): boolean { @@ -399,10 +328,7 @@ function hasStructuredOutput(body: Rec): boolean { } function hasServiceTier(body: Rec): boolean { - return ( - typeof body.service_tier === "string" && - (body.service_tier as string).length > 0 - ); + return typeof body.service_tier === "string" && (body.service_tier as string).length > 0; } function hasContextManagement(body: Rec): boolean { @@ -410,32 +336,14 @@ function hasContextManagement(body: Rec): boolean { } const KNOWN_BODY_FIELDS = new Set([ - "model", - "max_tokens", - "messages", - "system", - "tools", - "tool_choice", - "thinking", - "output_config", - "metadata", - "service_tier", - "stop_sequences", - "stream", - "temperature", - "top_p", - "top_k", - "cache_control", - "context_management", - "container", - "inference_geo", - "user_profile_id", - "defer_tools", - "deferred_tools", + "model", "max_tokens", "messages", "system", "tools", "tool_choice", "thinking", + "output_config", "metadata", "service_tier", "stop_sequences", "stream", + "temperature", "top_p", "top_k", "cache_control", "context_management", + "container", "inference_geo", "user_profile_id", "defer_tools", "deferred_tools", ]); function hasUnknownBodyField(body: Rec): boolean { - return Object.keys(body).some((field) => !KNOWN_BODY_FIELDS.has(field)); + return Object.keys(body).some(field => !KNOWN_BODY_FIELDS.has(field)); } /** @@ -486,11 +394,7 @@ export function collectClaudeFeatureCodes( */ export function analyzeClaudeCompatibility( body: unknown, - opts: { - mode: ClaudeCompatibilityMode; - adapter?: string; - anthropicBeta?: string; - }, + opts: { mode: ClaudeCompatibilityMode; adapter?: string; anthropicBeta?: string }, ): ClaudeCompatibilityResult { const featureCodes = collectClaudeFeatureCodes(body, opts.anthropicBeta); if (opts.adapter === "anthropic") { @@ -516,17 +420,13 @@ export function analyzeClaudeCompatibility( "server_tool", ]); if (opts.adapter !== "openai-responses") INCOMPATIBLE.add("deferred_tools"); - const incompatible = featureCodes - .filter((c) => INCOMPATIBLE.has(c)) - .slice(0, 32); + const incompatible = featureCodes.filter(c => INCOMPATIBLE.has(c)).slice(0, 32); if (opts.mode === "shadow") { return { featureCodes, compatible: true, decision: incompatible.length > 0 ? "shadow" : "allow", - ...(incompatible.length > 0 - ? { reason: `shadow: would reject for ${incompatible.join(", ")}` } - : {}), + ...(incompatible.length > 0 ? { reason: `shadow: would reject for ${incompatible.join(", ")}` } : {}), }; } if (incompatible.length > 0) { @@ -534,11 +434,7 @@ export function analyzeClaudeCompatibility( featureCodes, compatible: false, decision: "reject", - reason: - `unsupported features for routed adapter ${opts.adapter ?? "unknown"}: ${incompatible.join(", ")}. Select an Anthropic route, remove the feature, or begin a fresh reasoning turn`.slice( - 0, - 512, - ), + reason: `unsupported features for routed adapter ${opts.adapter ?? "unknown"}: ${incompatible.join(", ")}. Select an Anthropic route, remove the feature, or begin a fresh reasoning turn`.slice(0, 512), }; } return { featureCodes, compatible: true, decision: "allow" }; diff --git a/tests/claude-integration/claude-compatibility.test.ts b/tests/claude-integration/claude-compatibility.test.ts index bdfaf643fa3..2c9e2531ff5 100644 --- a/tests/claude-integration/claude-compatibility.test.ts +++ b/tests/claude-integration/claude-compatibility.test.ts @@ -9,192 +9,78 @@ import { enforceClaudeCompatibility } from "../../src/server/claude-messages"; describe("Claude routed compatibility foundation", () => { test("collects stable feature codes without reading credentials", () => { - expect( - collectClaudeFeatureCodes( - { - model: "claude-sonnet", - messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], - tools: [{ type: "web_search_20250305", name: "web_search" }], - }, - "context-1m-2025-08-07", - ), - ).toEqual(["beta_context_1m_2025_08_07", "web_search_tool"]); + expect(collectClaudeFeatureCodes({ + model: "claude-sonnet", + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + tools: [{ type: "web_search_20250305", name: "web_search" }], + }, "context-1m-2025-08-07")).toEqual(["beta_context_1m_2025_08_07", "web_search_tool"]); }); test("fails closed for unmappable routed content while native Anthropic stays allowed", () => { - const body = { - messages: [{ role: "user", content: [{ type: "document", title: "x" }] }], - }; - expect( - analyzeClaudeCompatibility(body, { - mode: "enforce", - adapter: "openai-chat", - }).decision, - ).toBe("reject"); - expect( - analyzeClaudeCompatibility(body, { - mode: "enforce", - adapter: "anthropic", - }), - ).toMatchObject({ compatible: true, decision: "allow" }); + const body = { messages: [{ role: "user", content: [{ type: "document", title: "x" }] }] }; + expect(analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "openai-chat" }).decision).toBe("reject"); + expect(analyzeClaudeCompatibility(body, { mode: "enforce", adapter: "anthropic" })).toMatchObject({ compatible: true, decision: "allow" }); }); test("does not mistake a client function name for MCP admission", () => { - const result = analyzeClaudeCompatibility( - { - tools: [ - { - type: "function", - name: "mcp_lookup", - input_schema: { type: "object" }, - }, - ], - }, - { mode: "enforce", adapter: "openai-chat" }, - ); + const result = analyzeClaudeCompatibility({ tools: [{ type: "function", name: "mcp_lookup", input_schema: { type: "object" } }] }, { mode: "enforce", adapter: "openai-chat" }); expect(result).toMatchObject({ compatible: true, decision: "allow" }); expect(result.featureCodes).not.toContain("mcp_tool"); }); test("does not mistake a client function name for hosted code execution", () => { - const result = analyzeClaudeCompatibility( - { - tools: [ - { - type: "function", - name: "safe_code_execution", - input_schema: { type: "object" }, - }, - ], - }, - { mode: "enforce", adapter: "openai-chat" }, - ); + const result = analyzeClaudeCompatibility({ + tools: [{ type: "function", name: "safe_code_execution", input_schema: { type: "object" } }], + }, { mode: "enforce", adapter: "openai-chat" }); expect(result).toMatchObject({ compatible: true, decision: "allow" }); expect(result.featureCodes).not.toContain("code_execution"); }); test("does not mistake client function names or tool_use blocks for hosted tool search", () => { - const result = analyzeClaudeCompatibility( - { - tools: [ - { - type: "function", - name: "tool_search_tool_local", - input_schema: { type: "object" }, - }, - ], - messages: [ - { - role: "assistant", - content: [ - { - type: "tool_use", - name: "tool_search", - id: "toolu_1", - input: {}, - }, - ], - }, - ], - }, - { mode: "enforce", adapter: "openai-chat" }, - ); + const result = analyzeClaudeCompatibility({ + tools: [{ type: "function", name: "tool_search_tool_local", input_schema: { type: "object" } }], + messages: [{ role: "assistant", content: [{ type: "tool_use", name: "tool_search", id: "toolu_1", input: {} }] }], + }, { mode: "enforce", adapter: "openai-chat" }); expect(result).toMatchObject({ compatible: true, decision: "allow" }); expect(result.featureCodes).not.toContain("tool_search"); }); - test("classifies hosted tool search without also adding generic server-tool evidence", () => { - const result = collectClaudeFeatureCodes({ - messages: [ - { - role: "assistant", - content: [ - { - type: "server_tool_use", - name: "tool_search", - id: "srvtoolu_1", - input: {}, - }, - ], - }, - ], - }); - expect(result).toEqual(["tool_search"]); - }); - test("recognizes cache control only at Anthropic block positions", () => { - expect( - collectClaudeFeatureCodes({ - tools: [ - { - type: "function", - name: "lookup", - input_schema: { - type: "object", - properties: { cache_control: { type: "string" } }, - }, - }, - ], - }), - ).not.toContain("cache_control"); - expect( - collectClaudeFeatureCodes({ - system: [ - { - type: "text", - text: "stable", - cache_control: { type: "ephemeral" }, - }, - ], - }), - ).toContain("cache_control"); - expect( - analyzeClaudeCompatibility( - { - system: [ - { - type: "text", - text: "stable", - cache_control: { type: "ephemeral" }, - }, - ], + expect(collectClaudeFeatureCodes({ + tools: [{ + type: "function", + name: "lookup", + input_schema: { + type: "object", + properties: { cache_control: { type: "string" } }, }, - { mode: "enforce", adapter: "openai-chat" }, - ), - ).toMatchObject({ compatible: true, decision: "allow" }); + }], + })).not.toContain("cache_control"); + expect(collectClaudeFeatureCodes({ + system: [{ type: "text", text: "stable", cache_control: { type: "ephemeral" } }], + })).toContain("cache_control"); + expect(analyzeClaudeCompatibility({ + system: [{ type: "text", text: "stable", cache_control: { type: "ephemeral" } }], + }, { mode: "enforce", adapter: "openai-chat" })).toMatchObject({ compatible: true, decision: "allow" }); }); test("recognizes deferred tools by activation value, not field presence", () => { - expect( - collectClaudeFeatureCodes({ defer_tools: false, deferred_tools: [] }), - ).not.toContain("deferred_tools"); - expect( - collectClaudeFeatureCodes({ - tools: [{ type: "function", name: "later", defer_loading: true }], - }), - ).toContain("deferred_tools"); - expect(collectClaudeFeatureCodes({ deferred_tools: ["later"] })).toContain( - "deferred_tools", - ); + expect(collectClaudeFeatureCodes({ defer_tools: false, deferred_tools: [] })).not.toContain("deferred_tools"); + expect(collectClaudeFeatureCodes({ tools: [{ type: "function", name: "later", defer_loading: true }] })).toContain("deferred_tools"); + expect(collectClaudeFeatureCodes({ deferred_tools: ["later"] })).toContain("deferred_tools"); }); test("shadow mode reports, but does not reject, an incompatible feature", () => { - const result = analyzeClaudeCompatibility( - { context_management: { edits: [{ op: "remove" }] } }, - { mode: "shadow", adapter: "openai-chat" }, - ); + const result = analyzeClaudeCompatibility({ context_management: { edits: [{ op: "remove" }] } }, { mode: "shadow", adapter: "openai-chat" }); expect(result).toMatchObject({ compatible: true, decision: "shadow" }); expect(result.reason).toContain("context_management"); }); test("feature evidence is bounded", () => { - const betas = Array.from( - { length: 40 }, - (_, index) => `feature-${index}-${"x".repeat(100)}`, - ).join(","); + const betas = Array.from({ length: 40 }, (_, index) => `feature-${index}-${"x".repeat(100)}`).join(","); const codes = collectClaudeFeatureCodes({}, betas); expect(codes).toHaveLength(32); - expect(codes.every((code) => code.length <= 53)).toBe(true); + expect(codes.every(code => code.length <= 53)).toBe(true); }); test("mode recognition is strict", () => { @@ -204,31 +90,12 @@ describe("Claude routed compatibility foundation", () => { }); test("the Messages endpoint gate is opt-in, fail-closed, and reason-bounded", () => { - const body = { - messages: [ - { role: "user", content: [{ type: "document", title: "secret-name" }] }, - ], - }; - expect(() => - enforceClaudeCompatibility(body, { adapter: "openai-chat" }), - ).not.toThrow(); - expect(() => - enforceClaudeCompatibility(body, { - mode: "shadow", - adapter: "openai-chat", - }), - ).not.toThrow(); - expect(() => - enforceClaudeCompatibility(body, { - mode: "enforce", - adapter: "openai-chat", - }), - ).toThrow(AnthropicRequestError); + const body = { messages: [{ role: "user", content: [{ type: "document", title: "secret-name" }] }] }; + expect(() => enforceClaudeCompatibility(body, { adapter: "openai-chat" })).not.toThrow(); + expect(() => enforceClaudeCompatibility(body, { mode: "shadow", adapter: "openai-chat" })).not.toThrow(); + expect(() => enforceClaudeCompatibility(body, { mode: "enforce", adapter: "openai-chat" })).toThrow(AnthropicRequestError); try { - enforceClaudeCompatibility(body, { - mode: "enforce", - adapter: "openai-chat", - }); + enforceClaudeCompatibility(body, { mode: "enforce", adapter: "openai-chat" }); } catch (error) { expect(error).toBeInstanceOf(Error); expect((error as Error).message.length).toBeLessThanOrEqual(512); From 75ecf05ef5e70c73d6dbae712dd3217441036961 Mon Sep 17 00:00:00 2001 From: Yumi Date: Sat, 5 Sep 2026 21:08:55 -0600 Subject: [PATCH 8/9] fix(claude): keep tool search evidence singular --- src/claude/compatibility.ts | 4 ++-- tests/claude-integration/claude-compatibility.test.ts | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/claude/compatibility.ts b/src/claude/compatibility.ts index 9d11b1def89..f7553e7e94c 100644 --- a/src/claude/compatibility.ts +++ b/src/claude/compatibility.ts @@ -229,7 +229,7 @@ function hasGenericServerTool(body: Rec): boolean { if (!isRec(b)) continue; if (b.type !== "server_tool_use") continue; const name = typeof b.name === "string" ? b.name : ""; - if (name.includes("web_search") || name.includes("code_execution") || name.includes("computer") || name.startsWith("tool_search_tool_")) continue; + if (name.includes("web_search") || name.includes("code_execution") || name.includes("computer") || name === "tool_search" || name.startsWith("tool_search_tool_")) continue; return true; } } @@ -257,7 +257,7 @@ function hasGenericServerTool(body: Rec): boolean { if (!isRec(b)) continue; if (b.type === "server_tool_use") { const n = typeof b.name === "string" ? b.name : ""; - if (n.includes("web_search") || n.includes("code_execution") || n.includes("computer") || n.startsWith("tool_search_tool_")) continue; + if (n.includes("web_search") || n.includes("code_execution") || n.includes("computer") || n === "tool_search" || n.startsWith("tool_search_tool_")) continue; return true; } } diff --git a/tests/claude-integration/claude-compatibility.test.ts b/tests/claude-integration/claude-compatibility.test.ts index 2c9e2531ff5..4089e4aa027 100644 --- a/tests/claude-integration/claude-compatibility.test.ts +++ b/tests/claude-integration/claude-compatibility.test.ts @@ -45,6 +45,13 @@ describe("Claude routed compatibility foundation", () => { expect(result.featureCodes).not.toContain("tool_search"); }); + test("classifies hosted tool search without also adding generic server-tool evidence", () => { + const result = collectClaudeFeatureCodes({ + messages: [{ role: "assistant", content: [{ type: "server_tool_use", name: "tool_search", id: "srvtoolu_1", input: {} }] }], + }); + expect(result).toEqual(["tool_search"]); + }); + test("recognizes cache control only at Anthropic block positions", () => { expect(collectClaudeFeatureCodes({ tools: [{ From 18d64748ade8001e05327726b2ae4b22e8393418 Mon Sep 17 00:00:00 2001 From: Yumi Date: Sun, 6 Sep 2026 06:10:25 -0600 Subject: [PATCH 9/9] fix(claude): persist compatibility shadow evidence --- .../docs/reference/configuration/server.md | 2 +- src/server/request-log.ts | 29 ++++++------------- src/usage/log.ts | 26 +++++++++++++++++ .../claude-messages-endpoint.test.ts | 13 +++++++-- 4 files changed, 46 insertions(+), 24 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 08eb6e38d84..59246be4a65 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -317,7 +317,7 @@ These settings govern `/v1/messages`, `/v1/messages/count_tokens`, the `ocx clau | --- | --- | --- | --- | | `claudeCode.bodyStallSec?` | `number` | `90` | Native-passthrough body inactivity budget in seconds while a read is pending, not total duration. Minimum 1; exactly `0` disables. | | `claudeCode.bodyMaxBytes?` | `number` | `67108864` | Cumulative native-passthrough body cap for streamed and buffered responses. Exactly `0` disables. | -| `claudeCode.compatibility?` | `"shadow" \| "enforce"` | unset | Optional routed-protocol compatibility gate. `shadow` records bounded decisions in the request-log ring without rejecting; `enforce` rejects Anthropic features that cannot be represented losslessly by the selected routed adapter. Unset preserves legacy translation behavior. | +| `claudeCode.compatibility?` | `"shadow" \| "enforce"` | unset | Optional compatibility gate for routed Claude Messages translation only. Native Anthropic pass-through remains unchanged. `shadow` records bounded decisions in the persisted request log without rejecting; `enforce` rejects Anthropic features that cannot be represented losslessly by the selected routed adapter. Unset preserves legacy translation behavior. | | `claudeCode.authMode?` | `"proxy" \| "subscription"` | auto | How launch handles `ANTHROPIC_AUTH_TOKEN`. Auto detects auth each launch; an explicit value is never overridden. | | `claudeCode.authModeMigratedAt?` | `string` | unset | Internal one-time upgrade marker. Do not set manually. | | `claudeCode.classifierModel?` | `string` | unset | Explicit target for Claude Code Auto Mode classifier turns, as a qualified `provider/model` (for example `RelayA/claude-opus-5`). Auto Mode sends bare safety checks such as `claude-opus-5` with no provider, so without this they fall through to `defaultProvider` — which may not speak Anthropic at all. Nothing is inferred automatically: only a target you declare here is used. | diff --git a/src/server/request-log.ts b/src/server/request-log.ts index 8bfaaa5e35a..c708f1e08de 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -24,6 +24,7 @@ import { isKnownUsageSurface, isCodexUsageAccountLogLabel, isValidReasoningWireValue, + normalizeClaudeCompatibilityUsageLog, readRecentUsageEntries, usageForFinalLog, usageStatusForFinalLog, @@ -31,6 +32,7 @@ import { type AttemptRecoveryKind, type PersistedUsageAttempt, type PersistedUsageEntry, + type PersistedClaudeCompatibilityLog, type UsageStatus, } from "../usage/log"; import { @@ -142,11 +144,7 @@ export interface RequestLogContext { claudeCompatibility?: ClaudeCompatibilityLog; } -export interface ClaudeCompatibilityLog { - decision: "shadow"; - featureCodes: string[]; - reason?: string; -} +export type ClaudeCompatibilityLog = PersistedClaudeCompatibilityLog; export interface RequestLogEntry { requestId: string; @@ -258,20 +256,6 @@ export function evictOldestRequestLogForBudget(): number { return removeOldestRequestLogEntry(); } -function normalizeClaudeCompatibilityLog(value: ClaudeCompatibilityLog | undefined): ClaudeCompatibilityLog | undefined { - if (value?.decision !== "shadow" || !Array.isArray(value.featureCodes)) return undefined; - const featureCodes = [...new Set(value.featureCodes - .filter(code => typeof code === "string" && /^[a-z0-9_]{1,64}$/.test(code)))] - .sort() - .slice(0, 32); - const reason = sanitizeLogMetadataString(value.reason, 512); - return { - decision: "shadow", - featureCodes, - ...(reason ? { reason } : {}), - }; -} - function asTerminalStatus(value: string | undefined): ResponsesTerminalStatus | undefined { if (value === "completed" || value === "failed" || value === "incomplete") return value; return undefined; @@ -295,6 +279,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R const terminalStatus = asTerminalStatus(entry.terminalStatus); const closeReason = asCloseReason(entry.closeReason); const routeDecision = normalizeRouteDecisionTraceForLog(entry.routeDecision); + const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility); return { requestId: entry.requestId, timestamp: entry.timestamp, @@ -337,6 +322,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}), ...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}), ...(routeDecision ? { routeDecision } : {}), + ...(claudeCompatibility ? { claudeCompatibility } : {}), }; } @@ -392,7 +378,7 @@ export function addRequestLog(entry: RequestLogEntry) { // line-oriented viewer — while `usage.jsonl` looked clean, which is the worst shape for a // sanitization bug because the safe surface is the one you check. const shadowCallRewrittenFrom = sanitizeLogMetadataString(entry.shadowCallRewrittenFrom); - const claudeCompatibility = normalizeClaudeCompatibilityLog(entry.claudeCompatibility); + const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility); const retained: RequestLogEntry = shadowCallRewrittenFrom === entry.shadowCallRewrittenFrom && claudeCompatibility === entry.claudeCompatibility ? entry @@ -462,6 +448,9 @@ export function addRequestLog(entry: RequestLogEntry) { ...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}), ...failureDiagnostics, ...(entry.routeDecision ? { routeDecision: entry.routeDecision } : {}), + ...(entry.claudeCompatibility + ? { claudeCompatibility: entry.claudeCompatibility } + : {}), }); } catch { /* request logging must never fail a user request */ diff --git a/src/usage/log.ts b/src/usage/log.ts index 257cb86d3ef..fee03dde4af 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -11,6 +11,28 @@ import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routi import { ACCOUNT_LOG_LABEL_RE, CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label"; export type UsageStatus = "reported" | "unreported" | "unsupported" | "estimated"; + +export interface PersistedClaudeCompatibilityLog { + decision: "shadow"; + featureCodes: string[]; + reason?: string; +} + +export function normalizeClaudeCompatibilityUsageLog( + value: PersistedClaudeCompatibilityLog | undefined, +): PersistedClaudeCompatibilityLog | undefined { + if (value?.decision !== "shadow" || !Array.isArray(value.featureCodes)) return undefined; + const featureCodes = [...new Set(value.featureCodes + .filter(code => typeof code === "string" && /^[a-z0-9_]{1,64}$/.test(code)))] + .sort() + .slice(0, 32); + const reason = sanitizeLogMetadataString(value.reason, 512); + return { + decision: "shadow", + featureCodes, + ...(reason ? { reason } : {}), + }; +} /** * A persisted account label: a Codex pool account (`main`/`p`) or a non-Codex OAuth * provider account (`o`, #2699). @@ -160,6 +182,8 @@ export interface PersistedUsageEntry { * contains prompts, credentials, or hidden reasoning. */ routeDecision?: RouteDecisionTraceV1; + /** Bounded, non-payload Claude compatibility evidence for opt-in shadow mode. */ + claudeCompatibility?: PersistedClaudeCompatibilityLog; } const KNOWN_USAGE_SURFACES = new Set>([ @@ -493,6 +517,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { const routeDecision = entry.routeDecision ? normalizeRouteDecisionTrace(entry.routeDecision) : undefined; + const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility); return { requestId: entry.requestId, timestamp: entry.timestamp, @@ -563,6 +588,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { ...(entry.closeReason ? { closeReason: entry.closeReason } : {}), ...(entry.upstreamError ? { upstreamError: entry.upstreamError } : {}), ...(routeDecision ? { routeDecision } : {}), + ...(claudeCompatibility ? { claudeCompatibility } : {}), }; } diff --git a/tests/claude-integration/claude-messages-endpoint.test.ts b/tests/claude-integration/claude-messages-endpoint.test.ts index 297e1e9b749..3d6ce8f1476 100644 --- a/tests/claude-integration/claude-messages-endpoint.test.ts +++ b/tests/claude-integration/claude-messages-endpoint.test.ts @@ -5,6 +5,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; +import { readRecentUsageEntries } from "../../src/usage/log"; import { buildDesktop3pRegistry } from "../../src/claude/desktop-3p"; import type { DesktopProfile } from "../../src/claude/desktop-profile"; import { createAnthropicAdapter } from "../../src/adapters/anthropic"; @@ -12,6 +13,7 @@ import { clearableDeadline } from "../../src/lib/abort"; import { clearRequestLogsForTests, getRequestLogEntries, + hydrateRequestLogsFromDisk, type RequestLogContext, } from "../../src/server/request-log"; import { startServer } from "../../src/server"; @@ -1423,7 +1425,7 @@ test("Claude compatibility enforcement is opt-in at the Messages endpoint", asyn } }); -test("Claude compatibility shadow decisions reach the bounded request-log ring", async () => { +test("Claude compatibility shadow decisions survive the persisted request-log projection", async () => { clearRequestLogsForTests(); const { server: upstream } = mockChatUpstreamCapturing(); const baseUrl = `${upstream.url.toString().replace(/\/$/, "")}/v1`; @@ -1441,11 +1443,16 @@ test("Claude compatibility shadow decisions reach the bounded request-log ring", }); expect(response.status).toBe(200); await response.text(); - expect(getRequestLogEntries().at(-1)?.claudeCompatibility).toEqual({ + const expected = { decision: "shadow", featureCodes: ["documents"], reason: "shadow: would reject for documents", - }); + } as const; + expect(getRequestLogEntries().at(-1)?.claudeCompatibility).toEqual(expected); + expect(readRecentUsageEntries(1).at(-1)?.claudeCompatibility).toEqual(expected); + clearRequestLogsForTests(); + expect(hydrateRequestLogsFromDisk()).toBe(1); + expect(getRequestLogEntries().at(-1)?.claudeCompatibility).toEqual(expected); } finally { await server.stop(true); await upstream.stop(true);