467 lines
18 KiB
TypeScript
467 lines
18 KiB
TypeScript
/**
|
|
* CLIProxyAPI Executor — routes requests to a local CLIProxyAPI instance.
|
|
*
|
|
* Always uses the OpenAI-compatible /v1/chat/completions endpoint. CLIProxyAPI
|
|
* internally detects Claude models and routes them through its Claude executor
|
|
* with full emulation (CCH signing, billing header, system prompt, uTLS,
|
|
* multi-account rotation, device profile learning, etc.).
|
|
*
|
|
* The UI toggle (cliproxyapiMode in providerSpecificData) controls WHETHER
|
|
* to use CLIProxyAPI as the backend, not the wire format. Response format
|
|
* is always OpenAI-compatible, so chatCore's SSE parsing works unchanged.
|
|
*
|
|
* Activation:
|
|
* 1. Per-provider upstream_proxy_config (mode=cliproxyapi or fallback)
|
|
* 2. Per-connection cliproxyapiMode toggle in providerSpecificData (UI)
|
|
*/
|
|
|
|
import {
|
|
BaseExecutor,
|
|
mergeUpstreamExtraHeaders,
|
|
mergeAbortSignals,
|
|
type ProviderCredentials,
|
|
} from "./base.ts";
|
|
import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
|
|
import { getProviderPluginManifestHeader } from "../config/providerPluginManifestUrl.ts";
|
|
import { cloakThirdPartyToolNames } from "../services/claudeCodeToolRemapper.ts";
|
|
import { sanitizeClaudeToolSchemas } from "../translator/helpers/schemaCoercion.ts";
|
|
|
|
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;
|
|
}
|
|
|
|
// Cached URL from settings (loaded once, invalidated on settings change via clearCliproxyapiUrlCache)
|
|
let _cachedSettingsUrl: { url: string; ts: number } | null = null;
|
|
const URL_CACHE_TTL_MS = 60_000;
|
|
|
|
export function clearCliproxyapiUrlCache() {
|
|
_cachedSettingsUrl = null;
|
|
}
|
|
|
|
// Pre-load settings URL at module init so the sync path has a cache hit.
|
|
// This runs once when the executor module is first imported.
|
|
(async () => {
|
|
try {
|
|
const { getSettings } = await import("@/lib/db/settings");
|
|
const settings = await getSettings();
|
|
if (typeof settings.cliproxyapi_url === "string" && settings.cliproxyapi_url.trim()) {
|
|
_cachedSettingsUrl = { url: settings.cliproxyapi_url.trim(), ts: Date.now() };
|
|
}
|
|
} catch { /* env vars will be used as fallback */ }
|
|
})();
|
|
|
|
/**
|
|
* Resolve CLIProxyAPI base URL. Priority:
|
|
* 1. Settings table `cliproxyapi_url` (set via UI)
|
|
* 2. Environment variables CLIPROXYAPI_HOST / CLIPROXYAPI_PORT
|
|
* 3. Defaults (127.0.0.1:8317)
|
|
*/
|
|
async function resolveCliproxyapiBaseUrl(): Promise<string> {
|
|
// Check settings cache first
|
|
if (_cachedSettingsUrl && Date.now() - _cachedSettingsUrl.ts < URL_CACHE_TTL_MS) {
|
|
return _cachedSettingsUrl.url;
|
|
}
|
|
|
|
try {
|
|
const { getSettings } = await import("@/lib/db/settings");
|
|
const settings = await getSettings();
|
|
if (typeof settings.cliproxyapi_url === "string" && settings.cliproxyapi_url.trim()) {
|
|
const url = settings.cliproxyapi_url.trim();
|
|
_cachedSettingsUrl = { url, ts: Date.now() };
|
|
return url;
|
|
}
|
|
} catch { /* fall through to env vars */ }
|
|
|
|
const host = process.env.CLIPROXYAPI_HOST || DEFAULT_HOST;
|
|
const port = parseInt(process.env.CLIPROXYAPI_PORT || String(DEFAULT_PORT), 10);
|
|
const url = `http://${host}:${port}`;
|
|
_cachedSettingsUrl = { url, ts: Date.now() };
|
|
return url;
|
|
}
|
|
|
|
// Sync wrapper for backward compatibility (health checks, tests)
|
|
function resolveCliproxyapiBaseUrlSync(): string {
|
|
if (_cachedSettingsUrl && Date.now() - _cachedSettingsUrl.ts < URL_CACHE_TTL_MS) {
|
|
return _cachedSettingsUrl.url;
|
|
}
|
|
const host = process.env.CLIPROXYAPI_HOST || DEFAULT_HOST;
|
|
const port = parseInt(process.env.CLIPROXYAPI_PORT || String(DEFAULT_PORT), 10);
|
|
return `http://${host}:${port}`;
|
|
}
|
|
|
|
export { resolveCliproxyapiBaseUrl };
|
|
|
|
/**
|
|
* Check if a connection has CLIProxyAPI deep mode enabled via UI toggle.
|
|
* Used by chatCore's resolveExecutorWithProxy to decide routing.
|
|
*/
|
|
export function isCliproxyapiDeepModeEnabled(
|
|
providerSpecificData?: Record<string, unknown> | null
|
|
): boolean {
|
|
return providerSpecificData?.cliproxyapiMode === "claude-native";
|
|
}
|
|
|
|
export class CliproxyapiExecutor extends BaseExecutor {
|
|
private readonly upstreamBaseUrl: string;
|
|
|
|
constructor(baseUrl?: string) {
|
|
const effectiveBase = baseUrl ?? resolveCliproxyapiBaseUrlSync();
|
|
super("cliproxyapi", {
|
|
id: "cliproxyapi",
|
|
baseUrl: effectiveBase + "/v1/chat/completions",
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
this.upstreamBaseUrl = effectiveBase;
|
|
}
|
|
|
|
buildUrl(
|
|
_model: string,
|
|
_stream: boolean,
|
|
_urlIndex = 0,
|
|
_credentials: ProviderCredentials | null = null
|
|
): string {
|
|
// 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: top-level `thinking` field is Anthropic-only. OpenAI
|
|
// uses `reasoning` / `reasoning_effort`. Even adaptive/raw Capy shapes
|
|
// emit thinking, so this catches minimal Capy bodies (string content,
|
|
// no system block) that would otherwise miss the messages[0].content
|
|
// array check below.
|
|
if (b.thinking !== undefined) return true;
|
|
// Strong signal: top-level `metadata.user_id` is the CC wire-image
|
|
// identifier; OpenAI request bodies don't carry it.
|
|
if (
|
|
b.metadata &&
|
|
typeof b.metadata === "object" &&
|
|
(b.metadata as Record<string, unknown>).user_id !== 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;
|
|
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
...getProviderPluginManifestHeader(),
|
|
};
|
|
|
|
if (key) {
|
|
headers["Authorization"] = `Bearer ${key}`;
|
|
}
|
|
if (stream) {
|
|
headers["Accept"] = "text/event-stream";
|
|
}
|
|
|
|
return headers;
|
|
}
|
|
|
|
transformRequest(
|
|
model: string,
|
|
body: unknown,
|
|
_stream: boolean,
|
|
_credentials: ProviderCredentials | null
|
|
): unknown {
|
|
if (!body || typeof body !== "object") return body;
|
|
|
|
const transformed = { ...(body as Record<string, unknown>) };
|
|
if (transformed.model !== model) {
|
|
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.
|
|
// Sanitize invalid tool input_schemas (truncation placeholders such as
|
|
// `enum: "[MaxDepth]"`, or index-keyed objects where arrays are required)
|
|
// that Anthropic rejects with `tools.N.custom.input_schema: JSON schema is
|
|
// invalid` — surfaced as the same misleading "out of extra usage" 400.
|
|
if (Array.isArray(transformed.tools)) {
|
|
transformed.tools = sanitizeClaudeToolSchemas(transformed.tools) as unknown[];
|
|
}
|
|
|
|
// Cloak third-party / blacklisted tool names (e.g. `mixture_of_agents`, or
|
|
// a large enough set of recognizable snake_case agent tools) that Anthropic
|
|
// fingerprints and refuses with the same placeholder. The `mcp_*` reserved
|
|
// namespace is deferred to applyMcpToolNameRewrite below (its bisected
|
|
// `Mcp_X` form) so the two reverse maps stay disjoint and single-hop.
|
|
const cloakMap = cloakThirdPartyToolNames(transformed, {
|
|
skip: (name) => MCP_RESERVED_PREFIX_RE.test(name),
|
|
});
|
|
|
|
const mcpMap = applyMcpToolNameRewrite(transformed);
|
|
|
|
const toolNameMap = new Map<string, string>(cloakMap);
|
|
for (const [alias, original] of mcpMap) {
|
|
toolNameMap.set(alias, original);
|
|
}
|
|
if (toolNameMap.size > 0) {
|
|
// Non-enumerable: chatCore reads this for response-side tool-name
|
|
// restoration; the wire body must never carry it (also stripped in execute()).
|
|
Object.defineProperty(transformed, "_toolNameMap", {
|
|
value: toolNameMap,
|
|
enumerable: false,
|
|
configurable: true,
|
|
writable: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
return transformed;
|
|
}
|
|
|
|
async execute(input: {
|
|
model: string;
|
|
body: unknown;
|
|
stream: boolean;
|
|
credentials: ProviderCredentials;
|
|
signal?: AbortSignal | null;
|
|
log?: any;
|
|
upstreamExtraHeaders?: Record<string, string> | null;
|
|
}) {
|
|
// Resolve URL dynamically so settings table cliproxyapi_url is respected.
|
|
// Uses 60s cache to avoid DB reads on every request.
|
|
const baseUrl = await resolveCliproxyapiBaseUrl();
|
|
const endpoint = this.selectEndpoint(input.body);
|
|
const url = `${baseUrl}${endpoint}`;
|
|
const shape = endpoint === "/v1/messages" ? "anthropic" : "openai";
|
|
const headers = this.buildHeaders(input.credentials, input.stream);
|
|
const transformedBody = this.transformRequest(
|
|
input.model,
|
|
input.body,
|
|
input.stream,
|
|
input.credentials
|
|
);
|
|
mergeUpstreamExtraHeaders(headers, input.upstreamExtraHeaders);
|
|
|
|
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
|
const combinedSignal = input.signal
|
|
? mergeAbortSignals(input.signal, timeoutSignal)
|
|
: timeoutSignal;
|
|
|
|
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: wireBody,
|
|
signal: combinedSignal,
|
|
});
|
|
|
|
if (response.status === HTTP_STATUS.RATE_LIMITED) {
|
|
input.log?.warn?.("CPA", `CLIProxyAPI rate limited: ${response.status}`);
|
|
}
|
|
|
|
return { response, url, headers, transformedBody };
|
|
}
|
|
|
|
/**
|
|
* Health check — verifies CLIProxyAPI is reachable.
|
|
*
|
|
* CPA 6.x doesn't expose a /health endpoint; previously we hit /health
|
|
* and got 404, which made the dashboard report "CLIProxyAPI not
|
|
* detected" even when the service was up and successfully serving
|
|
* /v1/messages. Probe /v1/models instead (returns 200 with the
|
|
* advertised model list), which is the closest thing CPA has to a
|
|
* liveness probe and works on every CPA version we've tested.
|
|
*/
|
|
async healthCheck(): Promise<{ ok: boolean; latencyMs: number; error?: string }> {
|
|
const start = Date.now();
|
|
try {
|
|
const baseUrl = await resolveCliproxyapiBaseUrl();
|
|
const res = await fetch(`${baseUrl}/v1/models`, {
|
|
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
|
|
});
|
|
return {
|
|
ok: res.ok,
|
|
latencyMs: Date.now() - start,
|
|
...(!res.ok ? { error: `HTTP ${res.status}` } : {}),
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
latencyMs: Date.now() - start,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
export default CliproxyapiExecutor;
|