diff --git a/open-sse/executors/cliproxyapi.ts b/open-sse/executors/cliproxyapi.ts index 69e2f05bb8..601d100e03 100644 --- a/open-sse/executors/cliproxyapi.ts +++ b/open-sse/executors/cliproxyapi.ts @@ -27,6 +27,91 @@ 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 +} + +/** + * 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) => { + reverseMap.set(rewritten, original); + }; + + const tools = body.tools; + if (Array.isArray(tools)) { + body.tools = tools.map((tool) => { + 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) { + remember(original, rewritten); + return { ...t, name: rewritten }; + } + return tool; + }); + } + + const messages = body.messages; + if (Array.isArray(messages)) { + 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") return block; + const original = typeof b.name === "string" ? b.name : ""; + const rewritten = rewriteMcpToolName(original); + if (rewritten) { + mutated = true; + remember(original, rewritten); + return { ...b, name: rewritten }; + } + return block; + }); + return mutated ? { ...m, content: newContent } : msg; + }); + } + + 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; + body.tool_choice = { ...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); @@ -64,11 +149,48 @@ 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: 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) { + 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; @@ -99,6 +221,59 @@ 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.output_config; + delete transformed.context_management; + delete transformed.client_info; + delete transformed.prompt_cache_key; + 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 + // 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; } @@ -111,7 +286,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,12 +303,21 @@ 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})`); + + // _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, }); diff --git a/tests/unit/cliproxyapi-executor.test.ts b/tests/unit/cliproxyapi-executor.test.ts index cc37ccb8a3..0d65bf2431 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", () => { @@ -229,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" + ); + }); + }); });