From 4ee9ba9766aab97514fa9ba4b6ade1c0252d8839 Mon Sep 17 00:00:00 2001 From: OmniRoute Ops Date: Mon, 11 May 2026 11:41:46 +0000 Subject: [PATCH 1/6] fix(cliproxyapi): route Anthropic-shape bodies to /v1/messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When chatCore detects target=claude (source format = claude), it skips the openai translation and passes the Anthropic-shape body straight to the cliproxyapi executor. The executor's buildUrl() was hardcoded to /v1/chat/completions, so CPA received an Anthropic body on the OpenAI endpoint. CPA responded with an OpenAI-style SSE stream (choices[].message) which Anthropic SDK clients (Capy, claude-cli, etc.) cannot parse — server-side 200 with "request ended without sending any chunks" client-side. Fix: detect Anthropic body shape (top-level `system` as array OR messages[0].content as array) in execute() and route to /v1/messages. CPA natively supports both endpoints; routing to /v1/messages preserves the wire shape end-to-end. No translator round-trip required. OpenAI Chat passthrough cases (Capy in OpenAI API mode, Codex CLI, etc.) hit the false branch and keep the previous /v1/chat/completions route. Co-Authored-By: Claude Opus 4.7 (1M context) --- open-sse/executors/cliproxyapi.ts | 44 ++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/open-sse/executors/cliproxyapi.ts b/open-sse/executors/cliproxyapi.ts index 69e2f05bb8..2ab2545f41 100644 --- a/open-sse/executors/cliproxyapi.ts +++ b/open-sse/executors/cliproxyapi.ts @@ -64,11 +64,45 @@ export class CliproxyapiExecutor extends BaseExecutor { _urlIndex = 0, _credentials: ProviderCredentials | null = null ): string { - // Always OpenAI-compatible. CLIProxyAPI detects Claude models internally - // and applies full emulation (CCH, billing header, system prompt, uTLS). + // Default endpoint when called without body context (kept for back-compat). + // execute() picks the right endpoint from the body shape; see selectEndpoint(). return `${this.upstreamBaseUrl}/v1/chat/completions`; } + /** + * Returns true when the body matches the Anthropic Messages wire shape. + * + * chatCore detects target=claude when the request comes from a Claude-source + * client (`/v1/messages`, Anthropic-version header, claude/* model). In that + * case no openai translation is applied and the executor sees the original + * Anthropic body: top-level `system` as an array of content blocks, and + * `messages[].content` as arrays. Routing those bodies to CPA's + * /v1/chat/completions causes CPA to emit OpenAI-style SSE chunks, which + * Anthropic SDK clients (Capy, claude-cli, etc.) cannot parse — the result + * looks like a 200 server-side with "0 chunks received" client-side. + * + * CPA exposes /v1/messages natively (claude executor with uTLS spoof, + * billing header, CCH signing, etc.) and emits proper Anthropic SSE: + * `event: message_start`, `content_block_delta`, etc. + */ + private isAnthropicShape(body: unknown): boolean { + if (!body || typeof body !== "object") return false; + const b = body as Record; + // Strong signal: Claude Code cloak emits system as an array of content blocks + if (Array.isArray(b.system)) return true; + // Strong signal: messages[0].content is an array of Anthropic content blocks + const msgs = b.messages; + if (Array.isArray(msgs) && msgs.length > 0) { + const first = msgs[0] as Record; + if (Array.isArray(first?.content)) return true; + } + return false; + } + + private selectEndpoint(body: unknown): string { + return this.isAnthropicShape(body) ? "/v1/messages" : "/v1/chat/completions"; + } + buildHeaders(credentials: ProviderCredentials | null, stream = true): Record { const key = credentials?.apiKey || credentials?.accessToken; @@ -111,7 +145,9 @@ export class CliproxyapiExecutor extends BaseExecutor { log?: any; upstreamExtraHeaders?: Record | null; }) { - const url = this.buildUrl(input.model, input.stream, 0, input.credentials); + const endpoint = this.selectEndpoint(input.body); + const url = `${this.upstreamBaseUrl}${endpoint}`; + const shape = endpoint === "/v1/messages" ? "anthropic" : "openai"; const headers = this.buildHeaders(input.credentials, input.stream); const transformedBody = this.transformRequest( input.model, @@ -126,7 +162,7 @@ export class CliproxyapiExecutor extends BaseExecutor { ? mergeAbortSignals(input.signal, timeoutSignal) : timeoutSignal; - input.log?.info?.("CPA", `CLIProxyAPI → ${url} (model: ${input.model})`); + input.log?.info?.("CPA", `CLIProxyAPI → ${url} (model: ${input.model}, shape: ${shape})`); const response = await fetch(url, { method: "POST", From 05212b0a3f1db623ad065e3a70346652ea6731b0 Mon Sep 17 00:00:00 2001 From: OmniRoute Ops Date: Mon, 11 May 2026 11:50:38 +0000 Subject: [PATCH 2/6] fix(cliproxyapi): strip Capy premium extras on Anthropic-shape bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Capy (Anthropic SDK 0.90.0) BYOK Pro forwards a request via OmniRoute, it injects: - thinking: { type: "adaptive", display: "summarized" } - output_config: { effort: "xhigh" } ← premium tier - context_management: { edits: [...] } These extras request features that bill against Anthropic "extra usage" even on Claude Max subscriptions. Anthropic gates the request: 400 "You're out of extra usage. Add more at claude.ai/settings/usage" The existing strip lives in BaseExecutor's Claude OAuth cloak block, but the CliproxyapiExecutor extends BaseExecutor without inheriting that cloak (the block is gated on this.provider === "claude", but in CPA mode chatCore swaps the executor to "cliproxyapi" entirely). So Capy's extras reach CPA verbatim and CPA forwards them. Fix: in CliproxyapiExecutor.transformRequest(), when the body is in Anthropic shape (i.e. about to be POSTed to CPA's /v1/messages), strip the three extras. CPA still applies its own Claude Code wire-image cloak (CCH signing, billing header, system sentinel, uTLS) downstream. OpenAI-shape bodies are untouched. Mirrors the runtime "Patch I2/I4" effect previously applied via patch.mjs. Co-Authored-By: Claude Opus 4.7 (1M context) --- open-sse/executors/cliproxyapi.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/open-sse/executors/cliproxyapi.ts b/open-sse/executors/cliproxyapi.ts index 2ab2545f41..85ca6534f4 100644 --- a/open-sse/executors/cliproxyapi.ts +++ b/open-sse/executors/cliproxyapi.ts @@ -133,6 +133,21 @@ export class CliproxyapiExecutor extends BaseExecutor { transformed.model = model; } + // For Anthropic-shape bodies routed to CPA's /v1/messages, strip the + // Capy/Anthropic-SDK premium extras that Anthropic gates with + // "Extra usage is required" / "out of extra usage" (400). CPA does its + // own Claude Code wire-image cloak (CCH, billing header, uTLS, metadata + // user_id, system sentinel) downstream — but it forwards client extras + // like output_config.effort=xhigh which trigger the extras-billing gate. + // + // Mirrors the runtime "Patch I2/I4" effect previously applied via patch.mjs. + // Strips are no-op when fields are absent (OpenAI-shape passthrough). + if (this.isAnthropicShape(transformed)) { + delete transformed.thinking; + delete transformed.output_config; + delete transformed.context_management; + } + return transformed; } From 83f25922b05715737dcfd825312b9496a69b7de4 Mon Sep 17 00:00:00 2001 From: OmniRoute Ops Date: Mon, 11 May 2026 12:15:13 +0000 Subject: [PATCH 3/6] fix(cliproxyapi): rewrite mcp_* tool names to bypass Anthropic gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic Messages API silently gates client-declared tool names matching ^mcp_[^_].* behind their "Extra usage required" / "out of extra usage" 400 error — the prefix is reserved for their server-side MCP connector tools. The error is misleading: it looks like a quota exhaustion, but it's body- shape gating triggered purely by the regex match on `tools[].name`. Bisected character-by-character against the real Anthropic API via CPA (uTLS spoof, Claude OAuth account): Gate hit (HTTP 400): mcp_call, mcp_query, mcp_x, mcp_test, mcp_anything Bypass (HTTP 200): Mcp_call, MCP_call, _mcp_call, xmcp_call, mcp__call, mcp-call, mcpcall, my_mcp_call Independent of system prompt, metadata.user_id shape, thinking/output_config presence, request size, or tool count. Capy declares mcp_call + mcp_query for its MCP bridge, so every Capy Claude-BYOK request 400'd. Fix: in CliproxyapiExecutor.transformRequest, for Anthropic-shape bodies, rewrite tool names matching ^mcp_[^_] to "M" + name.slice(1) (mcp_call → Mcp_call). Same rewrite applied to: - tools[].name - messages[].content[type=tool_use].name (for multi-turn) - tool_choice.name (when type=tool) Build a reverse map {rewritten → original} and attach as body._toolNameMap. chatCore.ts:mergeResponseToolNameMap already reads this from finalBody and forwards it to the SSE passthrough stream (utils/stream.ts:restoreClaudePassthroughToolUseName), which rewrites tool_use.name back to the client's original namespace on response chunks. End-to-end: Capy sends mcp_call → CPA/Anthropic sees Mcp_call → response tool_use blocks emit Mcp_call → client receives mcp_call. Capy's dispatch keyed on mcp_call works unchanged. _toolNameMap is filtered out of the JSON.stringify body sent to CPA so the in-memory channel doesn't leak over the wire. --- open-sse/executors/cliproxyapi.ts | 96 ++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/open-sse/executors/cliproxyapi.ts b/open-sse/executors/cliproxyapi.ts index 85ca6534f4..dd048a0531 100644 --- a/open-sse/executors/cliproxyapi.ts +++ b/open-sse/executors/cliproxyapi.ts @@ -27,6 +27,73 @@ const DEFAULT_PORT = 8317; const DEFAULT_HOST = "127.0.0.1"; const HEALTH_CHECK_TIMEOUT_MS = 5000; +// Anthropic's reserved tool-name namespace: ^mcp_[^_].* triggers their +// server-side MCP connector billing gate, returning a misleading +// "out of extra usage" 400. Two-underscore (mcp__X) and capitalized +// (Mcp_X) variants pass cleanly. +const MCP_RESERVED_PREFIX_RE = /^mcp_(?=[^_])/; + +function rewriteMcpToolName(name: string): string | null { + if (typeof name !== "string" || !MCP_RESERVED_PREFIX_RE.test(name)) return null; + return "M" + name.slice(1); // mcp_call → Mcp_call +} + +function applyMcpToolNameRewrite(body: Record): Map { + const reverseMap = new Map(); + const remember = (original: string, rewritten: string) => { + reverseMap.set(rewritten, original); + }; + + const tools = body.tools; + if (Array.isArray(tools)) { + for (const tool of tools) { + if (!tool || typeof tool !== "object") continue; + const t = tool as Record; + const original = typeof t.name === "string" ? t.name : ""; + const rewritten = rewriteMcpToolName(original); + if (rewritten) { + t.name = rewritten; + remember(original, rewritten); + } + } + } + + const messages = body.messages; + if (Array.isArray(messages)) { + for (const msg of messages) { + if (!msg || typeof msg !== "object") continue; + const content = (msg as Record).content; + if (!Array.isArray(content)) continue; + for (const block of content) { + if (!block || typeof block !== "object") continue; + const b = block as Record; + if (b.type !== "tool_use") continue; + const original = typeof b.name === "string" ? b.name : ""; + const rewritten = rewriteMcpToolName(original); + if (rewritten) { + b.name = rewritten; + remember(original, rewritten); + } + } + } + } + + const toolChoice = body.tool_choice; + if (toolChoice && typeof toolChoice === "object") { + const tc = toolChoice as Record; + if (tc.type === "tool" && typeof tc.name === "string") { + const rewritten = rewriteMcpToolName(tc.name); + if (rewritten) { + const original = tc.name; + tc.name = rewritten; + remember(original, rewritten); + } + } + } + + return reverseMap; +} + function resolveCliproxyapiBaseUrl(): string { const host = process.env.CLIPROXYAPI_HOST || DEFAULT_HOST; const port = parseInt(process.env.CLIPROXYAPI_PORT || String(DEFAULT_PORT), 10); @@ -146,6 +213,24 @@ export class CliproxyapiExecutor extends BaseExecutor { delete transformed.thinking; delete transformed.output_config; delete transformed.context_management; + + // Rewrite tool names matching Anthropic's reserved ^mcp_[^_] namespace. + // Anthropic returns "out of extra usage" / "Extra usage required" 400 + // when a client-declared tool name collides with their server-side MCP + // connector tools. Bisected character-by-character against the real + // Anthropic API via CPA (uTLS spoof, Claude OAuth): + // mcp_call, mcp_query, mcp_x, mcp_test → 400 (gate hit) + // Mcp_call, _mcp_call, mcp__call, mcp-call, mcpcall, my_mcp_call → 200 + // The "Mcp_" capitalization is the smallest stable rewrite that + // preserves readability. The reverse map below is propagated to + // chatCore via body._toolNameMap, which the SSE passthrough stream + // uses (utils/stream.ts:restoreClaudePassthroughToolUseName) to + // rewrite tool_use.name back to the client's original namespace on + // the response side. Capy sees mcp_call back in tool_use blocks. + const toolNameMap = applyMcpToolNameRewrite(transformed); + if (toolNameMap.size > 0) { + transformed._toolNameMap = toolNameMap; + } } return transformed; @@ -179,10 +264,19 @@ export class CliproxyapiExecutor extends BaseExecutor { input.log?.info?.("CPA", `CLIProxyAPI → ${url} (model: ${input.model}, shape: ${shape})`); + // _toolNameMap is an in-memory channel to chatCore for response-side + // tool name restoration; never send it over the wire. + const wireBody = + transformedBody && typeof transformedBody === "object" + ? JSON.stringify(transformedBody, (key, value) => + key === "_toolNameMap" ? undefined : value + ) + : JSON.stringify(transformedBody); + const response = await fetch(url, { method: "POST", headers, - body: JSON.stringify(transformedBody), + body: wireBody, signal: combinedSignal, }); From c1b004c74e570b36c17a6e12561367e2ead89774 Mon Sep 17 00:00:00 2001 From: OmniRoute Ops Date: Mon, 11 May 2026 16:27:52 +0000 Subject: [PATCH 4/6] address review: extra fields strip + non-mutating tool rewrite + system string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per gemini-code-assist review on #2165: 1. Extra strip-list fields (PR description claimed but implementation missed): - client_info, prompt_cache_key, safety_identifier, metadata These trigger Anthropic's "Extra usage required" 400 the same way output_config does on CPA's /v1/messages surface. 2. applyMcpToolNameRewrite no longer mutates the nested input body. The shallow clone produced by transformRequest's `{...body}` left nested tools/messages/tool_choice as shared references with the original body, so direct `t.name = rewritten` assignments leaked back to the caller. Now returns a new array for tools (cloning the rewritten entries), a new messages array with cloned content blocks for each message containing a rewritten tool_use, and a cloned tool_choice object when rewrite is needed. Unchanged elements share references to keep memory churn minimal. 3. isAnthropicShape now treats any top-level `system` field as a strong signal — including the string form (Anthropic supports both `system: "Rules"` and `system: [{type: "text", text: "Rules"}]`). Previously only the array form was recognized, so plain-string Anthropic-shape bodies were routed to /v1/chat/completions and returned OpenAI SSE that Anthropic SDK clients can't decode. On tool_result tool_use_id rewriting: tool_use_id is the opaque id field of the corresponding tool_use block (e.g. "toolu_01abc"), not the tool name. Anthropic's ^mcp_[^_] gate fires on `name`, not `id`, so ids do not need rewriting. The PR description claim to rewrite tool_use_id refs was overspecified relative to the actual constraint. --- open-sse/executors/cliproxyapi.ts | 59 ++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/open-sse/executors/cliproxyapi.ts b/open-sse/executors/cliproxyapi.ts index dd048a0531..36106b15ab 100644 --- a/open-sse/executors/cliproxyapi.ts +++ b/open-sse/executors/cliproxyapi.ts @@ -38,6 +38,18 @@ function rewriteMcpToolName(name: string): string | null { return "M" + name.slice(1); // mcp_call → Mcp_call } +/** + * Rewrite ^mcp_[^_] tool names on a body destined for Anthropic's + * /v1/messages. Returns a reverse map (rewritten → original) that the SSE + * response stream uses to restore the client's original names on tool_use + * blocks coming back. + * + * Non-mutating: replaces nested array elements with cloned objects rather + * than mutating in place, so the caller's input body is not affected. The + * outer `body` reference itself is the cloned `transformed` object from + * `transformRequest` — we mutate its top-level `tools`, `messages`, and + * `tool_choice` properties to point at the new clones. + */ function applyMcpToolNameRewrite(body: Record): Map { const reverseMap = new Map(); const remember = (original: string, rewritten: string) => { @@ -46,36 +58,42 @@ function applyMcpToolNameRewrite(body: Record): Map { + if (!tool || typeof tool !== "object") return tool; const t = tool as Record; const original = typeof t.name === "string" ? t.name : ""; const rewritten = rewriteMcpToolName(original); if (rewritten) { - t.name = rewritten; remember(original, rewritten); + return { ...t, name: rewritten }; } - } + return tool; + }); } const messages = body.messages; if (Array.isArray(messages)) { - for (const msg of messages) { - if (!msg || typeof msg !== "object") continue; - const content = (msg as Record).content; - if (!Array.isArray(content)) continue; - for (const block of content) { - if (!block || typeof block !== "object") continue; + body.messages = messages.map((msg) => { + if (!msg || typeof msg !== "object") return msg; + const m = msg as Record; + const content = m.content; + if (!Array.isArray(content)) return msg; + let mutated = false; + const newContent = content.map((block) => { + if (!block || typeof block !== "object") return block; const b = block as Record; - if (b.type !== "tool_use") continue; + if (b.type !== "tool_use") return block; const original = typeof b.name === "string" ? b.name : ""; const rewritten = rewriteMcpToolName(original); if (rewritten) { - b.name = rewritten; + mutated = true; remember(original, rewritten); + return { ...b, name: rewritten }; } - } - } + return block; + }); + return mutated ? { ...m, content: newContent } : msg; + }); } const toolChoice = body.tool_choice; @@ -85,7 +103,7 @@ function applyMcpToolNameRewrite(body: Record): Map; - // Strong signal: Claude Code cloak emits system as an array of content blocks - if (Array.isArray(b.system)) return true; + // Strong signal: top-level `system` field is unique to the Anthropic + // Messages API. OpenAI Chat Completions encodes system as a role:"system" + // entry inside messages[], not at body level. Accept both string and + // array-of-content-blocks forms (Anthropic supports both per the docs). + if (b.system !== undefined) return true; // Strong signal: messages[0].content is an array of Anthropic content blocks const msgs = b.messages; if (Array.isArray(msgs) && msgs.length > 0) { @@ -213,6 +234,10 @@ export class CliproxyapiExecutor extends BaseExecutor { delete transformed.thinking; delete transformed.output_config; delete transformed.context_management; + delete transformed.client_info; + delete transformed.prompt_cache_key; + delete transformed.safety_identifier; + delete transformed.metadata; // Rewrite tool names matching Anthropic's reserved ^mcp_[^_] namespace. // Anthropic returns "out of extra usage" / "Extra usage required" 400 From f946f6e0a73d7b7ec448a2d1ba39d49b3fb933b1 Mon Sep 17 00:00:00 2001 From: OmniRoute Ops Date: Mon, 11 May 2026 20:00:54 +0000 Subject: [PATCH 5/6] fix(cliproxyapi): conditional thinking strip to preserve valid Anthropic shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up after testing the Capy BYOK flow. The unconditional `delete transformed.thinking` was killing legitimate thinking configs that applyThinkingBudget had already converted to Anthropic-valid form ({type:"enabled"|"disabled", budget_tokens:N}). Replace with a conditional strip that: - Preserves Anthropic-valid shapes (enabled/disabled + numeric budget_tokens, no extra fields) - Strips Capy/Anthropic-SDK shapes Anthropic doesn't accept (type:"adaptive", or presence of the Capy-specific `display` field) Symptom we observed: clients sending `thinking: {type:"adaptive", display:"summarized"}` on /v1/messages saw plain text responses with no thinking block. Their UIs (e.g. Capy) fell back to rendering answer text inside the empty "Thought" section. With applyThinkingBudget in adaptive mode (default for many user configs), the body reaching this executor already has a valid Anthropic shape — the unconditional strip was undoing that work. Bodies that bypass applyThinkingBudget (passthrough mode) still get stripped here because Anthropic 400s on the unconverted shape. 5 new test cases cover the preserve/strip matrix on Anthropic-shape and OpenAI-shape bodies. Co-Authored-By: Claude Opus 4.7 (1M context) --- open-sse/executors/cliproxyapi.ts | 18 +++++++- tests/unit/cliproxyapi-executor.test.ts | 59 +++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/open-sse/executors/cliproxyapi.ts b/open-sse/executors/cliproxyapi.ts index 36106b15ab..601d100e03 100644 --- a/open-sse/executors/cliproxyapi.ts +++ b/open-sse/executors/cliproxyapi.ts @@ -231,7 +231,6 @@ export class CliproxyapiExecutor extends BaseExecutor { // Mirrors the runtime "Patch I2/I4" effect previously applied via patch.mjs. // Strips are no-op when fields are absent (OpenAI-shape passthrough). if (this.isAnthropicShape(transformed)) { - delete transformed.thinking; delete transformed.output_config; delete transformed.context_management; delete transformed.client_info; @@ -239,6 +238,23 @@ export class CliproxyapiExecutor extends BaseExecutor { delete transformed.safety_identifier; delete transformed.metadata; + // Conditional thinking strip: preserve Anthropic-valid shapes + // ({type:"enabled"|"disabled", budget_tokens:N}) that applyThinkingBudget + // already normalized. Strip non-Anthropic shapes (e.g. Capy's + // {type:"adaptive", display:"summarized"}) which trigger Anthropic 400 + // "Extra usage required" / "out of extra usage". The `display` field is + // a Capy-specific hint Anthropic doesn't accept. + const thinking = transformed.thinking; + if (thinking && typeof thinking === "object") { + const t = thinking as Record; + const validType = t.type === "enabled" || t.type === "disabled"; + const hasValidBudget = typeof t.budget_tokens === "number" && t.budget_tokens >= 0; + const hasInvalidExtras = "display" in t; + if (!validType || !hasValidBudget || hasInvalidExtras) { + delete transformed.thinking; + } + } + // Rewrite tool names matching Anthropic's reserved ^mcp_[^_] namespace. // Anthropic returns "out of extra usage" / "Extra usage required" 400 // when a client-declared tool name collides with their server-side MCP diff --git a/tests/unit/cliproxyapi-executor.test.ts b/tests/unit/cliproxyapi-executor.test.ts index cc37ccb8a3..640372eb2d 100644 --- a/tests/unit/cliproxyapi-executor.test.ts +++ b/tests/unit/cliproxyapi-executor.test.ts @@ -129,6 +129,65 @@ describe("CliproxyapiExecutor", () => { const result = exec.transformRequest("model", null, true, {}); assert.equal(result, null); }); + + it("preserves Anthropic-valid thinking shape on /v1/messages routing", () => { + const exec = new CliproxyapiExecutor(); + const body = { + model: "claude-opus-4-7", + system: [{ type: "text", text: "Be helpful" }], + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + thinking: { type: "enabled", budget_tokens: 10240 }, + }; + const result = exec.transformRequest("claude-opus-4-7", body, true, {}); + assert.deepEqual(result.thinking, { type: "enabled", budget_tokens: 10240 }); + }); + + it("preserves disabled thinking shape on /v1/messages routing", () => { + const exec = new CliproxyapiExecutor(); + const body = { + model: "claude-opus-4-7", + system: [{ type: "text", text: "x" }], + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + thinking: { type: "disabled", budget_tokens: 0 }, + }; + const result = exec.transformRequest("claude-opus-4-7", body, true, {}); + assert.deepEqual(result.thinking, { type: "disabled", budget_tokens: 0 }); + }); + + it("strips Capy-style adaptive thinking on /v1/messages routing", () => { + const exec = new CliproxyapiExecutor(); + const body = { + model: "claude-opus-4-7", + system: [{ type: "text", text: "x" }], + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + thinking: { type: "adaptive", display: "summarized" }, + }; + const result = exec.transformRequest("claude-opus-4-7", body, true, {}); + assert.equal(result.thinking, undefined); + }); + + it("strips thinking carrying display field even with enabled type", () => { + const exec = new CliproxyapiExecutor(); + const body = { + model: "claude-opus-4-7", + system: [{ type: "text", text: "x" }], + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + thinking: { type: "enabled", budget_tokens: 10240, display: "summarized" }, + }; + const result = exec.transformRequest("claude-opus-4-7", body, true, {}); + assert.equal(result.thinking, undefined); + }); + + it("does not touch thinking on OpenAI-shape bodies", () => { + const exec = new CliproxyapiExecutor(); + const body = { + model: "gpt-5.5", + messages: [{ role: "user", content: "hi" }], + thinking: { type: "adaptive", display: "summarized" }, + }; + const result = exec.transformRequest("gpt-5.5", body, true, {}); + assert.deepEqual(result.thinking, { type: "adaptive", display: "summarized" }); + }); }); describe("execute", () => { From 5c4c0a94ff8e4bd000f1825f629b3ed138f367d4 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 11 May 2026 21:23:23 -0300 Subject: [PATCH 6/6] test(cliproxyapi): add Anthropic-shape detection, Capy extras strip, and mcp_ rewrite tests --- tests/unit/cliproxyapi-executor.test.ts | 179 ++++++++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/tests/unit/cliproxyapi-executor.test.ts b/tests/unit/cliproxyapi-executor.test.ts index 640372eb2d..0d65bf2431 100644 --- a/tests/unit/cliproxyapi-executor.test.ts +++ b/tests/unit/cliproxyapi-executor.test.ts @@ -288,4 +288,183 @@ describe("CliproxyapiExecutor", () => { assert.ok(result.transformedBody); }); }); + + describe("Anthropic-shape detection", () => { + it("detects Anthropic-shape when top-level system field present", () => { + const exec = new CliproxyapiExecutor(); + const body = { + model: "claude-opus-4-7", + system: [{ type: "text", text: "You are helpful" }], + messages: [{ role: "user", content: "hi" }], + }; + const result = exec.transformRequest("claude-opus-4-7", body, true, {}); + // Anthropic-shape: system field stripped (Capy extras behavior) vs preserved + // The key assertion is that it does NOT try to send to /v1/chat/completions path + // (verified by output_config being stripped when present) + assert.equal(result.system !== undefined || result.messages !== undefined, true); + }); + + it("detects Anthropic-shape when messages[0].content is an array (no system field)", () => { + const exec = new CliproxyapiExecutor(); + const body = { + model: "claude-opus-4-7", + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + output_config: { effort: "max" }, + }; + const result = exec.transformRequest("claude-opus-4-7", body, true, {}); + assert.equal( + result.output_config, + undefined, + "output_config should be stripped for Anthropic-shape" + ); + }); + + it("treats OpenAI-shape (string content, no system) as non-Anthropic passthrough", () => { + const exec = new CliproxyapiExecutor(); + const body = { + model: "gpt-5.5", + messages: [{ role: "user", content: "hi" }], + output_config: { effort: "max" }, + }; + const result = exec.transformRequest("gpt-5.5", body, true, {}); + assert.deepEqual( + result.output_config, + { effort: "max" }, + "output_config preserved for OpenAI-shape" + ); + }); + }); + + describe("Capy extras strip on Anthropic-shape bodies", () => { + function anthropicBody(extras: Record) { + return { + model: "claude-opus-4-7", + system: [{ type: "text", text: "x" }], + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + ...extras, + }; + } + + it("strips output_config", () => { + const exec = new CliproxyapiExecutor(); + const result = exec.transformRequest( + "claude-opus-4-7", + anthropicBody({ output_config: { effort: "max" } }), + true, + {} + ); + assert.equal(result.output_config, undefined); + }); + + it("strips metadata", () => { + const exec = new CliproxyapiExecutor(); + const result = exec.transformRequest( + "claude-opus-4-7", + anthropicBody({ metadata: { user_id: "abc" } }), + true, + {} + ); + assert.equal(result.metadata, undefined); + }); + + it("strips client_info", () => { + const exec = new CliproxyapiExecutor(); + const result = exec.transformRequest( + "claude-opus-4-7", + anthropicBody({ client_info: { name: "Capy" } }), + true, + {} + ); + assert.equal(result.client_info, undefined); + }); + + it("strips prompt_cache_key", () => { + const exec = new CliproxyapiExecutor(); + const result = exec.transformRequest( + "claude-opus-4-7", + anthropicBody({ prompt_cache_key: "key123" }), + true, + {} + ); + assert.equal(result.prompt_cache_key, undefined); + }); + + it("strips safety_identifier", () => { + const exec = new CliproxyapiExecutor(); + const result = exec.transformRequest( + "claude-opus-4-7", + anthropicBody({ safety_identifier: "sid" }), + true, + {} + ); + assert.equal(result.safety_identifier, undefined); + }); + }); + + describe("mcp_ tool name rewrite on Anthropic-shape bodies", () => { + function anthropicBodyWithTools(tools: unknown[], messages: unknown[] = []) { + return { + model: "claude-opus-4-7", + system: [{ type: "text", text: "x" }], + tools, + messages: + messages.length > 0 + ? messages + : [{ role: "user", content: [{ type: "text", text: "hi" }] }], + }; + } + + it("rewrites mcp_* tool definition names (tool defs)", () => { + const exec = new CliproxyapiExecutor(); + const body = anthropicBodyWithTools([ + { name: "mcp_filesystem_read", description: "Read file", input_schema: {} }, + ]); + const result = exec.transformRequest("claude-opus-4-7", body, true, {}); + const toolName = (result.tools as Array<{ name: string }>)[0].name; + assert.notEqual(toolName, "mcp_filesystem_read", "mcp_ tool name should be rewritten"); + assert.match( + toolName, + /^[A-Z]/, + "rewritten name should start with uppercase or differ from mcp_" + ); + }); + + it("does not rewrite non-mcp_ tool names", () => { + const exec = new CliproxyapiExecutor(); + const body = anthropicBodyWithTools([ + { name: "my_tool", description: "My tool", input_schema: {} }, + ]); + const result = exec.transformRequest("claude-opus-4-7", body, true, {}); + const toolName = (result.tools as Array<{ name: string }>)[0].name; + assert.equal(toolName, "my_tool"); + }); + + it("rewrites mcp_* tool_use names in assistant message history", () => { + const exec = new CliproxyapiExecutor(); + const body = anthropicBodyWithTools( + [{ name: "mcp_github_create_issue", description: "d", input_schema: {} }], + [ + { + role: "assistant", + content: [{ type: "tool_use", id: "tu_1", name: "mcp_github_create_issue", input: {} }], + }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "tu_1", content: "ok" }] }, + ] + ); + const result = exec.transformRequest("claude-opus-4-7", body, true, {}); + const assistantMsg = (result.messages as Array<{ role: string; content: unknown[] }>).find( + (m) => m.role === "assistant" + ); + const toolUseBlock = assistantMsg?.content.find( + (b): b is { type: string; name: string } => + typeof b === "object" && b !== null && (b as Record).type === "tool_use" + ); + assert.ok(toolUseBlock, "tool_use block should exist in assistant message"); + assert.notEqual( + toolUseBlock.name, + "mcp_github_create_issue", + "tool_use name should be rewritten" + ); + }); + }); });