fix(cliproxyapi): Anthropic-shape body routing and gate compatibility (#2165)

Integrated into release/v3.8.0 — three fixes for CliProxyApi: Anthropic-shape body routing to /v1/messages, Capy premium extras strip, and mcp_* tool name rewrite to avoid Anthropic gate. Tests added covering all three categories.
This commit is contained in:
Anton
2026-05-12 02:23:59 +02:00
committed by GitHub
parent dcb32a6ba0
commit 704f686396
2 changed files with 429 additions and 5 deletions

View File

@@ -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;
}
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<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;
@@ -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;
}
@@ -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,
@@ -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,
});

View File

@@ -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<string, unknown>) {
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<string, unknown>).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"
);
});
});
});