Skip to content
196 changes: 191 additions & 5 deletions open-sse/executors/cliproxyapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): Map<string, string> {
const reverseMap = new Map<string, string>();
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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;
}
Comment on lines +53 to +113

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This function mutates the nested objects and arrays of the input body (e.g., t.name = rewritten and b.name = rewritten). Since transformRequest only performs a shallow clone of the top-level object, these mutations directly affect the original request body. This can lead to unexpected side effects if the body is reused for logging, retries, or by other executors in the pipeline.

Additionally, the implementation is missing the logic to rewrite tool_result tool_use_id references, which was explicitly mentioned as an objective in the PR description.


function resolveCliproxyapiBaseUrl(): string {
const host = process.env.CLIPROXYAPI_HOST || DEFAULT_HOST;
const port = parseInt(process.env.CLIPROXYAPI_PORT || String(DEFAULT_PORT), 10);
Expand Down Expand Up @@ -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<string, unknown>;
// 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<string, unknown>;
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<string, string> {
const key = credentials?.apiKey || credentials?.accessToken;

Expand Down Expand Up @@ -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<string, unknown>;
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;
}

Expand All @@ -111,7 +286,9 @@ export class CliproxyapiExecutor extends BaseExecutor {
log?: any;
upstreamExtraHeaders?: Record<string, string> | 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,
Expand All @@ -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,
});

Expand Down
Loading