From 7d4325a9fc00cfb979e0a6712d52dd25e9813531 Mon Sep 17 00:00:00 2001 From: thaolaptrinh Date: Wed, 1 Jul 2026 10:05:49 +0700 Subject: [PATCH 01/16] =?UTF-8?q?refactor(translate):=20Phase=201=20?= =?UTF-8?q?=E2=80=94=20encapsulate=20per-request=20state,=20extract=20help?= =?UTF-8?q?ers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix concurrency bug: module-level toolCallIndex and _messageId were shared across concurrent requests. Replace toOpenAIStreamChunk / toOpenAIErrorChunk with a per-request OpenAIStreamEncoder class. Remove getMessageId/resetMessageId globals from util.ts. Extract applyNoToolsSafeguard for reuse by the upcoming Anthropic translator. Deduplicate OPENAI_FINISH_MAP (was declared twice). --- src/translate/openai.ts | 385 +++++++++++++++++----------------------- src/translate/util.ts | 56 ++++-- 2 files changed, 207 insertions(+), 234 deletions(-) diff --git a/src/translate/openai.ts b/src/translate/openai.ts index a5a2b56..88a8c99 100644 --- a/src/translate/openai.ts +++ b/src/translate/openai.ts @@ -14,7 +14,12 @@ import type { UsageData, } from "@/translate/types.js"; import { resolveModel } from "@/translate/models.js"; -import { getMessageId, extractUsage, pruneDanglingTools, buildCCConfig } from "@/translate/util.js"; +import { + applyNoToolsSafeguard, + extractUsage, + pruneDanglingTools, + buildCCConfig, +} from "@/translate/util.js"; import { logger } from "@/logger.js"; // ────────────────────────────────────────── @@ -166,42 +171,8 @@ export function toCCRequest( threadId: crypto.randomUUID(), }; - const hasTools = req.tools && req.tools.length > 0; - const noToolsInstruction = - "CRITICAL: You are running in a chat-only environment. Tool execution is disabled. Do not generate or call any tools (e.g. Build, ReadFile, grep, Search, etc.). Respond only with plain text."; - const withToolsInstruction = ""; - - const finalSystemPrompt = systemPrompt - ? `${systemPrompt}\n\n${hasTools ? withToolsInstruction : noToolsInstruction}` - : hasTools - ? withToolsInstruction - : noToolsInstruction; - - if (finalSystemPrompt) { - body.params.system = finalSystemPrompt; - } - - // Also append directly to the last user message as a fallback to bypass upstream overrides - if (ccMessages.length > 0 && !hasTools) { - for (let i = ccMessages.length - 1; i >= 0; i--) { - if (ccMessages[i].role === "user") { - const msg = ccMessages[i]; - const suffix = - "\n\n[System Note: Tool execution is disabled in this environment. Do not output any tool calls (such as Build, Search, ReadFile, grep, etc.). You must answer directly in plain text.]"; - if (typeof msg.content === "string") { - msg.content += suffix; - } else if (Array.isArray(msg.content)) { - const lastTextPart = [...msg.content].reverse().find((p) => p.type === "text"); - if (lastTextPart) { - lastTextPart.text = (lastTextPart.text ?? "") + suffix; - } else { - msg.content.push({ type: "text", text: suffix }); - } - } - break; - } - } - } + if (systemPrompt) body.params.system = systemPrompt; + applyNoToolsSafeguard(body, ccMessages, req.tools != null && req.tools.length > 0); return body; } @@ -210,10 +181,16 @@ export function toCCRequest( // CC events → OpenAI streaming chunks // ────────────────────────────────────────── -/** - * Convert internal UsageData into the OpenAI `usage` object format - * (prompt_tokens / completion_tokens / total_tokens + detail sub-objects). - */ +const OPENAI_FINISH_MAP: Record = { + stop: "stop", + length: "length", + content_filtered: "content_filter", + "tool-call": "tool_calls", + "tool-calls": "tool_calls", + tool_call: "tool_calls", + error: "stop", +}; + function toOpenAIUsage(u: UsageData): Record { const promptTokens = u.promptTokens ?? 0; const completionTokens = u.completionTokens ?? 0; @@ -233,199 +210,181 @@ function toOpenAIUsage(u: UsageData): Record { return usage; } -// Index counter for tool calls within a single streaming response. -let toolCallIndex = 0; - -/** - * Translate a single CC event into OpenAI streaming chunks. - * - * `model` is the model id requested by the downstream client (echoed back in - * every chunk per the OpenAI spec). `responseModel` is the model reported by - * the upstream `start` event, if any — used only to enrich the first chunk. - */ -export function toOpenAIStreamChunk(event: CCEvent, model = "unknown"): object[] { - const chunks: object[] = []; - const id = getMessageId(); - const created = Math.floor(Date.now() / 1000); - - switch (event.type) { - case "start": { - toolCallIndex = 0; - chunks.push({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], - }); - break; - } +export class OpenAIStreamEncoder { + readonly id: string; + private readonly created: number; + private toolCallIndex = 0; + + constructor(private readonly model: string) { + this.id = crypto.randomUUID(); + this.created = Math.floor(Date.now() / 1000); + } - case "text-delta": { - const text = event.data.text as string; - if (text) { + emit(event: CCEvent): object[] { + const chunks: object[] = []; + const id = this.id; + const created = this.created; + + switch (event.type) { + case "start": { + this.toolCallIndex = 0; chunks.push({ id, object: "chat.completion.chunk", created, - model, - choices: [{ index: 0, delta: { content: text }, finish_reason: null }], + model: this.model, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], }); + break; } - break; - } - case "reasoning-delta": { - const text = event.data.text as string; - if (text) { + case "text-delta": { + const text = event.data.text as string; + if (text) { + chunks.push({ + id, + object: "chat.completion.chunk", + created, + model: this.model, + choices: [{ index: 0, delta: { content: text }, finish_reason: null }], + }); + } + break; + } + + case "reasoning-delta": { + const text = event.data.text as string; + if (text) { + chunks.push({ + id, + object: "chat.completion.chunk", + created, + model: this.model, + choices: [{ index: 0, delta: { reasoning_content: text }, finish_reason: null }], + }); + } + break; + } + + case "tool-call-delta": { + const tc: { + index: number; + id?: string; + type?: string; + function: { name?: string; arguments: string }; + } = { + index: (event.data.index as number) ?? 0, + function: { arguments: (event.data.arguments as string) ?? "" }, + }; + if (event.data.toolCallId) { + tc.id = event.data.toolCallId as string; + tc.type = "function"; + } + if (event.data.name) { + tc.function.name = event.data.name as string; + } chunks.push({ id, object: "chat.completion.chunk", created, - model, - choices: [{ index: 0, delta: { reasoning_content: text }, finish_reason: null }], + model: this.model, + choices: [{ index: 0, delta: { tool_calls: [tc] }, finish_reason: null }], }); - } - break; - } - - case "tool-call-delta": { - const tc: Record = { - index: (event.data.index as number) ?? 0, - function: { arguments: (event.data.arguments as string) ?? "" }, - }; - if (event.data.toolCallId) { - tc.id = event.data.toolCallId; - tc.type = "function"; - } - if (event.data.name) { - tc.function.name = event.data.name; + break; } - chunks.push({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [ - { - index: 0, - delta: { - tool_calls: [tc], + case "tool-call": { + const toolCallId = (event.data.toolCallId as string) ?? ""; + const toolName = (event.data.toolName as string) ?? (event.data.name as string) ?? ""; + const input = event.data.input ?? event.data.arguments; + const args = typeof input === "string" ? input : input != null ? JSON.stringify(input) : ""; + chunks.push({ + id, + object: "chat.completion.chunk", + created, + model: this.model, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: this.toolCallIndex++, + id: toolCallId, + type: "function", + function: { name: toolName, arguments: args }, + }, + ], + }, + finish_reason: null, }, - finish_reason: null, - }, - ], - }); - break; - } + ], + }); + break; + } - case "tool-call": { - // CC complete tool-call event: { toolCallId, toolName, input (object) } - // Translate to OpenAI tool_calls delta (arguments must be a JSON string). - const toolCallId = (event.data.toolCallId as string) ?? ""; - const toolName = (event.data.toolName as string) ?? (event.data.name as string) ?? ""; - const input = event.data.input ?? event.data.arguments; - const args = typeof input === "string" ? input : input != null ? JSON.stringify(input) : ""; - chunks.push({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: toolCallIndex++, - id: toolCallId, - type: "function", - function: { name: toolName, arguments: args }, - }, - ], - }, - finish_reason: null, - }, - ], - }); - break; - } + case "finish": { + const usage = extractUsage(event.data as Record); + const finishReason = (event.data.finishReason as string) ?? "stop"; + chunks.push({ + id, + object: "chat.completion.chunk", + created, + model: this.model, + choices: [ + { index: 0, delta: {}, finish_reason: OPENAI_FINISH_MAP[finishReason] ?? "stop" }, + ], + }); + if (usage) { + chunks.push({ + id, + object: "chat.completion.chunk", + created, + model: this.model, + choices: [], + usage: toOpenAIUsage(usage), + }); + } + break; + } - case "finish": { - const usage = extractUsage(event.data); - const finishReason = (event.data.finishReason as string) ?? "stop"; - const finishMap: Record = { - stop: "stop", - length: "length", - content_filtered: "content_filter", - "tool-call": "tool_calls", - "tool-calls": "tool_calls", - tool_call: "tool_calls", - error: "stop", - }; - chunks.push({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [{ index: 0, delta: {}, finish_reason: finishMap[finishReason] ?? "stop" }], - }); - // OpenAI streams usage as a separate trailing chunk with an empty - // `choices` array. We emit it whenever the upstream reports usage so - // clients that rely on it (context-window %, billing, etc.) receive it. - if (usage) { + case "error": { + const errMsg = + (event.data.message as string) ?? + (event.data.error as { message?: string } | undefined)?.message ?? + JSON.stringify(event.data); + logger.error(`[CC upstream error] ${errMsg}`); + chunks.push({ + id, + object: "chat.completion.chunk", + created, + model: this.model, + choices: [ + { index: 0, delta: { content: `[upstream error] ${errMsg}` }, finish_reason: null }, + ], + }); chunks.push({ id, object: "chat.completion.chunk", created, - model, - choices: [], - usage: toOpenAIUsage(usage), + model: this.model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], }); + break; } - break; } - case "error": { - // Surface upstream errors instead of silently dropping them, so the - // downstream client sees what went wrong (an empty stream is harder to - // debug). Emit the message as content, then a stop finish. - const errMsg = - (event.data.message as string) ?? - (event.data.error as { message?: string } | undefined)?.message ?? - JSON.stringify(event.data); - logger.error(`[CC upstream error] ${errMsg}`); - chunks.push({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [ - { index: 0, delta: { content: `[upstream error] ${errMsg}` }, finish_reason: null }, - ], - }); - chunks.push({ - id, - object: "chat.completion.chunk", - created, - model, - choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - }); - break; - } + return chunks; } - return chunks; -} - -export function toOpenAIErrorChunk(error: Error): object { - return { - error: { - message: error.message, - type: "upstream_error", - }, - }; + errorChunk(err: Error): object { + return { + error: { + message: err.message, + type: "upstream_error", + }, + }; + } } // ────────────────────────────────────────── @@ -437,17 +396,7 @@ interface FinishEvent { usage?: UsageData; } -const OPENAI_FINISH_MAP: Record = { - stop: "stop", - length: "length", - content_filtered: "content_filter", - "tool-call": "tool_calls", - "tool-calls": "tool_calls", - tool_call: "tool_calls", - error: "stop", -}; - -export function buildNonStreamingResponse(events: CCEvent[], model: string): object { +export function buildNonStreamingResponse(events: CCEvent[], model: string, id: string): object { let content = ""; let reasoningContent = ""; const toolCalls: ToolCall[] = []; @@ -511,7 +460,7 @@ export function buildNonStreamingResponse(events: CCEvent[], model: string): obj const finishReason = finish?.finishReason ?? "stop"; const response: Record = { - id: getMessageId(), + id, object: "chat.completion", created: Math.floor(Date.now() / 1000), model, diff --git a/src/translate/util.ts b/src/translate/util.ts index 9dad550..3bad1bf 100644 --- a/src/translate/util.ts +++ b/src/translate/util.ts @@ -1,21 +1,5 @@ -import crypto from "node:crypto"; import type { CCMessage, CCRequestBody, UsageData } from "@/translate/types.js"; -// ────────────────────────────────────────── -// Message ID -// ────────────────────────────────────────── - -// One id per downstream request, shared by the OpenAI encoders. -let _messageId = crypto.randomUUID(); - -export function getMessageId(): string { - return _messageId; -} - -export function resetMessageId(): void { - _messageId = crypto.randomUUID(); -} - // ────────────────────────────────────────── // Usage extraction // ────────────────────────────────────────── @@ -86,6 +70,46 @@ export function buildCCConfig( }; } +// ────────────────────────────────────────── +// No-tools safeguard +// ────────────────────────────────────────── + +export function applyNoToolsSafeguard( + body: CCRequestBody, + ccMessages: CCMessage[], + hasTools: boolean, +): void { + if (hasTools) return; + + const noToolsInstruction = + "CRITICAL: You are running in a chat-only environment. Tool execution is disabled. Do not generate or call any tools (e.g. Build, ReadFile, grep, Search, etc.). Respond only with plain text."; + + const existingSystem = body.params.system; + body.params.system = existingSystem + ? `${existingSystem}\n\n${noToolsInstruction}` + : noToolsInstruction; + + if (ccMessages.length === 0) return; + for (let i = ccMessages.length - 1; i >= 0; i--) { + if (ccMessages[i].role === "user") { + const msg = ccMessages[i]; + const suffix = + "\n\n[System Note: Tool execution is disabled in this environment. Do not output any tool calls (such as Build, Search, ReadFile, grep, etc.). You must answer directly in plain text.]"; + if (typeof msg.content === "string") { + msg.content += suffix; + } else if (Array.isArray(msg.content)) { + const lastTextPart = [...msg.content].reverse().find((p) => p.type === "text"); + if (lastTextPart) { + lastTextPart.text = (lastTextPart.text ?? "") + suffix; + } else { + msg.content.push({ type: "text", text: suffix }); + } + } + break; + } + } +} + // ────────────────────────────────────────── // Tool-call / tool-result pairing // ────────────────────────────────────────── From 40daa0eea76c118a91e661087c16b1c630d64ff3 Mon Sep 17 00:00:00 2001 From: thaolaptrinh Date: Wed, 1 Jul 2026 10:06:08 +0700 Subject: [PATCH 02/16] =?UTF-8?q?feat:=20Phase=202=20=E2=80=94=20Anthropic?= =?UTF-8?q?=20Messages=20API=20proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add full Anthropic API surface alongside the existing OpenAI-compatible endpoint: - POST /v1/messages (streaming + non-streaming) - POST /v1/messages/count_tokens (CJK-weighted heuristic estimate) - GET /v1/models (content-negotiated via anthropic-version header) Translator (src/translate/anthropic.ts): - toCCRequest: Anthropic → CC request (messages, tools, tool_choice, thinking, system, images, tool_result with name lookup) - AnthropicStreamEncoder: CC events → Anthropic SSE (message_start, content_block_*, signature_delta, ping, message_delta, message_stop, error) - buildAnthropicResponse: non-streaming Anthropic response assembly Supporting modules: - anthropic-types.ts: discriminated-union types (no any) - anthropic-models.ts: env-based claude-* → CC model mapping - validation.ts: validateAnthropicRequest (rejects unsupported blocks/tools) - stream.ts: formatAnthropicSSE for event-typed SSE - server.ts: handleMessages, handleCountTokens, sendAnthropicError, format-aware handleUpstreamError(err, format) 122 tests pass, lint clean, build clean. --- .env.example | 6 + DEVELOPMENT.md | 21 +- README.md | 138 ++++++-- src/auth.ts | 2 - src/proxy.ts | 4 +- src/server.ts | 247 ++++++++++++-- src/setup/opencode.ts | 6 +- src/stream.ts | 7 + src/translate/anthropic-models.ts | 33 ++ src/translate/anthropic-types.ts | 213 ++++++++++++ src/translate/anthropic.ts | 524 ++++++++++++++++++++++++++++++ src/translate/validation.ts | 90 +++++ src/upstream.ts | 4 +- tests/anthropic-models.test.ts | 44 +++ tests/e2e.test.ts | 2 +- tests/stream.test.ts | 37 ++- tests/translate-anthropic.test.ts | 455 ++++++++++++++++++++++++++ tests/translate.test.ts | 179 +++++++++- 18 files changed, 1936 insertions(+), 76 deletions(-) create mode 100644 src/translate/anthropic-models.ts create mode 100644 src/translate/anthropic-types.ts create mode 100644 src/translate/anthropic.ts create mode 100644 tests/anthropic-models.test.ts create mode 100644 tests/translate-anthropic.test.ts diff --git a/.env.example b/.env.example index a31f713..179a7a5 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,9 @@ CC_API_KEY= # Proxy config HOST=127.0.0.1 PORT=8787 + +# Anthropic model mapping (optional) +# ANTHROPIC_DEFAULT_MODEL=deepseek/deepseek-v4-pro +# ANTHROPIC_MODEL_CLAUDE_SONNET_4_5=glm-5.1 +# ANTHROPIC_MODEL_CLAUDE_OPUS_4_1=deepseek/deepseek-v4-pro +# ANTHROPIC_MODEL_CLAUDE_HAIKU=deepseek/deepseek-v4-flash diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 51d9ce7..e3253cc 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -47,18 +47,25 @@ src/ ├── upstream.ts # CC API client ├── setup/ │ └── opencode.ts # opencode.json bootstrap helper -└── translate/ - ├── types.ts # Shared types - ├── models.ts # Model resolution & aliasing - ├── util.ts # CC helpers (messageId, usage, tool pruning) - ├── validation.ts # Request validation - └── openai.ts # OpenAI ↔ CC translation +├── translate/ +│ ├── types.ts # Shared types (OpenAI, CC, UsageData) +│ ├── models.ts # Model resolution & aliasing +│ ├── util.ts # CC helpers (usage, tool pruning, safeguard) +│ ├── validation.ts # Request validation (OpenAI + Anthropic) +│ ├── openai.ts # OpenAI ↔ CC translation +│ ├── anthropic-types.ts # Anthropic API types +│ ├── anthropic-models.ts # Env-based Anthropic model mapping +│ └── anthropic.ts # Anthropic ↔ CC translation +setup/ +│ └── opencode.ts # opencode.json bootstrap helper tests/ ├── auth.test.ts # Auth module tests ├── config.test.ts # Config loader tests ├── server.test.ts # HTTP server tests ├── stream.test.ts # NDJSON parser & SSE tests -├── translate.test.ts # Translation layer tests +├── translate.test.ts # OpenAI translation layer tests +├── translate-anthropic.test.ts # Anthropic translation tests +├── anthropic-models.test.ts # Model mapping tests ├── upstream.test.ts # Upstream client tests ├── openai-schema.test.ts # ModelMessage[] schema conformance & usage └── e2e.test.ts # End-to-end integration tests diff --git a/README.md b/README.md index 6216ce2..2d5a3db 100644 --- a/README.md +++ b/README.md @@ -56,12 +56,12 @@ Get your API key from https://commandcode.ai/settings. ### CLI options -| Option | Description | Default | -| ----------- | -------------------- | ----------- | -| `--host` | Bind address | `127.0.0.1` | -| `--port` | Port | `8787` | -| `--api-key` | Command Code API key | — | -| `--setup-opencode` | Generate OpenCode provider config | — | +| Option | Description | Default | +| ------------------ | --------------------------------- | ----------- | +| `--host` | Bind address | `127.0.0.1` | +| `--port` | Port | `8787` | +| `--api-key` | Command Code API key | — | +| `--setup-opencode` | Generate OpenCode provider config | — | ## Endpoints @@ -78,6 +78,56 @@ curl http://127.0.0.1:8787/v1/chat/completions \ }' ``` +### `POST /v1/messages` (Anthropic) + +```bash +curl http://127.0.0.1:8787/v1/messages \ + -H "x-api-key: proxy-managed" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 4096, + "messages": [{"role": "user", "content": "Hello!"}], + "stream": true + }' +``` + +### `POST /v1/messages/count_tokens` (Anthropic) + +```bash +curl http://127.0.0.1:8787/v1/messages/count_tokens \ + -H "x-api-key: proxy-managed" \ + -H "Content-Type: application/json" \ + -d '{ + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + +### Anthropic model mapping + +Anthropic clients send Claude model IDs (e.g., `claude-sonnet-4-5-20250929`). +Map them to CC models via environment variables: + +```bash +ANTHROPIC_DEFAULT_MODEL=deepseek/deepseek-v4-pro # fallback for any unmapped Claude ID +ANTHROPIC_MODEL_CLAUDE_SONNET_4_5=glm-5.1 # map a specific Claude variant +ANTHROPIC_MODEL_CLAUDE_OPUS_4_1=deepseek/deepseek-v4-pro +ANTHROPIC_MODEL_CLAUDE_HAIKU=deepseek/deepseek-v4-flash +``` + +The proxy normalizes Claude IDs to env-var keys: +`claude-sonnet-4-5-20250929` → `CLAUDE_SONNET_4_5`. Non-Claude model IDs +pass through unchanged. + +### Anthropic limitations + +- **Thinking signatures**: placeholder only (not cryptographically signed). +- **Cache control**: stripped (no CC analogue). +- **Count tokens**: heuristic estimate, not exact. +- **Built-in/server tools** (`computer_`, `bash_`, `web_search_`, etc.): + rejected with `invalid_request_error`. Only custom tools supported. +- **`metadata`, `top_k`, `top_p`, `service_tier`**: accepted but dropped. + ## Client configuration ### OpenCode @@ -102,19 +152,37 @@ Or add manually: "apiKey": "proxy-managed" }, "models": { - "deepseek-v4-pro": { "name": "DeepSeek V4 Pro", "limit": { "context": 1048576, "output": 393216 } }, - "deepseek-v4-flash": { "name": "DeepSeek V4 Flash", "limit": { "context": 1048576, "output": 393216 } }, + "deepseek-v4-pro": { + "name": "DeepSeek V4 Pro", + "limit": { "context": 1048576, "output": 393216 } + }, + "deepseek-v4-flash": { + "name": "DeepSeek V4 Flash", + "limit": { "context": 1048576, "output": 393216 } + }, "MiniMax-M2.7": { "name": "MiniMax M2.7", "limit": { "context": 204800, "output": 32768 } }, "MiniMax-M2.5": { "name": "MiniMax M2.5", "limit": { "context": 204800, "output": 32768 } }, "GLM-5.1": { "name": "GLM-5.1", "limit": { "context": 200000, "output": 131072 } }, "GLM-5": { "name": "GLM-5", "limit": { "context": 200000, "output": 131072 } }, "Kimi-K2.6": { "name": "Kimi K2.6", "limit": { "context": 262144, "output": 98304 } }, "Kimi-K2.5": { "name": "Kimi K2.5", "limit": { "context": 262144, "output": 98304 } }, - "Qwen3.6-Max-Preview": { "name": "Qwen 3.6 Max Preview", "limit": { "context": 262144, "output": 65536 } }, - "Qwen3.6-Plus": { "name": "Qwen 3.6 Plus", "limit": { "context": 1048576, "output": 65536 } }, + "Qwen3.6-Max-Preview": { + "name": "Qwen 3.6 Max Preview", + "limit": { "context": 262144, "output": 65536 } + }, + "Qwen3.6-Plus": { + "name": "Qwen 3.6 Plus", + "limit": { "context": 1048576, "output": 65536 } + }, "Qwen3.7-Max": { "name": "Qwen 3.7 Max", "limit": { "context": 1048576, "output": 65536 } }, - "Qwen3.7-Plus": { "name": "Qwen 3.7 Plus", "limit": { "context": 1048576, "output": 65536 } }, - "Step-3.5-Flash": { "name": "Step 3.5 Flash", "limit": { "context": 262144, "output": 65536 } }, + "Qwen3.7-Plus": { + "name": "Qwen 3.7 Plus", + "limit": { "context": 1048576, "output": 65536 } + }, + "Step-3.5-Flash": { + "name": "Step 3.5 Flash", + "limit": { "context": 262144, "output": 65536 } + }, "mimo-v2.5": { "name": "MiMo V2.5", "limit": { "context": 1048576, "output": 131072 } } } } @@ -154,20 +222,46 @@ response = client.chat.completions.create( } ``` +### Claude Code + +Set the base URL and API key in your environment: + +```bash +export ANTHROPIC_BASE_URL=http://127.0.0.1:8787 +export ANTHROPIC_API_KEY=proxy-managed +``` + +### Anthropic SDK (Python) + +```python +from anthropic import Anthropic + +client = Anthropic( + base_url="http://127.0.0.1:8787/v1", + api_key="proxy-managed", +) + +message = client.messages.create( + model="claude-sonnet-4-5-20250929", + max_tokens=4096, + messages=[{"role": "user", "content": "Hello!"}], +) +``` + ## Model aliases Short names work in addition to full model IDs: -| Alias | Maps to | -| -------------------------------------- | ---------------------------- | -| `deepseek-v4-pro`, `deepseek-v4` | `deepseek/deepseek-v4-pro` | -| `deepseek-v4-flash`, `deepseek-flash` | `deepseek/deepseek-v4-flash` | -| `minimax-m2.7`, `minimax-m2.5` | `MiniMaxAI/MiniMax-*` | -| `glm-5.1`, `glm-5` | `zai-org/GLM-*` | -| `kimi-k2.6`, `kimi-k2.5` | `moonshotai/Kimi-*` | -| `qwen3.6-max`, `qwen3.6-plus` | `Qwen/Qwen3.6-*` | -| `step3.5` | `stepfun/Step-3.5-Flash` | -| `mimo-v2.5` | `xiaomi/mimo-v2.5` | +| Alias | Maps to | +| ------------------------------------- | ---------------------------- | +| `deepseek-v4-pro`, `deepseek-v4` | `deepseek/deepseek-v4-pro` | +| `deepseek-v4-flash`, `deepseek-flash` | `deepseek/deepseek-v4-flash` | +| `minimax-m2.7`, `minimax-m2.5` | `MiniMaxAI/MiniMax-*` | +| `glm-5.1`, `glm-5` | `zai-org/GLM-*` | +| `kimi-k2.6`, `kimi-k2.5` | `moonshotai/Kimi-*` | +| `qwen3.6-max`, `qwen3.6-plus` | `Qwen/Qwen3.6-*` | +| `step3.5` | `stepfun/Step-3.5-Flash` | +| `mimo-v2.5` | `xiaomi/mimo-v2.5` | Any model ID is passed through as-is — the proxy does not validate against a fixed list. diff --git a/src/auth.ts b/src/auth.ts index 966bc48..91f25a0 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -77,5 +77,3 @@ export async function promptForApiKey(): Promise { stdin.on("data", onData); }); } - - diff --git a/src/proxy.ts b/src/proxy.ts index 142a5d6..0936df2 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -42,7 +42,9 @@ if (args.includes("--setup-opencode")) { const config = loadConfig(); initLogger(config.logLevel); -logger.info(`API key source: ${process.env.CC_API_KEY ? "env CC_API_KEY" : config.apiKey ? "auth.json" : "none"} (length: ${config.apiKey?.length ?? 0})`); +logger.info( + `API key source: ${process.env.CC_API_KEY ? "env CC_API_KEY" : config.apiKey ? "auth.json" : "none"} (length: ${config.apiKey?.length ?? 0})`, +); if (!process.env.CC_CLI_VERSION) { const latest = await fetchLatestCliVersion(); diff --git a/src/server.ts b/src/server.ts index e008f1f..9e7ca0b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,19 +2,23 @@ import http from "node:http"; import { Readable } from "node:stream"; import { URL } from "node:url"; import type { Config } from "@/config.js"; +import { toCCRequest, OpenAIStreamEncoder, buildNonStreamingResponse } from "@/translate/openai.js"; import { - toCCRequest, - toOpenAIStreamChunk, - toOpenAIErrorChunk, - buildNonStreamingResponse, -} from "@/translate/openai.js"; + toCCRequest as anToCCRequest, + AnthropicStreamEncoder, + buildAnthropicResponse, +} from "@/translate/anthropic.js"; import { getDefaultModels, fetchModelList } from "@/translate/models.js"; -import { resetMessageId } from "@/translate/util.js"; import type { CCEvent } from "@/translate/types.js"; -import { formatSSE, formatSSEDone } from "@/stream.js"; +import { formatSSE, formatSSEDone, formatAnthropicSSE } from "@/stream.js"; import { sendToCC, collectEvents, UpstreamError } from "@/upstream.js"; import { logger } from "@/logger.js"; -import { validateOpenAIChatRequest, ValidationError } from "@/translate/validation.js"; +import { + validateOpenAIChatRequest, + validateAnthropicRequest, + ValidationError, +} from "@/translate/validation.js"; +import type { AnthropicRequest } from "@/translate/anthropic-types.js"; // ────────────────────────────────────────── // Mutable server state @@ -85,6 +89,30 @@ function sendJson(res: http.ServerResponse, status: number, data: unknown): void res.end(JSON.stringify(data)); } +function sendOpenAIError(res: http.ServerResponse, status: number, message: string): void { + sendJson(res, status, { error: { message, type: "proxy_error" } }); +} + +const ANTHROPIC_STATUS_ERROR_MAP: Record = { + 400: "invalid_request_error", + 401: "authentication_error", + 403: "permission_error", + 404: "not_found_error", + 429: "rate_limit_error", + 500: "api_error", + 529: "overloaded_error", +}; + +function sendAnthropicError( + res: http.ServerResponse, + status: number, + type: string, + message: string, +): void { + res.writeHead(status, { "Content-Type": "application/json", ...corsHeaders() }); + res.end(JSON.stringify({ type: "error", error: { type, message } })); +} + function corsHeaders(): Record { return { "Access-Control-Allow-Origin": "*", @@ -126,7 +154,29 @@ function handleHealth(_req: http.IncomingMessage, res: http.ServerResponse): voi }); } -function handleModels(_req: http.IncomingMessage, res: http.ServerResponse): void { +function handleModels(req: http.IncomingMessage, res: http.ServerResponse): void { + const isAnthropic = req.headers["anthropic-version"] !== undefined; + + if (isAnthropic) { + const items = modelList; + const data = { + data: items.map((id: string) => ({ + id, + type: "model" as const, + display_name: id, + created_at: new Date().toISOString(), + max_input_tokens: null as number | null, + max_tokens: null as number | null, + capabilities: null, + })), + has_more: false, + first_id: items.length > 0 ? items[0] : null, + last_id: items.length > 0 ? items[items.length - 1] : null, + }; + sendJson(res, 200, data); + return; + } + const data = { object: "list", data: modelList.map((id: string) => ({ @@ -147,7 +197,7 @@ async function handleChatCompletions( try { rawBody = await parseBody(req); } catch { - return sendJson(res, 400, { error: "Invalid JSON body" }); + return sendOpenAIError(res, 400, "Invalid JSON body"); } let openAIReq; @@ -155,18 +205,19 @@ async function handleChatCompletions( openAIReq = validateOpenAIChatRequest(rawBody); } catch (err) { if (err instanceof ValidationError) { - return sendJson(res, 400, { error: err.message }); + return sendOpenAIError(res, 400, err.message); } - return sendJson(res, 400, { error: "Invalid request body" }); + return sendOpenAIError(res, 400, "Invalid request body"); } const apiKey = extractApiKey(req); if (!apiKey) { - return sendJson(res, 401, { error: "Unauthorized" }); + return sendOpenAIError(res, 401, "Unauthorized"); } const isStream = openAIReq.stream === true; const model = openAIReq.model ?? "default"; + const encoder = new OpenAIStreamEncoder(model); logger.info(`[Incoming Request] Model: ${model}`); logger.info(`[Incoming Request] Tools count: ${openAIReq.tools ? openAIReq.tools.length : 0}`); @@ -178,7 +229,6 @@ async function handleChatCompletions( logger.info(`[Incoming Request] No tools were sent by the client!`); } - resetMessageId(); const ccBody = toCCRequest(openAIReq); const abort = abortOnClientDisconnect(req, res); @@ -204,7 +254,7 @@ async function handleChatCompletions( }); stream.on("data", (event: CCEvent) => { - for (const chunk of toOpenAIStreamChunk(event, model)) { + for (const chunk of encoder.emit(event)) { res.write(formatSSE(chunk)); } }); @@ -215,7 +265,7 @@ async function handleChatCompletions( stream.on("error", (err: Error) => { logger.error("[stream] OpenAI streaming error:", err.message); if (!res.destroyed) { - res.write(formatSSE(toOpenAIErrorChunk(err))); + res.write(formatSSE(encoder.errorChunk(err))); res.write(formatSSEDone()); res.end(); } @@ -223,28 +273,173 @@ async function handleChatCompletions( destroyStreamOnClientDisconnect(req, stream); } else { const events = await collectEvents(stream); - const response = buildNonStreamingResponse(events, model); + const response = buildNonStreamingResponse(events, model, encoder.id); sendJson(res, 200, response); } } catch (err) { - handleUpstreamError(res, err); + handleUpstreamError(res, err, "openai"); + } +} + +async function handleMessages(req: http.IncomingMessage, res: http.ServerResponse): Promise { + let rawBody: unknown; + try { + rawBody = await parseBody(req); + } catch { + return sendAnthropicError(res, 400, "invalid_request_error", "Invalid JSON body"); + } + + let anthropicReq: AnthropicRequest; + try { + anthropicReq = validateAnthropicRequest(rawBody); + } catch (err) { + if (err instanceof ValidationError) { + return sendAnthropicError(res, 400, "invalid_request_error", err.message); + } + return sendAnthropicError(res, 400, "invalid_request_error", "Invalid request body"); + } + + const apiKey = extractApiKey(req); + if (!apiKey) { + return sendAnthropicError(res, 401, "authentication_error", "Missing API key"); + } + + const isStream = anthropicReq.stream === true; + const model = anthropicReq.model; + + const encoder = new AnthropicStreamEncoder(model); + const ccBody = anToCCRequest(anthropicReq); + + const abort = abortOnClientDisconnect(req, res); + + try { + const result = await sendToCC( + ccBody, + { apiBase: config.ccApiBase, apiKey, ccVersion: config.ccVersion }, + abort.signal, + ); + const stream = result.stream; + + if (isStream) { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + ...corsHeaders(), + }); + + stream.on("data", (event: CCEvent) => { + for (const record of encoder.emit(event)) { + res.write(formatAnthropicSSE(record.event, record.data)); + } + }); + stream.on("end", () => res.end()); + stream.on("error", (err: Error) => { + logger.error("[stream] Anthropic streaming error:", err.message); + if (!res.destroyed) { + res.write( + formatAnthropicSSE("error", { + type: "error", + error: { type: "api_error", message: err.message }, + }), + ); + res.end(); + } + }); + destroyStreamOnClientDisconnect(req, stream); + } else { + const events = await collectEvents(stream); + const response = buildAnthropicResponse(events, model, encoder.messageId); + res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() }); + res.end(JSON.stringify(response)); + } + } catch (err) { + handleUpstreamError(res, err, "anthropic"); + } +} + +async function handleCountTokens( + req: http.IncomingMessage, + res: http.ServerResponse, +): Promise { + let rawBody: unknown; + try { + rawBody = await parseBody(req); + } catch { + return sendAnthropicError(res, 400, "invalid_request_error", "Invalid JSON body"); + } + + const body = rawBody as Record; + + const parts: string[] = []; + if (typeof body.system === "string") parts.push(body.system); + else if (Array.isArray(body.system)) { + for (const b of body.system as { text?: string }[]) { + if (b.text) parts.push(b.text); + } + } + const msgs = body.messages as { content?: unknown }[] | undefined; + if (msgs) { + for (const msg of msgs) { + parts.push(typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content)); + } + } + const tools = body.tools as + | { name?: string; description?: string; input_schema?: unknown }[] + | undefined; + if (tools) { + for (const t of tools) { + parts.push(t.name ?? "", t.description ?? "", JSON.stringify(t.input_schema ?? {})); + } } + + const allText = parts.join(""); + let cjk = 0; + let nonCjk = 0; + for (const ch of allText) { + const code = ch.codePointAt(0) ?? 0; + if ( + (code >= 0x4e00 && code <= 0x9fff) || + (code >= 0x3040 && code <= 0x309f) || + (code >= 0x30a0 && code <= 0x30ff) || + (code >= 0xac00 && code <= 0xd7af) + ) { + cjk++; + } else { + nonCjk++; + } + } + + const estimated = Math.ceil(cjk + nonCjk / 4); + + sendJson(res, 200, { input_tokens: estimated }); } // ────────────────────────────────────────── // Error handling // ────────────────────────────────────────── -function handleUpstreamError(res: http.ServerResponse, err: unknown): void { +function handleUpstreamError( + res: http.ServerResponse, + err: unknown, + format: "openai" | "anthropic", +): void { + if (format === "anthropic") { + if (err instanceof UpstreamError) { + const status = err.statusCode >= 400 && err.statusCode < 500 ? err.statusCode : 502; + const type = ANTHROPIC_STATUS_ERROR_MAP[status] ?? "api_error"; + sendAnthropicError(res, status, type, err.message); + } else { + sendAnthropicError(res, 502, "api_error", (err as Error).message); + } + return; + } + if (err instanceof UpstreamError) { const status = err.statusCode >= 400 && err.statusCode < 500 ? err.statusCode : 502; - sendJson(res, status, { - error: { message: err.message, type: "upstream_error", code: err.statusCode }, - }); + sendOpenAIError(res, status, err.message); } else { - sendJson(res, 502, { - error: { message: (err as Error).message, type: "proxy_error" }, - }); + sendOpenAIError(res, 502, (err as Error).message); } } @@ -276,6 +471,8 @@ export function createServer(cfg: Config): http.Server { { method: "GET", path: "/health", handler: handleHealth }, { method: "GET", path: "/v1/models", handler: handleModels }, { method: "POST", path: "/v1/chat/completions", handler: handleChatCompletions }, + { method: "POST", path: "/v1/messages", handler: handleMessages }, + { method: "POST", path: "/v1/messages/count_tokens", handler: handleCountTokens }, ]; const server = http.createServer((req, res) => { diff --git a/src/setup/opencode.ts b/src/setup/opencode.ts index a18ee53..056802f 100644 --- a/src/setup/opencode.ts +++ b/src/setup/opencode.ts @@ -4,8 +4,10 @@ import modelsData from "@/models.json" with { type: "json" }; function buildProviderConfig(): Record { const contextWindows: Record = modelsData.contextWindows ?? {}; - const maxOutputTokens: Record = (modelsData as Record).maxOutputTokens as Record ?? {}; - const modelNames: Record = (modelsData as Record).modelNames as Record ?? {}; + const maxOutputTokens: Record = + ((modelsData as Record).maxOutputTokens as Record) ?? {}; + const modelNames: Record = + ((modelsData as Record).modelNames as Record) ?? {}; const models: Record = {}; for (const id of modelsData.builtin) { const key = id.split("/").pop() ?? id; diff --git a/src/stream.ts b/src/stream.ts index ab95a8d..a18f51c 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -106,3 +106,10 @@ export function formatSSE(data: object): string { export function formatSSEDone(): string { return "data: [DONE]\n\n"; } + +export function formatAnthropicSSE(eventType: string, data: unknown): string { + if (eventType === "message_stop") { + return "event: message_stop\ndata: {}\n\n"; + } + return `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`; +} diff --git a/src/translate/anthropic-models.ts b/src/translate/anthropic-models.ts new file mode 100644 index 0000000..33d5e1e --- /dev/null +++ b/src/translate/anthropic-models.ts @@ -0,0 +1,33 @@ +import { resolveModel, getDefaultModels } from "@/translate/models.js"; + +/** + * Normalize a Claude model ID into an env-var key. + * "claude-sonnet-4-5-20250929" → "CLAUDE_SONNET_4_5" + */ +function normalizeEnvKey(claudeId: string): string { + const stripped = claudeId.replace(/-20\d{6,}$/, ""); + return stripped.toUpperCase().replace(/[^A-Z0-9]/g, "_"); +} + +/** + * Resolve a model ID from an Anthropic client into a CC model ID. + * For claude-* IDs, looks up env vars; for other IDs, passes through. + */ +export function resolveAnthropicModel(requestedModel: string): string { + if (!isClaudeModel(requestedModel)) { + return resolveModel(requestedModel); + } + + const key = normalizeEnvKey(requestedModel); + const envResult = process.env[`ANTHROPIC_MODEL_${key}`]; + if (envResult) return resolveModel(envResult); + + const defaultModel = process.env["ANTHROPIC_DEFAULT_MODEL"]; + if (defaultModel) return resolveModel(defaultModel); + + return resolveModel(getDefaultModels()[0]); +} + +function isClaudeModel(id: string): boolean { + return id.startsWith("claude-"); +} diff --git a/src/translate/anthropic-types.ts b/src/translate/anthropic-types.ts new file mode 100644 index 0000000..1dc884a --- /dev/null +++ b/src/translate/anthropic-types.ts @@ -0,0 +1,213 @@ +// Anthropic request/response/event types for the Messages API. +// Verified against @anthropic-ai/sdk TypeScript types. + +// ── Request ── + +export interface AnthropicRequest { + model: string; + messages: AnthropicMessage[]; + max_tokens: number; + system?: string | TextBlockParam[]; + stream?: boolean; + metadata?: { user_id?: string }; + stop_sequences?: string[]; + temperature?: number; + top_p?: number; + top_k?: number; + tools?: AnthropicTool[]; + tool_choice?: AnthropicToolChoice; + thinking?: { type: "enabled"; budget_tokens: number }; + service_tier?: string; +} + +export interface AnthropicMessage { + role: "user" | "assistant"; + content: string | AnthropicContentBlock[]; +} + +export type AnthropicTool = + | { name: string; description?: string; input_schema: Record } + | { type: string; name: string; description?: string; input_schema: Record }; + +export type AnthropicToolChoice = + | { type: "auto"; disable_parallel_tool_use?: boolean } + | { type: "any" } + | { type: "tool"; name: string } + | { type: "none" }; + +// ── Content blocks (input) ── + +export type AnthropicContentBlock = + | TextBlockParam + | ImageBlockParam + | ToolUseBlockParam + | ToolResultBlockParam + | ThinkingBlockParam + | RedactedThinkingBlockParam + | DocumentBlockParam + | SearchResultBlockParam + | ServerToolParam; + +export interface TextBlockParam { + type: "text"; + text: string; + cache_control?: { type: "ephemeral" }; +} + +export interface ImageBlockParam { + type: "image"; + source: { type: "base64"; media_type: string; data: string } | { type: "url"; url: string }; +} + +export interface ToolUseBlockParam { + type: "tool_use"; + id: string; + name: string; + input: Record; +} + +export interface ToolResultBlockParam { + type: "tool_result"; + tool_use_id: string; + content: string | Array; + is_error?: boolean; +} + +export interface ThinkingBlockParam { + type: "thinking"; + thinking: string; + signature: string; +} + +export interface RedactedThinkingBlockParam { + type: "redacted_thinking"; + data: string; +} + +export interface DocumentBlockParam { + type: "document"; + source: Record; +} + +export interface SearchResultBlockParam { + type: "search_result"; +} + +export interface ServerToolParam { + type: + | "web_search_tool_result" + | "web_fetch_tool_result" + | "code_execution_tool_result" + | "mcp_tool_result" + | "container_upload" + | "server_tool_use" + | "mid_conversation_system"; +} + +// ── Response ── + +export interface AnthropicResponse { + id: string; + type: "message"; + role: "assistant"; + model: string; + content: OutputContentBlock[]; + stop_reason: AnthropicStopReason | null; + stop_sequence: string | null; + usage: AnthropicUsage; +} + +export type OutputContentBlock = OutputTextBlock | OutputThinkingBlock | OutputToolUseBlock; + +export interface OutputTextBlock { + type: "text"; + text: string; +} + +export interface OutputThinkingBlock { + type: "thinking"; + thinking: string; + signature: string; +} + +export interface OutputToolUseBlock { + type: "tool_use"; + id: string; + name: string; + input: Record; +} + +export type AnthropicStopReason = + | "end_turn" + | "max_tokens" + | "stop_sequence" + | "tool_use" + | "pause_turn" + | "refusal" + | "model_context_window_exceeded"; + +export interface AnthropicUsage { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens: number; + cache_read_input_tokens: number; + service_tier?: string; +} + +// ── Streaming events ── + +export type AnthropicSSEEventType = + | "message_start" + | "content_block_start" + | "content_block_delta" + | "content_block_stop" + | "message_delta" + | "message_stop" + | "ping" + | "error" + | "signature_delta"; + +export interface AnthropicSSERecord { + event: AnthropicSSEEventType; + data: Record; +} + +// ── Delta shapes ── + +export interface TextDelta { + type: "text_delta"; + text: string; +} +export interface ThinkingDelta { + type: "thinking_delta"; + thinking: string; +} +export interface InputJsonDelta { + type: "input_json_delta"; + partial_json: string; +} +export interface SignatureDelta { + type: "signature_delta"; + signature: string; +} + +export type DeltaShape = TextDelta | ThinkingDelta | InputJsonDelta | SignatureDelta; + +// ── Content block start shapes ── + +export interface TextBlockStart { + type: "text"; + text: string; +} +export interface ThinkingBlockStart { + type: "thinking"; + thinking: string; +} +export interface ToolUseBlockStart { + type: "tool_use"; + id: string; + name: string; + input: Record; +} + +export type ContentBlockStartShape = TextBlockStart | ThinkingBlockStart | ToolUseBlockStart; diff --git a/src/translate/anthropic.ts b/src/translate/anthropic.ts new file mode 100644 index 0000000..64471d4 --- /dev/null +++ b/src/translate/anthropic.ts @@ -0,0 +1,524 @@ +import crypto from "node:crypto"; +import type { + AnthropicRequest, + AnthropicContentBlock, + ImageBlockParam, + ToolResultBlockParam, + OutputContentBlock, + OutputToolUseBlock, + AnthropicSSERecord, + AnthropicStopReason, + AnthropicResponse, + ContentBlockStartShape, + DeltaShape, +} from "@/translate/anthropic-types.js"; +import type { CCMessage, CCContentPart, CCRequestBody, CCEvent } from "@/translate/types.js"; +import { resolveAnthropicModel } from "@/translate/anthropic-models.js"; +import { + extractUsage, + pruneDanglingTools, + buildCCConfig, + applyNoToolsSafeguard, +} from "@/translate/util.js"; +import { logger } from "@/logger.js"; + +// ── Constants ── + +const REASONING_THRESHOLDS = { LOW: 2000, MEDIUM: 8000 } as const; +const ANTHROPIC_STOP_REASON_MAP: Record = { + stop: "end_turn", + length: "max_tokens", + "tool-call": "tool_use", + "tool-calls": "tool_use", + tool_call: "tool_use", + content_filtered: "stop_sequence", + pause_turn: "pause_turn", + refusal: "refusal", + model_context_window_exceeded: "model_context_window_exceeded", +}; +const INITIAL_OUTPUT_TOKENS = 1; + +// ── Request translator ── + +function toCCMessages(messages: AnthropicRequest["messages"]): { + ccMessages: CCMessage[]; + systemPrompt: string | undefined; +} { + const toolUseIdToName = new Map(); + + for (const msg of messages) { + if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue; + for (const block of msg.content as AnthropicContentBlock[]) { + if (block.type === "tool_use") { + toolUseIdToName.set(block.id, block.name); + } + } + } + + const ccMessages: CCMessage[] = []; + + for (const msg of messages) { + const content = msg.content; + + if (msg.role === "user") { + if (typeof content === "string") { + ccMessages.push({ role: "user", content }); + } else { + const parts = (content as AnthropicContentBlock[]).map(toCCPartFn(toolUseIdToName)); + ccMessages.push({ role: "user", content: parts }); + } + continue; + } + + // assistant + if (typeof content === "string") { + ccMessages.push({ role: "assistant", content }); + } else if (Array.isArray(content)) { + const parts: CCContentPart[] = []; + for (const block of content as AnthropicContentBlock[]) { + if (block.type === "text") { + parts.push({ type: "text", text: block.text }); + } else if (block.type === "tool_use") { + parts.push({ + type: "tool-call", + toolCallId: block.id, + toolName: block.name, + input: block.input, + }); + } + // thinking, redacted_thinking: dropped from history + } + if (parts.length > 0) { + ccMessages.push({ role: "assistant", content: parts }); + } + } + } + + return { + ccMessages: pruneDanglingTools(ccMessages), + systemPrompt: undefined, + }; +} + +function toCCPartFn( + toolUseIdToName: Map, +): (block: AnthropicContentBlock) => CCContentPart { + return (block: AnthropicContentBlock): CCContentPart => { + if (block.type === "text") return { type: "text", text: block.text }; + if (block.type === "image") { + const src = (block as ImageBlockParam).source; + if (src.type === "base64") { + return { type: "image", image: `data:${src.media_type};base64,${src.data}` }; + } + return { type: "image", image: src.url }; + } + if (block.type === "tool_result") { + const trb = block as ToolResultBlockParam; + const name = toolUseIdToName.get(trb.tool_use_id) ?? ""; + const resultText = + typeof trb.content === "string" + ? trb.content + : (trb.content as Array<{ type: string; text?: string }>) + .map((p) => p.text ?? "") + .join(""); + return { + type: "tool-result", + toolCallId: trb.tool_use_id, + toolName: name, + output: { type: "text", value: resultText }, + isError: trb.is_error, + }; + } + return { type: "text", text: "" }; + }; +} + +function resolveReasoningEffort(thinking: AnthropicRequest["thinking"]): string | undefined { + if (!thinking) return undefined; + if (thinking.budget_tokens <= REASONING_THRESHOLDS.LOW) return "low"; + if (thinking.budget_tokens <= REASONING_THRESHOLDS.MEDIUM) return "medium"; + return "high"; +} + +function resolveToolChoice(anthropic: AnthropicRequest): string | undefined { + const tc = anthropic.tool_choice; + if (!tc) return undefined; + if (tc.type === "auto") return undefined; + if (tc.type === "any") return "required"; + if (tc.type === "tool") return tc.name; + if (tc.type === "none") return "none"; + return undefined; +} + +export function toCCRequest( + req: AnthropicRequest, + configOverrides?: Partial, +): CCRequestBody { + const { ccMessages, systemPrompt } = toCCMessages(req.messages); + + let systemText: string | undefined; + if (typeof req.system === "string") { + systemText = req.system; + } else if (Array.isArray(req.system)) { + systemText = req.system + .filter((b) => b.type === "text") + .map((b) => (b.type === "text" ? b.text : "")) + .filter(Boolean) + .join("\n\n"); + } + + const resolvedModel = resolveAnthropicModel(req.model); + + const ccTools = req.tools?.map((t) => ({ + name: t.name, + description: t.description, + input_schema: t.input_schema, + })); + + const finalSystem = systemText + ? systemPrompt != null + ? `${systemPrompt}\n\n${systemText}` + : systemText + : systemPrompt; + + const body: CCRequestBody = { + config: buildCCConfig(configOverrides), + memory: "", + taste: "", + skills: "", + permissionMode: "standard", + params: { + model: resolvedModel, + messages: ccMessages, + stream: req.stream ?? false, + max_tokens: req.max_tokens, + temperature: req.temperature, + stop: req.stop_sequences, + tools: ccTools, + tool_choice: resolveToolChoice(req), + reasoning_effort: resolveReasoningEffort(req.thinking), + }, + threadId: crypto.randomUUID(), + }; + + if (finalSystem) { + body.params.system = finalSystem; + } + + const hasTools = req.tools != null && req.tools.length > 0; + applyNoToolsSafeguard(body, ccMessages, hasTools); + + return body; +} + +// ── Streaming encoder ── + +export class AnthropicStreamEncoder { + readonly messageId: string; + private blockIndex = 0; + private currentBlockType: "text" | "thinking" | "tool_use" | null = null; + private pendingStart: CCEvent | null = null; + private started = false; + private pinged = false; + + constructor(private readonly model: string) { + this.messageId = `msg_${crypto.randomUUID()}`; + } + + emit(event: CCEvent): AnthropicSSERecord[] { + if (event.type === "start") { + this.blockIndex = 0; + this.currentBlockType = null; + this.pendingStart = event; + return []; + } + + if (event.type === "error") { + const msg = + (event.data.message as string) ?? + (event.data.error as { message?: string } | undefined)?.message ?? + JSON.stringify(event.data); + logger.error(`[CC upstream error] ${msg}`); + + const records: AnthropicSSERecord[] = []; + if (!this.started) { + records.push(this.makeMessageStart(0)); + } + this.closeCurrentBlock(records); + records.push({ + event: "error", + data: { type: "error", error: { type: "api_error", message: msg } }, + }); + records.push({ event: "message_stop", data: {} }); + return records; + } + + if (event.type === "finish") { + return this.handleFinish(event); + } + + // Content event + if (!this.started) { + const records: AnthropicSSERecord[] = []; + const startInputTokens = this.pendingStart + ? ( + this.pendingStart.data.totalUsage as Record as + | { + inputTokens?: number; + } + | undefined + )?.inputTokens + : undefined; + + records.push(this.makeMessageStart(startInputTokens ?? 0)); + this.started = true; + return [...records, ...this.handleContent(event)]; + } + + return this.handleContent(event); + } + + private handleFinish(event: CCEvent): AnthropicSSERecord[] { + const records: AnthropicSSERecord[] = []; + + this.closeCurrentBlock(records); + + const finishReason = (event.data.finishReason as string) ?? "stop"; + const usage = extractUsage(event.data as Record); + + records.push({ + event: "message_delta", + data: { + type: "message_delta", + delta: { + stop_reason: ANTHROPIC_STOP_REASON_MAP[finishReason] ?? "end_turn", + stop_sequence: null, + }, + usage: { + output_tokens: usage?.completionTokens ?? 0, + }, + }, + }); + + records.push({ event: "message_stop", data: {} }); + return records; + } + + private handleContent(event: CCEvent): AnthropicSSERecord[] { + const records: AnthropicSSERecord[] = []; + + switch (event.type) { + case "text-delta": + this.ensureBlockOpen(records, "text", () => ({ type: "text", text: "" })); + records.push( + this.makeDelta({ type: "text_delta", text: (event.data.text as string) ?? "" }), + ); + break; + + case "reasoning-delta": + this.ensureBlockOpen(records, "thinking", () => ({ + type: "thinking", + thinking: "", + })); + records.push( + this.makeDelta({ type: "thinking_delta", thinking: (event.data.text as string) ?? "" }), + ); + break; + + case "tool-call-delta": { + const tcId = (event.data.toolCallId as string) ?? ""; + const tcName = (event.data.name as string) ?? ""; + if (this.currentBlockType !== "tool_use") { + this.closeCurrentBlock(records); + this.ensureBlockOpenWith(records, "tool_use", { + type: "tool_use", + id: tcId, + name: tcName, + input: {}, + }); + } + records.push( + this.makeDelta({ + type: "input_json_delta", + partial_json: (event.data.arguments as string) ?? "", + }), + ); + break; + } + + case "tool-call": { + const tcId = (event.data.toolCallId as string) ?? ""; + const tcName = (event.data.toolName as string) ?? (event.data.name as string) ?? ""; + const input = event.data.input ?? event.data.arguments; + const argsStr = + typeof input === "string" ? input : input != null ? JSON.stringify(input) : ""; + this.closeCurrentBlock(records); + this.ensureBlockOpenWith(records, "tool_use", { + type: "tool_use", + id: tcId, + name: tcName, + input: {}, + }); + if (argsStr) { + records.push(this.makeDelta({ type: "input_json_delta", partial_json: argsStr })); + } + this.closeCurrentBlock(records); + break; + } + } + + return records; + } + + private ensureBlockOpen( + records: AnthropicSSERecord[], + type: "text" | "thinking" | "tool_use", + blockFactory: () => ContentBlockStartShape, + ): void { + if (this.currentBlockType === type) return; + this.closeCurrentBlock(records); + + this.ensureBlockOpenWith(records, type, blockFactory()); + } + + private ensureBlockOpenWith( + records: AnthropicSSERecord[], + type: "text" | "thinking" | "tool_use", + block: ContentBlockStartShape, + ): void { + this.currentBlockType = type; + records.push({ + event: "content_block_start", + data: { + type: "content_block_start", + index: this.blockIndex, + content_block: block, + }, + }); + + if (!this.pinged) { + this.pinged = true; + records.push({ event: "ping", data: { type: "ping" } }); + } + } + + private closeCurrentBlock(records: AnthropicSSERecord[]): void { + if (this.currentBlockType === null) return; + + if (this.currentBlockType === "thinking") { + records.push({ + event: "signature_delta", + data: { type: "signature_delta", signature: "_cc_proxy_placeholder" }, + } as AnthropicSSERecord); + } + + records.push({ + event: "content_block_stop", + data: { type: "content_block_stop", index: this.blockIndex }, + }); + + this.blockIndex++; + this.currentBlockType = null; + } + + private makeDelta(delta: DeltaShape): AnthropicSSERecord { + return { + event: "content_block_delta", + data: { type: "content_block_delta", index: this.blockIndex, delta }, + }; + } + + private makeMessageStart(inputTokens: number): AnthropicSSERecord { + return { + event: "message_start", + data: { + type: "message_start", + message: { + id: this.messageId, + type: "message", + role: "assistant", + model: this.model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: inputTokens, + output_tokens: INITIAL_OUTPUT_TOKENS, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + service_tier: "standard", + }, + }, + }, + }; + } +} + +// ── Non-streaming response builder ── + +export function buildAnthropicResponse( + events: CCEvent[], + model: string, + messageId: string, +): AnthropicResponse { + let textContent = ""; + let thinkingContent = ""; + const toolUseBlocks: OutputToolUseBlock[] = []; + + for (const event of events) { + switch (event.type) { + case "text-delta": + textContent += (event.data.text as string) ?? ""; + break; + case "reasoning-delta": + thinkingContent += (event.data.text as string) ?? ""; + break; + case "tool-call": { + const input = event.data.input ?? event.data.arguments; + toolUseBlocks.push({ + type: "tool_use", + id: (event.data.toolCallId as string) ?? "", + name: (event.data.toolName as string) ?? (event.data.name as string) ?? "", + input: + typeof input === "object" && input != null ? (input as Record) : {}, + }); + break; + } + case "finish": + break; + } + } + + const content: OutputContentBlock[] = []; + if (thinkingContent) { + content.push({ + type: "thinking", + thinking: thinkingContent, + signature: "_cc_proxy_placeholder", + }); + } + content.push(...toolUseBlocks); + if (textContent || content.length === 0) { + content.unshift({ type: "text", text: textContent }); + } + + const finishEvent = events.find((e) => e.type === "finish"); + const finishReason = (finishEvent?.data.finishReason as string) ?? "stop"; + const usage = finishEvent ? extractUsage(finishEvent.data as Record) : undefined; + + return { + id: messageId, + type: "message", + role: "assistant", + model, + content, + stop_reason: ANTHROPIC_STOP_REASON_MAP[finishReason] ?? "end_turn", + stop_sequence: null, + usage: { + input_tokens: usage?.promptTokens ?? 0, + output_tokens: usage?.completionTokens ?? 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }; +} diff --git a/src/translate/validation.ts b/src/translate/validation.ts index b8cb54a..0a50a39 100644 --- a/src/translate/validation.ts +++ b/src/translate/validation.ts @@ -1,4 +1,5 @@ import type { OpenAIChatRequest } from "./types.js"; +import type { AnthropicRequest } from "./anthropic-types.js"; export class ValidationError extends Error { constructor(message: string) { @@ -39,3 +40,92 @@ export function validateOpenAIChatRequest(body: unknown): OpenAIChatRequest { return body as OpenAIChatRequest; } + +// ── Anthropic validation ── + +const UNSUPPORTED_CONTENT_TYPES = new Set([ + "document", + "search_result", + "web_search_tool_result", + "web_fetch_tool_result", + "code_execution_tool_result", + "mcp_tool_result", + "container_upload", + "server_tool_use", + "mid_conversation_system", +]); + +const BUILT_IN_TOOL_TYPES = new Set([ + "computer_20241022", + "bash_20241022", + "text_editor_20241022", + "web_search_20250305", +]); + +export function validateAnthropicRequest(body: unknown): AnthropicRequest { + if (!body || typeof body !== "object") { + throw new ValidationError("Request body must be a JSON object"); + } + + const req = body as Record; + + if (typeof req.model !== "string") { + throw new ValidationError("Field 'model' must be a string"); + } + + if (typeof req.max_tokens !== "number") { + throw new ValidationError("Field 'max_tokens' must be a number"); + } + + if (!Array.isArray(req.messages)) { + throw new ValidationError("Field 'messages' must be an array"); + } + + for (let i = 0; i < req.messages.length; i++) { + const msg = req.messages[i] as Record; + if (msg.role !== "user" && msg.role !== "assistant") { + throw new ValidationError(`messages[${i}].role must be "user" or "assistant"`); + } + if (msg.content === undefined) { + throw new ValidationError(`messages[${i}] must contain 'content'`); + } + validateContentBlocks(msg.content, i); + } + + if (Array.isArray(req.tools)) { + for (let i = 0; i < req.tools.length; i++) { + const tool = req.tools[i] as Record; + const type = tool.type as string | undefined; + if (type && BUILT_IN_TOOL_TYPES.has(type)) { + throw new ValidationError( + `tools[${i}]: built-in tool type "${type}" is not supported. Only custom tools ({name, description, input_schema}) are allowed.`, + ); + } + } + } + + if (req.thinking && typeof req.thinking === "object") { + const t = req.thinking as Record; + if (typeof t.budget_tokens === "number" && t.budget_tokens >= (req.max_tokens as number)) { + throw new ValidationError("thinking.budget_tokens must be less than max_tokens"); + } + } + + return body as AnthropicRequest; +} + +function validateContentBlocks(content: unknown, msgIdx: number): void { + if (typeof content === "string") return; + if (!Array.isArray(content)) { + throw new ValidationError(`messages[${msgIdx}].content must be a string or array of blocks`); + } + for (let i = 0; i < content.length; i++) { + const block = content[i] as Record; + if (typeof block.type !== "string") continue; + if (UNSUPPORTED_CONTENT_TYPES.has(block.type)) { + throw new ValidationError( + `messages[${msgIdx}].content[${i}]: block type "${block.type}" is not supported`, + ); + } + } +} diff --git a/src/upstream.ts b/src/upstream.ts index ee47867..9251b5d 100644 --- a/src/upstream.ts +++ b/src/upstream.ts @@ -22,7 +22,9 @@ export function buildHeaders( body: CCRequestBody, ): Record { const sessionId = body.threadId; - logger.debug(`Sending Authorization: Bearer ${apiKey.slice(0, 12)}... (length: ${apiKey.length})`); + logger.debug( + `Sending Authorization: Bearer ${apiKey.slice(0, 12)}... (length: ${apiKey.length})`, + ); return { "Content-Type": "application/json", Accept: "application/json, */*", diff --git a/tests/anthropic-models.test.ts b/tests/anthropic-models.test.ts new file mode 100644 index 0000000..2c0a593 --- /dev/null +++ b/tests/anthropic-models.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { resolveAnthropicModel } from "@/translate/anthropic-models.js"; + +describe("resolveAnthropicModel", () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + delete process.env["ANTHROPIC_MODEL_CLAUDE_SONNET_4_5"]; + delete process.env["ANTHROPIC_DEFAULT_MODEL"]; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("claude-* mapped via env var", () => { + process.env["ANTHROPIC_MODEL_CLAUDE_SONNET_4_5"] = "deepseek/deepseek-v4-pro"; + const result = resolveAnthropicModel("claude-sonnet-4-5-20250929"); + expect(result).toBe("deepseek/deepseek-v4-pro"); + }); + + it("claude-* falls back to ANTHROPIC_DEFAULT_MODEL", () => { + process.env["ANTHROPIC_DEFAULT_MODEL"] = "deepseek/deepseek-v4-flash"; + const result = resolveAnthropicModel("claude-haiku-4-5-20251001"); + expect(result).toBe("deepseek/deepseek-v4-flash"); + }); + + it("claude-* falls back to first builtin when no env vars are set", () => { + const result = resolveAnthropicModel("claude-sonnet-4-5-20250929"); + expect(typeof result).toBe("string"); + expect(result).toBeTruthy(); + }); + + it("non-claude ID passed through", () => { + const result = resolveAnthropicModel("custom-model"); + expect(result).toBe("custom-model"); + }); + + it("full provider/model ID passed through", () => { + const result = resolveAnthropicModel("openai/gpt-4"); + expect(result).toBe("openai/gpt-4"); + }); +}); diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index 3a4e73c..6bbfcc4 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -148,7 +148,7 @@ describe("E2E: OpenAI /v1/chat/completions", () => { }); expect(res.status).toBe(400); const body = (await res.json()) as any; - expect(body.error).toBe("Invalid JSON body"); + expect(body.error.message).toBe("Invalid JSON body"); }); }); diff --git a/tests/stream.test.ts b/tests/stream.test.ts index 98efd2e..ffdeddf 100644 --- a/tests/stream.test.ts +++ b/tests/stream.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect } from "vitest"; import { Readable } from "node:stream"; -import { parseCCLine, formatSSE, formatSSEDone, NDJSONParser } from "@/stream.js"; +import { + parseCCLine, + formatSSE, + formatSSEDone, + formatAnthropicSSE, + NDJSONParser, +} from "@/stream.js"; describe("parseCCLine", () => { it("parses a CC event with data: prefix", () => { @@ -170,3 +176,32 @@ describe("NDJSONParser", () => { expect(events[0].data.text).toBe("flushed"); }); }); + +describe("formatAnthropicSSE", () => { + it("emits event + data lines", () => { + const result = formatAnthropicSSE("message_start", { + type: "message_start", + message: { id: "msg_1", model: "claude" }, + }); + expect(result).toContain("event: message_start"); + expect(result).toContain("data: "); + expect(result).toContain("\n\n"); + }); + + it("message_stop uses empty data", () => { + const result = formatAnthropicSSE("message_stop", {}); + expect(result).toContain("event: message_stop"); + expect(result).toContain("data: {}"); + }); + + it("content_block_delta formats correctly", () => { + const result = formatAnthropicSSE("content_block_delta", { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Hello" }, + }); + expect(result).toContain("event: content_block_delta"); + const parsed = JSON.parse(result.split("data: ")[1].trimEnd()); + expect(parsed.delta.text).toBe("Hello"); + }); +}); diff --git a/tests/translate-anthropic.test.ts b/tests/translate-anthropic.test.ts new file mode 100644 index 0000000..66290c8 --- /dev/null +++ b/tests/translate-anthropic.test.ts @@ -0,0 +1,455 @@ +import { describe, it, expect } from "vitest"; +import { + toCCRequest, + AnthropicStreamEncoder, + buildAnthropicResponse, +} from "@/translate/anthropic.js"; +import type { AnthropicRequest, AnthropicSSERecord } from "@/translate/anthropic-types.js"; +import type { CCEvent } from "@/translate/types.js"; + +describe("toCCRequest", () => { + it("basic text request maps correctly", () => { + const req: AnthropicRequest = { + model: "claude-sonnet-4-5-20250929", + max_tokens: 100, + messages: [{ role: "user", content: "Hello" }], + }; + const result = toCCRequest(req); + expect(result.params.max_tokens).toBe(100); + expect(result.params.messages).toHaveLength(1); + expect(result.params.messages[0].role).toBe("user"); + expect(result.params.messages[0].content).toContain("Hello"); + }); + + it("system as string is injected", () => { + const req: AnthropicRequest = { + model: "claude-sonnet-4-5-20250929", + max_tokens: 100, + system: "You are helpful.", + messages: [{ role: "user", content: "Hello" }], + }; + const result = toCCRequest(req); + expect(result.params.system).toContain("You are helpful."); + }); + + it("system as array joins text blocks", () => { + const req: AnthropicRequest = { + model: "claude-sonnet-4-5-20250929", + max_tokens: 100, + system: [ + { type: "text", text: "Be helpful." }, + { type: "text", text: "Be concise.", cache_control: { type: "ephemeral" } }, + ], + messages: [{ role: "user", content: "Hi" }], + }; + const result = toCCRequest(req); + expect(result.params.system).toContain("Be helpful."); + expect(result.params.system).toContain("Be concise."); + }); + + it("tool_use and tool_result translation", () => { + const req: AnthropicRequest = { + model: "claude-sonnet-4-5-20250929", + max_tokens: 100, + tools: [{ name: "weather", input_schema: { type: "object", properties: {} } }], + messages: [ + { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_1", name: "weather", input: { city: "Paris" } }], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_1", + content: "Sunny", + is_error: false, + }, + ], + }, + ], + }; + const result = toCCRequest(req); + + const toolResultMsg = result.params.messages.find( + (m) => + Array.isArray(m.content) && + m.content.some((p) => (p as Record).type === "tool-result"), + ); + expect(toolResultMsg).toBeDefined(); + const parts = toolResultMsg!.content; + expect(Array.isArray(parts)).toBe(true); + const part = (parts as Array>)[0]; + expect(part.type).toBe("tool-result"); + expect(part.toolName).toBe("weather"); + }); + + it("thinking maps to reasoning_effort", () => { + const req: AnthropicRequest = { + model: "claude-sonnet-4-5-20250929", + max_tokens: 4000, + thinking: { type: "enabled", budget_tokens: 3000 }, + messages: [{ role: "user", content: "Hi" }], + }; + const result = toCCRequest(req); + expect(result.params.reasoning_effort).toBe("medium"); + }); + + it("thinking with low budget maps to low", () => { + const req: AnthropicRequest = { + model: "claude-sonnet-4-5-20250929", + max_tokens: 4000, + thinking: { type: "enabled", budget_tokens: 1500 }, + messages: [{ role: "user", content: "Hi" }], + }; + const result = toCCRequest(req); + expect(result.params.reasoning_effort).toBe("low"); + }); + + it("thinking with high budget maps to high", () => { + const req: AnthropicRequest = { + model: "claude-sonnet-4-5-20250929", + max_tokens: 4000, + thinking: { type: "enabled", budget_tokens: 10000 }, + messages: [{ role: "user", content: "Hi" }], + }; + const result = toCCRequest(req); + expect(result.params.reasoning_effort).toBe("high"); + }); + + it("thinking not set = no reasoning_effort", () => { + const req: AnthropicRequest = { + model: "claude-sonnet-4-5-20250929", + max_tokens: 100, + messages: [{ role: "user", content: "Hi" }], + }; + const result = toCCRequest(req); + expect(result.params.reasoning_effort).toBeUndefined(); + }); + + it("image base64 conversion", () => { + const req: AnthropicRequest = { + model: "claude-sonnet-4-5-20250929", + max_tokens: 100, + messages: [ + { + role: "user", + content: [ + { + type: "image", + source: { type: "base64", media_type: "image/png", data: "abc123" }, + }, + ], + }, + ], + }; + const result = toCCRequest(req); + const part = (result.params.messages[0].content as { type: string; image: string }[])[0]; + expect(part.image).toBe("data:image/png;base64,abc123"); + }); + + it("image URL passthrough", () => { + const req: AnthropicRequest = { + model: "claude-sonnet-4-5-20250929", + max_tokens: 100, + messages: [ + { + role: "user", + content: [ + { + type: "image", + source: { type: "url", url: "https://example.com/img.png" }, + }, + ], + }, + ], + }; + const result = toCCRequest(req); + const part = (result.params.messages[0].content as { type: string; image: string }[])[0]; + expect(part.image).toBe("https://example.com/img.png"); + }); + + it("thinking dropped from assistant history", () => { + const req: AnthropicRequest = { + model: "claude-sonnet-4-5-20250929", + max_tokens: 100, + messages: [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "Hmm...", signature: "sig123" }, + { type: "redacted_thinking", data: "redacted content" }, + { type: "text", text: "The answer is 42" }, + ], + }, + ], + }; + const result = toCCRequest(req); + const msg = result.params.messages[0]; + expect(Array.isArray(msg.content)).toBe(true); + const parts = msg.content as Array>; + expect(parts).toHaveLength(1); + expect(parts[0].type).toBe("text"); + expect(parts[0].text).toBe("The answer is 42"); + }); + + it("tool_choice mapping", () => { + const base: AnthropicRequest = { + model: "claude-sonnet-4-5-20250929", + max_tokens: 100, + messages: [{ role: "user", content: "Hi" }], + tools: [{ name: "calc", input_schema: {} }], + }; + + expect( + toCCRequest({ ...base, tool_choice: { type: "auto" } }).params.tool_choice, + ).toBeUndefined(); + expect(toCCRequest({ ...base, tool_choice: { type: "any" } }).params.tool_choice).toBe( + "required", + ); + expect( + toCCRequest({ ...base, tool_choice: { type: "tool", name: "calc" } }).params.tool_choice, + ).toBe("calc"); + expect(toCCRequest({ ...base, tool_choice: { type: "none" } }).params.tool_choice).toBe("none"); + }); + + it("no-tools safeguard injected", () => { + const req: AnthropicRequest = { + model: "claude-sonnet-4-5-20250929", + max_tokens: 100, + messages: [{ role: "user", content: "Hello" }], + }; + const result = toCCRequest(req); + expect(result.params.system).toContain("chat-only environment"); + }); + + it("stream is passed through", () => { + const req: AnthropicRequest = { + model: "claude-sonnet-4-5-20250929", + max_tokens: 100, + stream: true, + messages: [{ role: "user", content: "Hi" }], + }; + expect(toCCRequest(req).params.stream).toBe(true); + }); +}); + +describe("AnthropicStreamEncoder", () => { + it("text-only stream", () => { + const encoder = new AnthropicStreamEncoder("test-model"); + + const start = encoder.emit({ type: "start", data: {} }); + expect(start).toHaveLength(0); + + const chunks = encoder.emit({ type: "text-delta", data: { text: "Hello" } }); + const events = chunks.map((c: AnthropicSSERecord) => c.event); + expect(events).toContain("message_start"); + expect(events).toContain("content_block_start"); + expect(events).toContain("ping"); + expect(events).toContain("content_block_delta"); + + const delta = chunks.find((c) => c.event === "content_block_delta"); + expect((delta!.data.delta as { text: string })?.text).toBe("Hello"); + + const finish = encoder.emit({ type: "finish", data: { finishReason: "stop" } }); + const finishEvents = finish.map((c) => c.event); + expect(finishEvents).toContain("content_block_stop"); + expect(finishEvents).toContain("message_delta"); + expect(finishEvents).toContain("message_stop"); + }); + + it("stop reason mapping", () => { + const cases: [string, string][] = [ + ["stop", "end_turn"], + ["length", "max_tokens"], + ["tool-call", "tool_use"], + ["tool-calls", "tool_use"], + ["pause_turn", "pause_turn"], + ["refusal", "refusal"], + ["model_context_window_exceeded", "model_context_window_exceeded"], + ]; + + for (const [ccReason, expected] of cases) { + const encoder = new AnthropicStreamEncoder("m"); + encoder.emit({ type: "start", data: {} }); + encoder.emit({ type: "text-delta", data: { text: "." } }); + const finish = encoder.emit({ type: "finish", data: { finishReason: ccReason } }); + const md = finish.find((c) => c.event === "message_delta"); + expect((md!.data.delta as { stop_reason: string })?.stop_reason).toBe(expected); + } + }); + + it("thinking block emits signature_delta on close", () => { + const encoder = new AnthropicStreamEncoder("m"); + encoder.emit({ type: "start", data: {} }); + + const chunks = encoder.emit({ type: "reasoning-delta", data: { text: "Let me think..." } }); + // After reasoning-delta, current block type is "thinking" + // But no signature_delta yet — that only comes on close + expect(chunks.every((c) => c.event !== "signature_delta")).toBe(true); + + const finish = encoder.emit({ type: "finish", data: { finishReason: "stop" } }); + const sig = finish.find((c) => c.event === "signature_delta"); + expect(sig).toBeDefined(); + expect((sig!.data as { signature: string })?.signature).toBe("_cc_proxy_placeholder"); + }); + + it("error before content emits message_start then error", () => { + const encoder = new AnthropicStreamEncoder("m"); + const records = encoder.emit({ type: "error", data: { message: "Boom!" } }); + const events = records.map((r) => r.event); + expect(events).toContain("message_start"); + expect(events).toContain("error"); + expect(events).toContain("message_stop"); + }); + + it("message_start includes full usage shape", () => { + const encoder = new AnthropicStreamEncoder("m"); + encoder.emit({ + type: "start", + data: { totalUsage: { inputTokens: 42 } }, + }); + const chunks = encoder.emit({ type: "text-delta", data: { text: "x" } }); + const ms = chunks.find((c) => c.event === "message_start"); + const msg = ms!.data.message as Record; + expect(msg.type).toBe("message"); + expect(msg.role).toBe("assistant"); + expect(msg.content).toEqual([]); + expect(msg.stop_reason).toBeNull(); + expect(msg.stop_sequence).toBeNull(); + const usage = msg.usage as Record; + expect(usage.input_tokens).toBe(42); + expect(usage.output_tokens).toBe(1); + expect(usage.cache_creation_input_tokens).toBe(0); + expect(usage.cache_read_input_tokens).toBe(0); + expect(usage.service_tier).toBe("standard"); + }); + + it("two encoders are independent (concurrency fix)", () => { + const a = new AnthropicStreamEncoder("a"); + const b = new AnthropicStreamEncoder("b"); + expect(a.messageId).not.toBe(b.messageId); + + a.emit({ type: "start", data: {} }); + b.emit({ type: "start", data: {} }); + + const chunksA = a.emit({ type: "text-delta", data: { text: "A" } }); + const chunksB = b.emit({ type: "text-delta", data: { text: "B" } }); + + const startA = chunksA.find((c) => c.event === "message_start"); + const startB = chunksB.find((c) => c.event === "message_start"); + expect((startA!.data.message as { id: string })?.id).not.toBe( + (startB!.data.message as { id: string })?.id, + ); + }); + + it("tool-call-delta emits content_block_start and input_json_delta", () => { + const encoder = new AnthropicStreamEncoder("m"); + encoder.emit({ type: "start", data: {} }); + + const chunks = encoder.emit({ + type: "tool-call-delta", + data: { toolCallId: "tc1", name: "weather", arguments: '{"city":' }, + }); + const events = chunks.map((c) => c.event); + expect(events).toContain("message_start"); + expect(events).toContain("content_block_start"); + const cbs = chunks.find((c) => c.event === "content_block_start"); + expect((cbs!.data.content_block as { type: string }).type).toBe("tool_use"); + expect(events).toContain("content_block_delta"); + const cbd = chunks.find((c) => c.event === "content_block_delta"); + expect((cbd!.data.delta as { type: string; partial_json: string }).partial_json).toBe( + '{"city":', + ); + }); + + it("tool-call emits content_block_start and input_json_delta with JSON args", () => { + const encoder = new AnthropicStreamEncoder("m"); + encoder.emit({ type: "start", data: {} }); + + const chunks = encoder.emit({ + type: "tool-call", + data: { toolCallId: "tc1", toolName: "weather", input: { city: "Paris" } }, + }); + const events = chunks.map((c) => c.event); + expect(events).toContain("content_block_start"); + const cbd = chunks.find((c) => c.event === "content_block_delta"); + expect((cbd!.data.delta as { type: string; partial_json: string }).partial_json).toBe( + '{"city":"Paris"}', + ); + expect(events).toContain("content_block_stop"); + }); +}); + +describe("buildAnthropicResponse", () => { + it("builds response from text events", () => { + const events: CCEvent[] = [ + { type: "text-delta", data: { text: "Hello" } }, + { type: "text-delta", data: { text: " world" } }, + { + type: "finish", + data: { + finishReason: "stop", + totalUsage: { inputTokens: 5, outputTokens: 2 }, + }, + }, + ]; + const resp = buildAnthropicResponse(events, "test-model", "msg-123"); + expect(resp.id).toBe("msg-123"); + expect(resp.type).toBe("message"); + expect(resp.role).toBe("assistant"); + expect(resp.model).toBe("test-model"); + expect(resp.content).toHaveLength(1); + expect((resp.content[0] as { text: string }).text).toBe("Hello world"); + expect(resp.stop_reason).toBe("end_turn"); + expect(resp.usage.input_tokens).toBe(5); + expect(resp.usage.output_tokens).toBe(2); + }); + + it("includes thinking block when reasoning-delta received", () => { + const events: CCEvent[] = [ + { type: "reasoning-delta", data: { text: "thinking..." } }, + { type: "text-delta", data: { text: "Answer" } }, + { type: "finish", data: { finishReason: "stop" } }, + ]; + const resp = buildAnthropicResponse(events, "m", "id"); + expect(resp.content).toHaveLength(2); + expect(resp.content[0].type).toBe("text"); + expect(resp.content[1].type).toBe("thinking"); + expect((resp.content[1] as { signature: string }).signature).toBe("_cc_proxy_placeholder"); + }); + + it("includes tool_use blocks (no text means no empty text block)", () => { + const events: CCEvent[] = [ + { + type: "tool-call", + data: { toolCallId: "tc1", toolName: "weather", input: { city: "Paris" } }, + }, + { + type: "tool-call", + data: { toolCallId: "tc2", toolName: "search", input: { q: "test" } }, + }, + { type: "finish", data: { finishReason: "tool-call" } }, + ]; + const resp = buildAnthropicResponse(events, "m", "id"); + expect(resp.stop_reason).toBe("tool_use"); + expect(resp.content).toHaveLength(2); + expect(resp.content[0].type).toBe("tool_use"); + expect(resp.content[1].type).toBe("tool_use"); + }); + + it("includes empty text block when no content", () => { + const events: CCEvent[] = [{ type: "finish", data: { finishReason: "stop" } }]; + const resp = buildAnthropicResponse(events, "m", "id"); + expect(resp.content).toHaveLength(1); + expect(resp.content[0].type).toBe("text"); + expect((resp.content[0] as { text: string }).text).toBe(""); + }); + + it("defaults to end_turn stop reason on missing finish event", () => { + const events: CCEvent[] = [{ type: "text-delta", data: { text: "Hi" } }]; + const resp = buildAnthropicResponse(events, "m", "id"); + expect(resp.stop_reason).toBe("end_turn"); + }); +}); diff --git a/tests/translate.test.ts b/tests/translate.test.ts index 5ab043d..418c95c 100644 --- a/tests/translate.test.ts +++ b/tests/translate.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { toCCRequest, buildNonStreamingResponse, toOpenAIStreamChunk } from "@/translate/openai.js"; +import { toCCRequest, buildNonStreamingResponse, OpenAIStreamEncoder } from "@/translate/openai.js"; import { resolveModel, getDefaultModels } from "@/translate/models.js"; -import { resetMessageId } from "@/translate/util.js"; import type { OpenAIChatRequest, CCEvent } from "@/translate/types.js"; // ────────────────────────────────────────── @@ -218,14 +217,18 @@ describe("toCCRequest", () => { }); // ────────────────────────────────────────── -// toOpenAIStreamChunk +// OpenAIStreamEncoder // ────────────────────────────────────────── -describe("toOpenAIStreamChunk", () => { - beforeEach(() => resetMessageId()); +describe("OpenAIStreamEncoder", () => { + let encoder: OpenAIStreamEncoder; + + beforeEach(() => { + encoder = new OpenAIStreamEncoder("test-model"); + }); it("converts start event to role chunk", () => { - const chunks = toOpenAIStreamChunk({ + const chunks = encoder.emit({ type: "start", data: { model: "deepseek/deepseek-v4-pro" }, }); @@ -235,7 +238,7 @@ describe("toOpenAIStreamChunk", () => { }); it("converts text-delta to content chunk", () => { - const chunks = toOpenAIStreamChunk({ + const chunks = encoder.emit({ type: "text-delta", data: { text: "Hello" }, }); @@ -244,7 +247,7 @@ describe("toOpenAIStreamChunk", () => { }); it("converts reasoning-delta to reasoning_content chunk", () => { - const chunks = toOpenAIStreamChunk({ + const chunks = encoder.emit({ type: "reasoning-delta", data: { text: "thinking..." }, }); @@ -253,7 +256,7 @@ describe("toOpenAIStreamChunk", () => { }); it("converts finish event with usage", () => { - const chunks = toOpenAIStreamChunk({ + const chunks = encoder.emit({ type: "finish", data: { finishReason: "stop", @@ -279,7 +282,7 @@ describe("toOpenAIStreamChunk", () => { }; for (const [from, to] of Object.entries(map)) { - const chunks = toOpenAIStreamChunk({ + const chunks = encoder.emit({ type: "finish", data: { finishReason: from as string }, }); @@ -293,8 +296,6 @@ describe("toOpenAIStreamChunk", () => { // ────────────────────────────────────────── describe("buildNonStreamingResponse", () => { - beforeEach(() => resetMessageId()); - it("builds response from text-delta events", () => { const events: CCEvent[] = [ { type: "start", data: { model: "deepseek/deepseek-v4-pro" } }, @@ -303,7 +304,12 @@ describe("buildNonStreamingResponse", () => { { type: "finish", data: { finishReason: "stop", usage: { totalTokens: 5 } } }, ]; - const resp = buildNonStreamingResponse(events, "deepseek/deepseek-v4-pro") as any; + const resp = buildNonStreamingResponse( + events, + "deepseek/deepseek-v4-pro", + "test-id-123", + ) as any; + expect(resp.id).toBe("test-id-123"); expect(resp.choices[0].message.content).toBe("Hello world"); expect(resp.choices[0].finish_reason).toBe("stop"); expect(resp.usage.total_tokens).toBe(5); @@ -317,8 +323,153 @@ describe("buildNonStreamingResponse", () => { { type: "finish", data: { finishReason: "stop" } }, ]; - const resp = buildNonStreamingResponse(events, "model") as any; + const resp = buildNonStreamingResponse(events, "model", "test-id-456") as any; + expect(resp.id).toBe("test-id-456"); expect(resp.choices[0].message.reasoning_content).toBe("thinking"); expect(resp.choices[0].message.content).toBe("Answer"); }); }); + +// ────────────────────────────────────────── +// validateAnthropicRequest +// ────────────────────────────────────────── + +import { validateAnthropicRequest, ValidationError } from "@/translate/validation.js"; + +describe("validateAnthropicRequest", () => { + it("rejects non-object body", () => { + expect(() => validateAnthropicRequest(null)).toThrow(ValidationError); + expect(() => validateAnthropicRequest("string")).toThrow(ValidationError); + }); + + it("rejects missing model", () => { + expect(() => validateAnthropicRequest({ max_tokens: 10, messages: [] })).toThrow( + ValidationError, + ); + }); + + it("rejects missing max_tokens", () => { + expect(() => + validateAnthropicRequest({ model: "x", messages: [{ role: "user", content: "Hi" }] }), + ).toThrow(ValidationError); + }); + + it("rejects missing messages", () => { + expect(() => validateAnthropicRequest({ model: "x", max_tokens: 10 })).toThrow(ValidationError); + }); + + it("rejects invalid message role", () => { + expect(() => + validateAnthropicRequest({ + model: "x", + max_tokens: 10, + messages: [{ role: "system", content: "Hi" }], + }), + ).toThrow(ValidationError); + }); + + it("rejects message missing content", () => { + expect(() => + validateAnthropicRequest({ + model: "x", + max_tokens: 10, + messages: [{ role: "user" }], + }), + ).toThrow(ValidationError); + }); + + it("rejects document content block", () => { + expect(() => + validateAnthropicRequest({ + model: "x", + max_tokens: 10, + messages: [ + { + role: "user", + content: [{ type: "document", source: {} }], + }, + ], + }), + ).toThrow(/document.*not supported/); + }); + + it("rejects built-in tool type", () => { + expect(() => + validateAnthropicRequest({ + model: "x", + max_tokens: 10, + messages: [{ role: "user", content: "Hi" }], + tools: [{ type: "computer_20241022", name: "computer", input_schema: {} }], + }), + ).toThrow(/computer_20241022.*not supported/); + }); + + it("rejects thinking.budget_tokens >= max_tokens", () => { + expect(() => + validateAnthropicRequest({ + model: "x", + max_tokens: 100, + messages: [{ role: "user", content: "Hi" }], + thinking: { type: "enabled", budget_tokens: 200 }, + }), + ).toThrow(/budget_tokens.*less than max_tokens/); + }); + + it("accepts valid request", () => { + const req = validateAnthropicRequest({ + model: "claude-sonnet-4-5-20250929", + max_tokens: 4096, + messages: [{ role: "user", content: "Hello" }], + }); + expect(req.model).toBe("claude-sonnet-4-5-20250929"); + expect(req.max_tokens).toBe(4096); + }); +}); + +describe("concurrency", () => { + it("two encoders produce independent IDs and toolCallIndex sequences", () => { + const encoderA = new OpenAIStreamEncoder("model-a"); + const encoderB = new OpenAIStreamEncoder("model-b"); + + expect(encoderA.id).not.toBe(encoderB.id); + + encoderA.emit({ type: "start", data: {} }); + encoderB.emit({ type: "start", data: {} }); + + const tcA1 = encoderA.emit({ + type: "tool-call", + data: { toolCallId: "c1", toolName: "fnA", input: {} }, + }); + const tcB1 = encoderB.emit({ + type: "tool-call", + data: { toolCallId: "c2", toolName: "fnB", input: {} }, + }); + const tcA2 = encoderA.emit({ + type: "tool-call", + data: { toolCallId: "c3", toolName: "fnA2", input: {} }, + }); + + const getIndex = (chunks: object[]) => { + const toolCallChunks = chunks.filter((c: Record) => { + const choices = c.choices as Record[] | undefined; + return ( + choices?.[0]?.delta != null && + (choices[0].delta as Record).tool_calls != null + ); + }); + const indices: number[] = []; + for (const chunk of toolCallChunks) { + const tc = (chunk as { choices: { delta: { tool_calls: { index: number }[] } }[] }) + .choices[0].delta.tool_calls; + for (const t of tc) indices.push(t.index); + } + return indices; + }; + + const indicesA = getIndex([...tcA1, ...tcA2]); + expect(indicesA).toEqual([0, 1]); + + const indicesB = getIndex(tcB1); + expect(indicesB).toEqual([0]); + }); +}); From a89d38a17d9a8417d2d1ba9d806444598957cdaf Mon Sep 17 00:00:00 2001 From: thaolaptrinh Date: Wed, 1 Jul 2026 10:59:14 +0700 Subject: [PATCH 03/16] refactor(translate): replace env-var model mapping with config-file glob matching --- src/translate/anthropic-models.ts | 53 +++++++----- tests/anthropic-models.test.ts | 135 +++++++++++++++++++++++++----- 2 files changed, 146 insertions(+), 42 deletions(-) diff --git a/src/translate/anthropic-models.ts b/src/translate/anthropic-models.ts index 33d5e1e..d56f9c5 100644 --- a/src/translate/anthropic-models.ts +++ b/src/translate/anthropic-models.ts @@ -1,33 +1,44 @@ import { resolveModel, getDefaultModels } from "@/translate/models.js"; +import { logger } from "@/logger.js"; -/** - * Normalize a Claude model ID into an env-var key. - * "claude-sonnet-4-5-20250929" → "CLAUDE_SONNET_4_5" - */ -function normalizeEnvKey(claudeId: string): string { - const stripped = claudeId.replace(/-20\d{6,}$/, ""); - return stripped.toUpperCase().replace(/[^A-Z0-9]/g, "_"); +export interface AnthropicModelConfig { + default?: string; + mappings?: Record; +} + +let cachedConfig: AnthropicModelConfig | null = null; + +export function initAnthropicModelConfig(config: AnthropicModelConfig | null): void { + cachedConfig = config; +} + +function globToRegex(pattern: string): RegExp { + const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp("^" + escaped.replace(/\\\*/g, ".*") + "$"); } -/** - * Resolve a model ID from an Anthropic client into a CC model ID. - * For claude-* IDs, looks up env vars; for other IDs, passes through. - */ export function resolveAnthropicModel(requestedModel: string): string { - if (!isClaudeModel(requestedModel)) { + if (!requestedModel.startsWith("claude-")) { return resolveModel(requestedModel); } - const key = normalizeEnvKey(requestedModel); - const envResult = process.env[`ANTHROPIC_MODEL_${key}`]; - if (envResult) return resolveModel(envResult); + if (cachedConfig?.mappings) { + for (const [pattern, ccModel] of Object.entries(cachedConfig.mappings)) { + if (!ccModel) continue; + try { + if (globToRegex(pattern).test(requestedModel)) { + return resolveModel(ccModel); + } + } catch { + logger.debug(`Invalid glob pattern in config: "${pattern}"`); + } + } + } - const defaultModel = process.env["ANTHROPIC_DEFAULT_MODEL"]; - if (defaultModel) return resolveModel(defaultModel); + const envDefault = process.env.ANTHROPIC_DEFAULT_MODEL; + if (envDefault) return resolveModel(envDefault); - return resolveModel(getDefaultModels()[0]); -} + if (cachedConfig?.default) return resolveModel(cachedConfig.default); -function isClaudeModel(id: string): boolean { - return id.startsWith("claude-"); + return resolveModel(getDefaultModels()[0]); } diff --git a/tests/anthropic-models.test.ts b/tests/anthropic-models.test.ts index 2c0a593..cdd85c2 100644 --- a/tests/anthropic-models.test.ts +++ b/tests/anthropic-models.test.ts @@ -1,44 +1,137 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { resolveAnthropicModel } from "@/translate/anthropic-models.js"; +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { resolveAnthropicModel, initAnthropicModelConfig } from "@/translate/anthropic-models.js"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +function getConfigPath(): string { + return path.join(os.homedir(), ".config", "commandcode-api-proxy", "anthropic-models.json"); +} describe("resolveAnthropicModel", () => { let originalEnv: NodeJS.ProcessEnv; beforeEach(() => { originalEnv = { ...process.env }; - delete process.env["ANTHROPIC_MODEL_CLAUDE_SONNET_4_5"]; - delete process.env["ANTHROPIC_DEFAULT_MODEL"]; + delete process.env.ANTHROPIC_DEFAULT_MODEL; + initAnthropicModelConfig(null); }); afterEach(() => { process.env = originalEnv; + initAnthropicModelConfig(null); + }); + + test("glob wildcard matches versioned claude ID", () => { + initAnthropicModelConfig({ + default: "fallback-model", + mappings: { "claude-sonnet-*": "mapped-model-a" }, + }); + expect(resolveAnthropicModel("claude-sonnet-4-5-20250929")).toBe("mapped-model-a"); + }); + + test("claude-* catch-all matches any claude ID", () => { + initAnthropicModelConfig({ + mappings: { "claude-*": "mapped-model-b" }, + }); + expect(resolveAnthropicModel("claude-anything-here")).toBe("mapped-model-b"); + }); + + test("FIFO ordering: specific pattern before general returns specific", () => { + initAnthropicModelConfig({ + mappings: { + "claude-*": "general-model", + "claude-sonnet-*": "specific-model", + }, + }); + // claude-* comes first → wins despite claude-sonnet-* also matching + expect(resolveAnthropicModel("claude-sonnet-4-5")).toBe("general-model"); + }); + + test("FIFO ordering: specific first returns specific", () => { + initAnthropicModelConfig({ + mappings: { + "claude-sonnet-*": "specific-model", + "claude-*": "general-model", + }, + }); + expect(resolveAnthropicModel("claude-sonnet-4-5")).toBe("specific-model"); }); - it("claude-* mapped via env var", () => { - process.env["ANTHROPIC_MODEL_CLAUDE_SONNET_4_5"] = "deepseek/deepseek-v4-pro"; - const result = resolveAnthropicModel("claude-sonnet-4-5-20250929"); - expect(result).toBe("deepseek/deepseek-v4-pro"); + test("exact match (no wildcard)", () => { + initAnthropicModelConfig({ + mappings: { "claude-sonnet-4-5-20250929": "exact-model" }, + }); + expect(resolveAnthropicModel("claude-sonnet-4-5-20250929")).toBe("exact-model"); + expect(resolveAnthropicModel("claude-sonnet-4-5")).not.toBe("exact-model"); }); - it("claude-* falls back to ANTHROPIC_DEFAULT_MODEL", () => { - process.env["ANTHROPIC_DEFAULT_MODEL"] = "deepseek/deepseek-v4-flash"; - const result = resolveAnthropicModel("claude-haiku-4-5-20251001"); - expect(result).toBe("deepseek/deepseek-v4-flash"); + test("special chars in pattern are literal", () => { + initAnthropicModelConfig({ + mappings: { "claude-sonnet-4.5-*": "dot-model" }, + }); + expect(resolveAnthropicModel("claude-sonnet-4.5-20250929")).toBe("dot-model"); + // dot should NOT match arbitrary char + expect(resolveAnthropicModel("claude-sonnet-4X5-20250929")).not.toBe("dot-model"); }); - it("claude-* falls back to first builtin when no env vars are set", () => { - const result = resolveAnthropicModel("claude-sonnet-4-5-20250929"); + test("no match → config default", () => { + initAnthropicModelConfig({ + default: "default-model", + mappings: { "claude-opus-*": "opus-model" }, + }); + expect(resolveAnthropicModel("claude-sonnet-4-5")).toBe("default-model"); + }); + + test("no match, no config default → ANTHROPIC_DEFAULT_MODEL env", () => { + process.env.ANTHROPIC_DEFAULT_MODEL = "env-default-model"; + initAnthropicModelConfig({ mappings: {} }); + expect(resolveAnthropicModel("claude-sonnet-4-5")).toBe("env-default-model"); + }); + + test("env default overrides config default", () => { + process.env.ANTHROPIC_DEFAULT_MODEL = "env-wins-model"; + initAnthropicModelConfig({ default: "config-default-model" }); + expect(resolveAnthropicModel("claude-unknown")).toBe("env-wins-model"); + }); + + test("no config, no env → hardcoded fallback", () => { + initAnthropicModelConfig(null); + const result = resolveAnthropicModel("claude-sonnet-4-5"); expect(typeof result).toBe("string"); - expect(result).toBeTruthy(); + expect(result.length).toBeGreaterThan(0); }); - it("non-claude ID passed through", () => { - const result = resolveAnthropicModel("custom-model"); - expect(result).toBe("custom-model"); + test("non-claude model passes through", () => { + initAnthropicModelConfig({ mappings: { "claude-*": "mapped" } }); + expect(resolveAnthropicModel("deepseek/deepseek-v4-pro")).toBe("deepseek/deepseek-v4-pro"); }); - it("full provider/model ID passed through", () => { - const result = resolveAnthropicModel("openai/gpt-4"); - expect(result).toBe("openai/gpt-4"); + test("empty mapping value is skipped", () => { + initAnthropicModelConfig({ + default: "fallback-model", + mappings: { "claude-sonnet-*": "", "claude-*": "real-model" }, + }); + expect(resolveAnthropicModel("claude-sonnet-4-5")).toBe("real-model"); + }); + + test("config with only default, no mappings", () => { + initAnthropicModelConfig({ default: "the-default-model" }); + expect(resolveAnthropicModel("claude-anything")).toBe("the-default-model"); + }); + + test("config with only mappings, no default", () => { + initAnthropicModelConfig({ mappings: { "claude-opus-*": "opus" } }); + // unmatched → hardcoded fallback (not undefined) + const result = resolveAnthropicModel("claude-sonnet-4-5"); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + + test("null config (missing file) uses hardcoded fallback", () => { + initAnthropicModelConfig(null); + const result = resolveAnthropicModel("claude-sonnet-4-5"); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); }); }); From f51257e8e2b2b3a247685c0434ca6a7002eb0293 Mon Sep 17 00:00:00 2001 From: thaolaptrinh Date: Wed, 1 Jul 2026 11:00:40 +0700 Subject: [PATCH 04/16] feat(setup): add --setup-claude-code CLI for config file + env instructions --- src/setup/claude-code.ts | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/setup/claude-code.ts diff --git a/src/setup/claude-code.ts b/src/setup/claude-code.ts new file mode 100644 index 0000000..ba01ace --- /dev/null +++ b/src/setup/claude-code.ts @@ -0,0 +1,42 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { logger } from "@/logger.js"; + +function getConfigPath(): string { + const home = os.homedir(); + return path.join(home, ".config", "commandcode-api-proxy", "anthropic-models.json"); +} + +const DEFAULT_CONFIG = { + default: "deepseek/deepseek-v4-pro", + mappings: { + "claude-sonnet-*": "deepseek/deepseek-v4-pro", + "claude-opus-*": "deepseek/deepseek-v4-pro", + "claude-haiku-*": "deepseek/deepseek-v4-flash", + }, +}; + +export async function setupClaudeCodeConfig(force: boolean): Promise { + const filePath = getConfigPath(); + + if (fs.existsSync(filePath) && !force) { + console.log(`\n ⚠ Config already exists at ${filePath}\n`); + console.log(" Edit it manually, or run: commandcode-api-proxy --setup-claude-code --force\n"); + return; + } + + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n"); + + console.log(`\n ✓ Config written to ${filePath}\n`); + console.log(" Edit the file to customize which CC model each Claude tier maps to."); + console.log( + " Order matters — put specific patterns (claude-sonnet-*) before general (claude-*).\n", + ); + console.log(" Then add these to your shell profile (~/.zshrc or ~/.bashrc):\n"); + console.log(" export ANTHROPIC_BASE_URL=http://127.0.0.1:8787"); + console.log(" export ANTHROPIC_API_KEY=proxy-managed\n"); + console.log(" Restart your shell, then run: claude\n"); +} From 4670eee136341e9d75c23d9e4d2638f5330825d9 Mon Sep 17 00:00:00 2001 From: thaolaptrinh Date: Wed, 1 Jul 2026 11:02:03 +0700 Subject: [PATCH 05/16] feat(config): load anthropic-models.json at startup, add --setup-claude-code handler --- src/config.ts | 19 +++++++++++++++++++ src/proxy.ts | 21 ++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index 8d15bc4..96d767c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,9 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; import { readAuthKey } from "@/auth.js"; +import type { AnthropicModelConfig } from "@/translate/anthropic-models.js"; +import { logger } from "@/logger.js"; interface CliArgs { host?: string; @@ -84,3 +89,17 @@ export function loadConfig(): Config { return { host, port, apiKey, ccApiBase, ccVersion, logLevel }; } + +export function loadAnthropicModelConfig(): AnthropicModelConfig | null { + const home = os.homedir(); + const filePath = path.join(home, ".config", "commandcode-api-proxy", "anthropic-models.json"); + try { + const raw = fs.readFileSync(filePath, "utf-8"); + return JSON.parse(raw) as AnthropicModelConfig; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + logger.warn(`Failed to load anthropic-models.json: ${(err as Error).message}`); + } + return null; + } +} diff --git a/src/proxy.ts b/src/proxy.ts index 0936df2..d2b5dd1 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,9 +1,11 @@ #!/usr/bin/env node -import { loadConfig, fetchLatestCliVersion } from "@/config.js"; +import { loadConfig, fetchLatestCliVersion, loadAnthropicModelConfig } from "@/config.js"; import { createServer } from "@/server.js"; import { saveApiKey, promptForApiKey, readAuthKey, deleteAuth } from "@/auth.js"; import { setupOpenCodeConfig } from "@/setup/opencode.js"; +import { setupClaudeCodeConfig } from "@/setup/claude-code.js"; +import { initAnthropicModelConfig } from "@/translate/anthropic-models.js"; import { logger, initLogger } from "@/logger.js"; const args = process.argv.slice(2); @@ -39,7 +41,15 @@ if (args.includes("--setup-opencode")) { process.exit(0); } +if (args.includes("--setup-claude-code")) { + const force = args.includes("--force"); + await setupClaudeCodeConfig(force); + process.exit(0); +} + const config = loadConfig(); +const anthropicModelConfig = loadAnthropicModelConfig(); +initAnthropicModelConfig(anthropicModelConfig); initLogger(config.logLevel); logger.info( @@ -76,6 +86,15 @@ server.listen(config.port, config.host, () => { console.log(" GET /health"); console.log(" GET /v1/models"); console.log(" POST /v1/chat/completions (OpenAI format)"); + console.log(" POST /v1/messages (Anthropic format)"); + console.log(" POST /v1/messages/count_tokens (Anthropic format)"); + console.log(""); + if (anthropicModelConfig) { + const count = Object.keys(anthropicModelConfig.mappings ?? {}).length; + console.log(` Anthropic models: config loaded (${count} mappings)`); + } else { + console.log(" Anthropic models: defaults (run --setup-claude-code to customize)"); + } console.log(""); console.log(" Press Ctrl+C to stop\n"); }); From e19c0f90fd21c9b5068849aa0c5eb0ddc81eb335 Mon Sep 17 00:00:00 2001 From: thaolaptrinh Date: Wed, 1 Jul 2026 11:02:39 +0700 Subject: [PATCH 06/16] test(setup): add --setup-claude-code tests (create, existing, force) --- tests/setup-claude-code.test.ts | 92 +++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 tests/setup-claude-code.test.ts diff --git a/tests/setup-claude-code.test.ts b/tests/setup-claude-code.test.ts new file mode 100644 index 0000000..07b8097 --- /dev/null +++ b/tests/setup-claude-code.test.ts @@ -0,0 +1,92 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { setupClaudeCodeConfig } from "@/setup/claude-code.js"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +function getConfigPath(): string { + return path.join(os.homedir(), ".config", "commandcode-api-proxy", "anthropic-models.json"); +} + +describe("setupClaudeCodeConfig", () => { + let originalExists: typeof fs.existsSync; + let originalMkdir: typeof fs.mkdirSync; + let originalWrite: typeof fs.writeFileSync; + let consoleLogSpy: ReturnType; + let writtenPath: string | null = null; + let writtenContent: string | null = null; + + beforeEach(() => { + writtenPath = null; + writtenContent = null; + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + originalExists = fs.existsSync; + originalMkdir = fs.mkdirSync; + originalWrite = fs.writeFileSync; + + fs.existsSync = ((p: fs.PathLike) => + p === getConfigPath() ? false : originalExists(p)) as typeof fs.existsSync; + fs.mkdirSync = ((p: fs.PathLike, opts?: fs.MakeDirectoryOptions) => + undefined) as typeof fs.mkdirSync; + fs.writeFileSync = ((p: fs.PathLike, data: string) => { + if (p === getConfigPath()) { + writtenPath = p as string; + writtenContent = data; + } + }) as typeof fs.writeFileSync; + }); + + afterEach(() => { + fs.existsSync = originalExists; + fs.mkdirSync = originalMkdir; + fs.writeFileSync = originalWrite; + consoleLogSpy.mockRestore(); + }); + + test("creates config file with defaults when not exists", async () => { + await setupClaudeCodeConfig(false); + expect(writtenPath).toBe(getConfigPath()); + expect(writtenContent).toBeTruthy(); + + const parsed = JSON.parse(writtenContent!); + expect(parsed.default).toBeDefined(); + expect(parsed.mappings).toBeDefined(); + expect(parsed.mappings["claude-sonnet-*"]).toBeDefined(); + }); + + test("prints instructions with ANTHROPIC_BASE_URL", async () => { + await setupClaudeCodeConfig(false); + const output = consoleLogSpy.mock.calls.map((c) => c.join(" ")).join("\n"); + expect(output).toContain("ANTHROPIC_BASE_URL"); + expect(output).toContain("ANTHROPIC_API_KEY"); + }); + + test("prints order matters hint", async () => { + await setupClaudeCodeConfig(false); + const output = consoleLogSpy.mock.calls.map((c) => c.join(" ")).join("\n"); + expect(output).toContain("Order matters"); + }); + + test("existing file without force warns and does not write", async () => { + fs.existsSync = ((p: fs.PathLike) => + p === getConfigPath() ? true : originalExists(p)) as typeof fs.existsSync; + + await setupClaudeCodeConfig(false); + + expect(writtenPath).toBeNull(); + const output = consoleLogSpy.mock.calls.map((c) => c.join(" ")).join("\n"); + expect(output).toContain("already exists"); + expect(output).toContain("--force"); + }); + + test("existing file with force overwrites", async () => { + fs.existsSync = ((p: fs.PathLike) => + p === getConfigPath() ? true : originalExists(p)) as typeof fs.existsSync; + + await setupClaudeCodeConfig(true); + + expect(writtenPath).toBe(getConfigPath()); + expect(writtenContent).toBeTruthy(); + }); +}); From ed9d89b3ba1762ebdd8e9a3d1ac6a6c99f8601fe Mon Sep 17 00:00:00 2001 From: thaolaptrinh Date: Wed, 1 Jul 2026 11:07:26 +0700 Subject: [PATCH 07/16] docs: replace env-var model mapping with config-file + --setup-claude-code --- .env.example | 5 +---- DEVELOPMENT.md | 3 ++- README.md | 44 ++++++++++++++++++++++++++++++-------------- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index 179a7a5..29c7006 100644 --- a/.env.example +++ b/.env.example @@ -5,8 +5,5 @@ CC_API_KEY= HOST=127.0.0.1 PORT=8787 -# Anthropic model mapping (optional) +# Anthropic model mapping (optional — prefer --setup-claude-code) # ANTHROPIC_DEFAULT_MODEL=deepseek/deepseek-v4-pro -# ANTHROPIC_MODEL_CLAUDE_SONNET_4_5=glm-5.1 -# ANTHROPIC_MODEL_CLAUDE_OPUS_4_1=deepseek/deepseek-v4-pro -# ANTHROPIC_MODEL_CLAUDE_HAIKU=deepseek/deepseek-v4-flash diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e3253cc..af4c0df 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -46,7 +46,8 @@ src/ ├── stream.ts # NDJSON parser & SSE formatter ├── upstream.ts # CC API client ├── setup/ -│ └── opencode.ts # opencode.json bootstrap helper +│ ├── opencode.ts # opencode.json bootstrap helper +│ └── claude-code.ts # claude-code config + model mapping setup ├── translate/ │ ├── types.ts # Shared types (OpenAI, CC, UsageData) │ ├── models.ts # Model resolution & aliasing diff --git a/README.md b/README.md index 2d5a3db..996020a 100644 --- a/README.md +++ b/README.md @@ -56,12 +56,13 @@ Get your API key from https://commandcode.ai/settings. ### CLI options -| Option | Description | Default | -| ------------------ | --------------------------------- | ----------- | -| `--host` | Bind address | `127.0.0.1` | -| `--port` | Port | `8787` | -| `--api-key` | Command Code API key | — | -| `--setup-opencode` | Generate OpenCode provider config | — | +| Option | Description | Default | +| --------------------- | --------------------------------- | ----------- | +| `--host` | Bind address | `127.0.0.1` | +| `--port` | Port | `8787` | +| `--api-key` | Command Code API key | — | +| `--setup-opencode` | Generate OpenCode provider config | — | +| `--setup-claude-code` | Generate Claude Code model config | — | ## Endpoints @@ -106,18 +107,33 @@ curl http://127.0.0.1:8787/v1/messages/count_tokens \ ### Anthropic model mapping Anthropic clients send Claude model IDs (e.g., `claude-sonnet-4-5-20250929`). -Map them to CC models via environment variables: +The proxy maps them to CC models via a config file with glob wildcards: ```bash -ANTHROPIC_DEFAULT_MODEL=deepseek/deepseek-v4-pro # fallback for any unmapped Claude ID -ANTHROPIC_MODEL_CLAUDE_SONNET_4_5=glm-5.1 # map a specific Claude variant -ANTHROPIC_MODEL_CLAUDE_OPUS_4_1=deepseek/deepseek-v4-pro -ANTHROPIC_MODEL_CLAUDE_HAIKU=deepseek/deepseek-v4-flash +npx commandcode-api-proxy --setup-claude-code ``` -The proxy normalizes Claude IDs to env-var keys: -`claude-sonnet-4-5-20250929` → `CLAUDE_SONNET_4_5`. Non-Claude model IDs -pass through unchanged. +This writes `~/.config/commandcode-api-proxy/anthropic-models.json`: + +```json +{ + "default": "deepseek/deepseek-v4-pro", + "mappings": { + "claude-sonnet-*": "deepseek/deepseek-v4-pro", + "claude-opus-*": "deepseek/deepseek-v4-pro", + "claude-haiku-*": "deepseek/deepseek-v4-flash" + } +} +``` + +**Glob matching:** `*` matches any characters. First matching pattern wins — +put specific patterns before general ones (e.g. `claude-sonnet-*` before +`claude-*`). Edit the file to customize mappings, then restart the proxy. + +Non-Claude model IDs pass through unchanged. + +**Optional override:** Set `ANTHROPIC_DEFAULT_MODEL` env var to override the +config file's `default` field without editing the file. ### Anthropic limitations From 9240cab8de373cb52a301af10dc2de51a75a527c Mon Sep 17 00:00:00 2001 From: thaolaptrinh Date: Wed, 1 Jul 2026 11:36:19 +0700 Subject: [PATCH 08/16] test(e2e): add Anthropic endpoint e2e tests - /v1/messages (non-streaming + streaming) - /v1/messages/count_tokens - /v1/models content-negotiation (Anthropic vs OpenAI shape) - Anthropic error shapes (400 invalid_request_error) --- tests/e2e.test.ts | 191 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index 6bbfcc4..9fc67c1 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -224,3 +224,194 @@ describe("E2E: health and models", () => { expect(res.headers.get("access-control-allow-origin")).toBe("*"); }); }); + +// ────────────────────────────────────────── +// E2E: Anthropic /v1/messages +// ────────────────────────────────────────── + +describe("E2E: Anthropic /v1/messages", () => { + let server: http.Server; + let baseUrl: string; + const port = 19005; + + beforeAll(async () => { + const config = { ...loadConfig(), port, apiKey: "test-key", host: "127.0.0.1" }; + server = createServer(config); + await new Promise((resolve) => server.listen(port, "127.0.0.1", () => resolve())); + baseUrl = `http://127.0.0.1:${port}`; + }); + + afterAll(() => new Promise((resolve) => server.close(() => resolve()))); + + afterEach(() => { + sendToCCSpy.mockReset(); + }); + + it("non-streaming: returns full Anthropic message", async () => { + sendToCCSpy.mockResolvedValue({ stream: fakeStream() }); + + const res = await fetch(`${baseUrl}/v1/messages`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": "test-key", + }, + body: JSON.stringify({ + model: "claude-sonnet-4-5-20250929", + max_tokens: 100, + messages: [{ role: "user", content: "Hi" }], + stream: false, + }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as any; + expect(body.type).toBe("message"); + expect(body.role).toBe("assistant"); + expect(body.content).toBeInstanceOf(Array); + expect(body.content.length).toBeGreaterThan(0); + expect(body.content[0].type).toBe("text"); + expect(body.content[0].text).toBe("Hello world"); + expect(body.stop_reason).toBe("end_turn"); + expect(body.usage).toBeDefined(); + expect(body.usage.input_tokens).toBe(5); + expect(body.usage.output_tokens).toBe(10); + }); + + it("streaming: returns Anthropic SSE events", async () => { + sendToCCSpy.mockResolvedValue({ stream: fakeStream() }); + + const res = await fetch(`${baseUrl}/v1/messages`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": "test-key", + }, + body: JSON.stringify({ + model: "claude-sonnet-4-5-20250929", + max_tokens: 100, + messages: [{ role: "user", content: "Hi" }], + stream: true, + }), + }); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/event-stream"); + + const text = await res.text(); + expect(text).toContain("event: message_start"); + expect(text).toContain("event: content_block_start"); + expect(text).toContain("event: content_block_delta"); + expect(text).toContain("event: content_block_stop"); + expect(text).toContain("event: message_delta"); + expect(text).toContain("event: message_stop"); + }); + + it("returns 400 for invalid JSON body with Anthropic error shape", async () => { + const res = await fetch(`${baseUrl}/v1/messages`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": "test-key" }, + body: "not json", + }); + expect(res.status).toBe(400); + const body = (await res.json()) as any; + expect(body.type).toBe("error"); + expect(body.error.type).toBe("invalid_request_error"); + }); + + it("returns 400 for missing max_tokens with Anthropic error shape", async () => { + const res = await fetch(`${baseUrl}/v1/messages`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": "test-key" }, + body: JSON.stringify({ + model: "claude-sonnet-4-5-20250929", + messages: [{ role: "user", content: "Hi" }], + }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as any; + expect(body.type).toBe("error"); + expect(body.error.type).toBe("invalid_request_error"); + }); +}); + +// ────────────────────────────────────────── +// E2E: Anthropic /v1/messages/count_tokens +// ────────────────────────────────────────── + +describe("E2E: Anthropic /v1/messages/count_tokens", () => { + let server: http.Server; + let baseUrl: string; + const port = 19006; + + beforeAll(async () => { + const config = { ...loadConfig(), port, apiKey: "test-key", host: "127.0.0.1" }; + server = createServer(config); + await new Promise((resolve) => server.listen(port, "127.0.0.1", () => resolve())); + baseUrl = `http://127.0.0.1:${port}`; + }); + + afterAll(() => new Promise((resolve) => server.close(() => resolve()))); + + it("returns estimated input_tokens", async () => { + const res = await fetch(`${baseUrl}/v1/messages/count_tokens`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": "test-key" }, + body: JSON.stringify({ + model: "claude-sonnet-4-5-20250929", + messages: [{ role: "user", content: "Hello World!" }], + }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as any; + expect(body.input_tokens).toBeDefined(); + expect(typeof body.input_tokens).toBe("number"); + expect(body.input_tokens).toBeGreaterThan(0); + }); +}); + +// ────────────────────────────────────────── +// E2E: /v1/models with anthropic-version header +// ────────────────────────────────────────── + +describe("E2E: /v1/models Anthropic shape", () => { + let server: http.Server; + let baseUrl: string; + const port = 19007; + + beforeAll(async () => { + const config = { ...loadConfig(), port, apiKey: null, host: "127.0.0.1" }; + server = createServer(config); + await new Promise((resolve) => server.listen(port, "127.0.0.1", () => resolve())); + baseUrl = `http://127.0.0.1:${port}`; + }); + + afterAll(() => new Promise((resolve) => server.close(() => resolve()))); + + it("returns Anthropic model shape with anthropic-version header", async () => { + const res = await fetch(`${baseUrl}/v1/models`, { + headers: { "anthropic-version": "2023-06-01" }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as any; + expect(body.data).toBeInstanceOf(Array); + expect(body.data.length).toBeGreaterThan(0); + expect(body.data[0].type).toBe("model"); + expect(body.data[0]).toHaveProperty("display_name"); + expect(body.data[0]).toHaveProperty("created_at"); + expect(body).toHaveProperty("has_more"); + expect(body).toHaveProperty("first_id"); + expect(body).toHaveProperty("last_id"); + }); + + it("returns OpenAI model shape without anthropic-version header", async () => { + const res = await fetch(`${baseUrl}/v1/models`); + + expect(res.status).toBe(200); + const body = (await res.json()) as any; + expect(body.object).toBe("list"); + expect(body.data[0].object).toBe("model"); + }); +}); From e02f4fc9c398bc9889f85cab32285087888c966f Mon Sep 17 00:00:00 2001 From: thaolaptrinh Date: Wed, 1 Jul 2026 11:49:17 +0700 Subject: [PATCH 09/16] feat(setup): --setup-claude-code auto-appends env vars to shell RC Instead of printing instructions, detect shell (zsh/bash/fish) and append ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY=proxy-managed to the user's RC file. Detect already-set vars to avoid duplicates. --- src/setup/claude-code.ts | 90 +++++++++++++++++++++----- tests/setup-claude-code.test.ts | 108 ++++++++++++++++++++++---------- 2 files changed, 148 insertions(+), 50 deletions(-) diff --git a/src/setup/claude-code.ts b/src/setup/claude-code.ts index ba01ace..5bc6653 100644 --- a/src/setup/claude-code.ts +++ b/src/setup/claude-code.ts @@ -1,7 +1,6 @@ import fs from "node:fs"; import path from "node:path"; import os from "node:os"; -import { logger } from "@/logger.js"; function getConfigPath(): string { const home = os.homedir(); @@ -17,26 +16,85 @@ const DEFAULT_CONFIG = { }, }; +// ── Shell RC file detection ── + +function detectShellRc(): string | null { + const shell = process.env.SHELL || ""; + const home = os.homedir(); + + if (shell.includes("zsh")) return path.join(home, ".zshrc"); + if (shell.includes("bash")) return path.join(home, ".bashrc"); + if (shell.includes("fish")) return path.join(home, ".config", "fish", "config.fish"); + + return null; +} + +function rcExportLines(): string[] { + const shell = process.env.SHELL || ""; + const lines = [ + `export ANTHROPIC_BASE_URL="http://127.0.0.1:8787"`, + `export ANTHROPIC_API_KEY="proxy-managed"`, + ]; + if (shell.includes("fish")) { + return [ + `set -gx ANTHROPIC_BASE_URL "http://127.0.0.1:8787"`, + `set -gx ANTHROPIC_API_KEY "proxy-managed"`, + ]; + } + return lines; +} + +function appendToRc(rcPath: string, lines: string[]): boolean { + let content = ""; + try { + content = fs.readFileSync(rcPath, "utf-8"); + } catch { + content = ""; + } + + const alreadyHasBaseUrl = content.includes("ANTHROPIC_BASE_URL"); + const alreadyHasApiKey = content.includes("ANTHROPIC_API_KEY"); + + if (alreadyHasBaseUrl && alreadyHasApiKey) return false; + + const toAppend: string[] = []; + if (!alreadyHasBaseUrl) toAppend.push(lines[0]); + if (!alreadyHasApiKey) toAppend.push(lines[1]); + + const newSection = `\n# Command Code API Proxy (added by --setup-claude-code)\n${toAppend.join("\n")}\n`; + fs.appendFileSync(rcPath, newSection); + return true; +} + +// ── Main ── + export async function setupClaudeCodeConfig(force: boolean): Promise { - const filePath = getConfigPath(); + const configPath = getConfigPath(); - if (fs.existsSync(filePath) && !force) { - console.log(`\n ⚠ Config already exists at ${filePath}\n`); + if (fs.existsSync(configPath) && !force) { + console.log(`\n ⚠ Config already exists at ${configPath}\n`); console.log(" Edit it manually, or run: commandcode-api-proxy --setup-claude-code --force\n"); return; } - const dir = path.dirname(filePath); + const dir = path.dirname(configPath); fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(filePath, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n"); - - console.log(`\n ✓ Config written to ${filePath}\n`); - console.log(" Edit the file to customize which CC model each Claude tier maps to."); - console.log( - " Order matters — put specific patterns (claude-sonnet-*) before general (claude-*).\n", - ); - console.log(" Then add these to your shell profile (~/.zshrc or ~/.bashrc):\n"); - console.log(" export ANTHROPIC_BASE_URL=http://127.0.0.1:8787"); - console.log(" export ANTHROPIC_API_KEY=proxy-managed\n"); - console.log(" Restart your shell, then run: claude\n"); + fs.writeFileSync(configPath, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n"); + + console.log(`\n ✓ Config written to ${configPath}\n`); + + const rcPath = detectShellRc(); + if (rcPath) { + const lines = rcExportLines(); + const appended = appendToRc(rcPath, lines); + if (appended) { + console.log(` ✓ Added Claude Code env vars to ${rcPath}\n`); + console.log(` Restart your shell, then run: claude\n`); + } else { + console.log(` ✓ ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY already set in ${rcPath}\n`); + } + } else { + console.log(" Could not detect shell. Add these to your shell profile:\n"); + console.log(` ${rcExportLines().join("\n ")}\n`); + } } diff --git a/tests/setup-claude-code.test.ts b/tests/setup-claude-code.test.ts index 07b8097..4fbdfed 100644 --- a/tests/setup-claude-code.test.ts +++ b/tests/setup-claude-code.test.ts @@ -9,84 +9,124 @@ function getConfigPath(): string { } describe("setupClaudeCodeConfig", () => { - let originalExists: typeof fs.existsSync; - let originalMkdir: typeof fs.mkdirSync; - let originalWrite: typeof fs.writeFileSync; + let snapshot: Record; let consoleLogSpy: ReturnType; let writtenPath: string | null = null; let writtenContent: string | null = null; + let appendedPath: string | null = null; + let appendedContent: string | null = null; + let rcContent: string = ""; + + function saveFs(): void { + snapshot = { + existsSync: fs.existsSync as unknown as Record, + mkdirSync: fs.mkdirSync, + writeFileSync: fs.writeFileSync, + appendFileSync: fs.appendFileSync, + readFileSync: fs.readFileSync, + }; + } + + function restoreFs(): void { + fs.existsSync = snapshot.existsSync as typeof fs.existsSync; + fs.mkdirSync = snapshot.mkdirSync as typeof fs.mkdirSync; + fs.writeFileSync = snapshot.writeFileSync as typeof fs.writeFileSync; + fs.appendFileSync = snapshot.appendFileSync as typeof fs.appendFileSync; + fs.readFileSync = snapshot.readFileSync as typeof fs.readFileSync; + } beforeEach(() => { writtenPath = null; writtenContent = null; + appendedPath = null; + appendedContent = null; + rcContent = ""; + process.env.SHELL = "/usr/bin/zsh"; consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + saveFs(); - originalExists = fs.existsSync; - originalMkdir = fs.mkdirSync; - originalWrite = fs.writeFileSync; - - fs.existsSync = ((p: fs.PathLike) => - p === getConfigPath() ? false : originalExists(p)) as typeof fs.existsSync; - fs.mkdirSync = ((p: fs.PathLike, opts?: fs.MakeDirectoryOptions) => - undefined) as typeof fs.mkdirSync; + fs.existsSync = ((p: fs.PathLike) => { + if (p === getConfigPath()) return false; + return true; + }) as typeof fs.existsSync; + fs.mkdirSync = (() => undefined) as typeof fs.mkdirSync; fs.writeFileSync = ((p: fs.PathLike, data: string) => { if (p === getConfigPath()) { writtenPath = p as string; writtenContent = data; } }) as typeof fs.writeFileSync; + fs.appendFileSync = ((p: fs.PathLike, data: string) => { + appendedPath = p as string; + appendedContent = data; + }) as typeof fs.appendFileSync; + fs.readFileSync = (() => "") as typeof fs.readFileSync; }); afterEach(() => { - fs.existsSync = originalExists; - fs.mkdirSync = originalMkdir; - fs.writeFileSync = originalWrite; + restoreFs(); consoleLogSpy.mockRestore(); + delete process.env.SHELL; }); - test("creates config file with defaults when not exists", async () => { + test("creates config file with defaults", async () => { await setupClaudeCodeConfig(false); expect(writtenPath).toBe(getConfigPath()); - expect(writtenContent).toBeTruthy(); - const parsed = JSON.parse(writtenContent!); expect(parsed.default).toBeDefined(); - expect(parsed.mappings).toBeDefined(); expect(parsed.mappings["claude-sonnet-*"]).toBeDefined(); }); - test("prints instructions with ANTHROPIC_BASE_URL", async () => { + test("appends proxy env vars to zshrc", async () => { + process.env.SHELL = "/usr/bin/zsh"; await setupClaudeCodeConfig(false); - const output = consoleLogSpy.mock.calls.map((c) => c.join(" ")).join("\n"); - expect(output).toContain("ANTHROPIC_BASE_URL"); - expect(output).toContain("ANTHROPIC_API_KEY"); + expect(appendedPath).toContain(".zshrc"); + expect(appendedContent).toContain("ANTHROPIC_BASE_URL"); + expect(appendedContent).toContain("ANTHROPIC_API_KEY"); + expect(appendedContent).toContain("proxy-managed"); }); - test("prints order matters hint", async () => { + test("appends to bashrc for bash", async () => { + process.env.SHELL = "/usr/bin/bash"; await setupClaudeCodeConfig(false); - const output = consoleLogSpy.mock.calls.map((c) => c.join(" ")).join("\n"); - expect(output).toContain("Order matters"); + expect(appendedPath).toContain(".bashrc"); + expect(appendedContent).toContain("export ANTHROPIC"); }); - test("existing file without force warns and does not write", async () => { - fs.existsSync = ((p: fs.PathLike) => - p === getConfigPath() ? true : originalExists(p)) as typeof fs.existsSync; + test("uses fish syntax for fish shell", async () => { + process.env.SHELL = "/usr/bin/fish"; + await setupClaudeCodeConfig(false); + expect(appendedContent).toContain("set -gx ANTHROPIC"); + }); + test("does not duplicate ANTHROPIC_BASE_URL", async () => { + fs.readFileSync = (() => + 'export ANTHROPIC_BASE_URL="http://localhost:8787"') as typeof fs.readFileSync; await setupClaudeCodeConfig(false); + expect(appendedContent).toContain("ANTHROPIC_API_KEY"); + expect(appendedContent).not.toContain("ANTHROPIC_BASE_URL"); + }); + test("does not append if both vars already in RC", async () => { + fs.readFileSync = (() => + 'export ANTHROPIC_BASE_URL="http://localhost:8787"\nexport ANTHROPIC_API_KEY="x"') as typeof fs.readFileSync; + await setupClaudeCodeConfig(false); + expect(appendedPath).toBeNull(); + }); + + test("existing file without force warns", async () => { + fs.existsSync = ((p: fs.PathLike) => true) as typeof fs.existsSync; + await setupClaudeCodeConfig(false); expect(writtenPath).toBeNull(); + expect(appendedPath).toBeNull(); const output = consoleLogSpy.mock.calls.map((c) => c.join(" ")).join("\n"); expect(output).toContain("already exists"); - expect(output).toContain("--force"); }); test("existing file with force overwrites", async () => { - fs.existsSync = ((p: fs.PathLike) => - p === getConfigPath() ? true : originalExists(p)) as typeof fs.existsSync; - + fs.existsSync = ((p: fs.PathLike) => true) as typeof fs.existsSync; await setupClaudeCodeConfig(true); - expect(writtenPath).toBe(getConfigPath()); - expect(writtenContent).toBeTruthy(); + expect(appendedPath).toBeTruthy(); }); }); From 1e3311092153ce2b3383629ab2c0f2cbf34108b0 Mon Sep 17 00:00:00 2001 From: thaolaptrinh Date: Wed, 1 Jul 2026 12:04:21 +0700 Subject: [PATCH 10/16] feat(setup): --setup-claude-code creates dedicated settings file - Write model config to ~/.config/commandcode-api-proxy/anthropic-models.json - Write Claude Code settings to ~/.config/commandcode-api-proxy/claude-settings.json (does NOT touch ~/.claude/settings.json) - Print usage: claude --settings or alias shortcut --- README.md | 23 ++++++- src/setup/claude-code.ts | 91 ++++++------------------- tests/setup-claude-code.test.ts | 117 ++++++++++++-------------------- 3 files changed, 87 insertions(+), 144 deletions(-) diff --git a/README.md b/README.md index 996020a..00916a5 100644 --- a/README.md +++ b/README.md @@ -240,11 +240,28 @@ response = client.chat.completions.create( ### Claude Code -Set the base URL and API key in your environment: +Run the proxy's setup to generate model config and Claude Code settings: ```bash -export ANTHROPIC_BASE_URL=http://127.0.0.1:8787 -export ANTHROPIC_API_KEY=proxy-managed +npx commandcode-api-proxy --setup-claude-code +``` + +This creates: + +1. Model mapping config at `~/.config/commandcode-api-proxy/anthropic-models.json` +2. Claude Code settings at `~/.config/commandcode-api-proxy/claude-settings.json` + +Then run: + +```bash +claude --settings ~/.config/commandcode-api-proxy/claude-settings.json +``` + +Or set an alias: + +```bash +alias claude-proxy="claude --settings ~/.config/commandcode-api-proxy/claude-settings.json" +claude-proxy ``` ### Anthropic SDK (Python) diff --git a/src/setup/claude-code.ts b/src/setup/claude-code.ts index 5bc6653..fd59e2d 100644 --- a/src/setup/claude-code.ts +++ b/src/setup/claude-code.ts @@ -3,8 +3,11 @@ import path from "node:path"; import os from "node:os"; function getConfigPath(): string { - const home = os.homedir(); - return path.join(home, ".config", "commandcode-api-proxy", "anthropic-models.json"); + return path.join(os.homedir(), ".config", "commandcode-api-proxy", "anthropic-models.json"); +} + +function getClaudeProxySettingsPath(): string { + return path.join(os.homedir(), ".config", "commandcode-api-proxy", "claude-settings.json"); } const DEFAULT_CONFIG = { @@ -16,58 +19,6 @@ const DEFAULT_CONFIG = { }, }; -// ── Shell RC file detection ── - -function detectShellRc(): string | null { - const shell = process.env.SHELL || ""; - const home = os.homedir(); - - if (shell.includes("zsh")) return path.join(home, ".zshrc"); - if (shell.includes("bash")) return path.join(home, ".bashrc"); - if (shell.includes("fish")) return path.join(home, ".config", "fish", "config.fish"); - - return null; -} - -function rcExportLines(): string[] { - const shell = process.env.SHELL || ""; - const lines = [ - `export ANTHROPIC_BASE_URL="http://127.0.0.1:8787"`, - `export ANTHROPIC_API_KEY="proxy-managed"`, - ]; - if (shell.includes("fish")) { - return [ - `set -gx ANTHROPIC_BASE_URL "http://127.0.0.1:8787"`, - `set -gx ANTHROPIC_API_KEY "proxy-managed"`, - ]; - } - return lines; -} - -function appendToRc(rcPath: string, lines: string[]): boolean { - let content = ""; - try { - content = fs.readFileSync(rcPath, "utf-8"); - } catch { - content = ""; - } - - const alreadyHasBaseUrl = content.includes("ANTHROPIC_BASE_URL"); - const alreadyHasApiKey = content.includes("ANTHROPIC_API_KEY"); - - if (alreadyHasBaseUrl && alreadyHasApiKey) return false; - - const toAppend: string[] = []; - if (!alreadyHasBaseUrl) toAppend.push(lines[0]); - if (!alreadyHasApiKey) toAppend.push(lines[1]); - - const newSection = `\n# Command Code API Proxy (added by --setup-claude-code)\n${toAppend.join("\n")}\n`; - fs.appendFileSync(rcPath, newSection); - return true; -} - -// ── Main ── - export async function setupClaudeCodeConfig(force: boolean): Promise { const configPath = getConfigPath(); @@ -77,24 +28,26 @@ export async function setupClaudeCodeConfig(force: boolean): Promise { return; } + // 1. Write anthropic-models.json const dir = path.dirname(configPath); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(configPath, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n"); - console.log(`\n ✓ Config written to ${configPath}\n`); - const rcPath = detectShellRc(); - if (rcPath) { - const lines = rcExportLines(); - const appended = appendToRc(rcPath, lines); - if (appended) { - console.log(` ✓ Added Claude Code env vars to ${rcPath}\n`); - console.log(` Restart your shell, then run: claude\n`); - } else { - console.log(` ✓ ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY already set in ${rcPath}\n`); - } - } else { - console.log(" Could not detect shell. Add these to your shell profile:\n"); - console.log(` ${rcExportLines().join("\n ")}\n`); - } + // 2. Write dedicated claude proxy settings (won't touch ~/.claude/settings.json) + const settingsPath = getClaudeProxySettingsPath(); + const settings = { + env: { + ANTHROPIC_BASE_URL: "http://127.0.0.1:8787", + ANTHROPIC_API_KEY: "proxy-managed", + }, + }; + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n"); + console.log(` ✓ Settings written to ${settingsPath}\n`); + + // 3. Print usage instructions + console.log(" To use with Claude Code, run:\n"); + console.log(` claude --settings ${settingsPath}\n`); + console.log(" Or set it as an alias:\n"); + console.log(` alias claude-proxy="claude --settings ${settingsPath}"\n`); } diff --git a/tests/setup-claude-code.test.ts b/tests/setup-claude-code.test.ts index 4fbdfed..5b1fda2 100644 --- a/tests/setup-claude-code.test.ts +++ b/tests/setup-claude-code.test.ts @@ -4,26 +4,24 @@ import fs from "node:fs"; import path from "node:path"; import os from "node:os"; -function getConfigPath(): string { +function proxySettingsPath(): string { + return path.join(os.homedir(), ".config", "commandcode-api-proxy", "claude-settings.json"); +} + +function modelConfigPath(): string { return path.join(os.homedir(), ".config", "commandcode-api-proxy", "anthropic-models.json"); } describe("setupClaudeCodeConfig", () => { let snapshot: Record; let consoleLogSpy: ReturnType; - let writtenPath: string | null = null; - let writtenContent: string | null = null; - let appendedPath: string | null = null; - let appendedContent: string | null = null; - let rcContent: string = ""; + let writtenFiles: Map; function saveFs(): void { snapshot = { - existsSync: fs.existsSync as unknown as Record, + existsSync: fs.existsSync, mkdirSync: fs.mkdirSync, writeFileSync: fs.writeFileSync, - appendFileSync: fs.appendFileSync, - readFileSync: fs.readFileSync, }; } @@ -31,102 +29,77 @@ describe("setupClaudeCodeConfig", () => { fs.existsSync = snapshot.existsSync as typeof fs.existsSync; fs.mkdirSync = snapshot.mkdirSync as typeof fs.mkdirSync; fs.writeFileSync = snapshot.writeFileSync as typeof fs.writeFileSync; - fs.appendFileSync = snapshot.appendFileSync as typeof fs.appendFileSync; - fs.readFileSync = snapshot.readFileSync as typeof fs.readFileSync; } beforeEach(() => { - writtenPath = null; - writtenContent = null; - appendedPath = null; - appendedContent = null; - rcContent = ""; - process.env.SHELL = "/usr/bin/zsh"; + writtenFiles = new Map(); consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); saveFs(); fs.existsSync = ((p: fs.PathLike) => { - if (p === getConfigPath()) return false; + const s = p.toString(); + if (s === modelConfigPath()) return false; + if (s === proxySettingsPath()) return false; return true; }) as typeof fs.existsSync; - fs.mkdirSync = (() => undefined) as typeof fs.mkdirSync; + fs.writeFileSync = ((p: fs.PathLike, data: string) => { - if (p === getConfigPath()) { - writtenPath = p as string; - writtenContent = data; - } + writtenFiles.set(p.toString(), data); }) as typeof fs.writeFileSync; - fs.appendFileSync = ((p: fs.PathLike, data: string) => { - appendedPath = p as string; - appendedContent = data; - }) as typeof fs.appendFileSync; - fs.readFileSync = (() => "") as typeof fs.readFileSync; + + fs.mkdirSync = (() => undefined) as typeof fs.mkdirSync; }); afterEach(() => { restoreFs(); consoleLogSpy.mockRestore(); - delete process.env.SHELL; }); - test("creates config file with defaults", async () => { + test("creates model config file with defaults", async () => { await setupClaudeCodeConfig(false); - expect(writtenPath).toBe(getConfigPath()); - const parsed = JSON.parse(writtenContent!); - expect(parsed.default).toBeDefined(); + const raw = writtenFiles.get(modelConfigPath()); + expect(raw).toBeTruthy(); + const parsed = JSON.parse(raw!); + expect(parsed.default).toBe("deepseek/deepseek-v4-pro"); expect(parsed.mappings["claude-sonnet-*"]).toBeDefined(); }); - test("appends proxy env vars to zshrc", async () => { - process.env.SHELL = "/usr/bin/zsh"; + test("creates proxy settings file with env vars", async () => { await setupClaudeCodeConfig(false); - expect(appendedPath).toContain(".zshrc"); - expect(appendedContent).toContain("ANTHROPIC_BASE_URL"); - expect(appendedContent).toContain("ANTHROPIC_API_KEY"); - expect(appendedContent).toContain("proxy-managed"); + const raw = writtenFiles.get(proxySettingsPath()); + expect(raw).toBeTruthy(); + const parsed = JSON.parse(raw!); + expect(parsed.env.ANTHROPIC_BASE_URL).toBe("http://127.0.0.1:8787"); + expect(parsed.env.ANTHROPIC_API_KEY).toBe("proxy-managed"); }); - test("appends to bashrc for bash", async () => { - process.env.SHELL = "/usr/bin/bash"; - await setupClaudeCodeConfig(false); - expect(appendedPath).toContain(".bashrc"); - expect(appendedContent).toContain("export ANTHROPIC"); - }); + test("does not overwrite existing config without --force", async () => { + writtenFiles.set(modelConfigPath(), JSON.stringify({ default: "old" })); + fs.existsSync = (() => true) as typeof fs.existsSync; - test("uses fish syntax for fish shell", async () => { - process.env.SHELL = "/usr/bin/fish"; await setupClaudeCodeConfig(false); - expect(appendedContent).toContain("set -gx ANTHROPIC"); - }); - test("does not duplicate ANTHROPIC_BASE_URL", async () => { - fs.readFileSync = (() => - 'export ANTHROPIC_BASE_URL="http://localhost:8787"') as typeof fs.readFileSync; - await setupClaudeCodeConfig(false); - expect(appendedContent).toContain("ANTHROPIC_API_KEY"); - expect(appendedContent).not.toContain("ANTHROPIC_BASE_URL"); + const output = consoleLogSpy.mock.calls.map((c) => c.join(" ")).join("\n"); + expect(output).toContain("already exists"); + expect(writtenFiles.has(proxySettingsPath())).toBe(false); }); - test("does not append if both vars already in RC", async () => { - fs.readFileSync = (() => - 'export ANTHROPIC_BASE_URL="http://localhost:8787"\nexport ANTHROPIC_API_KEY="x"') as typeof fs.readFileSync; - await setupClaudeCodeConfig(false); - expect(appendedPath).toBeNull(); + test("existing config with --force overwrites", async () => { + writtenFiles.set(modelConfigPath(), JSON.stringify({ default: "old" })); + fs.existsSync = (() => true) as typeof fs.existsSync; + + await setupClaudeCodeConfig(true); + + const parsed = JSON.parse(writtenFiles.get(modelConfigPath())!); + expect(parsed.default).toBe("deepseek/deepseek-v4-pro"); + expect(writtenFiles.has(proxySettingsPath())).toBe(true); }); - test("existing file without force warns", async () => { - fs.existsSync = ((p: fs.PathLike) => true) as typeof fs.existsSync; + test("prints claude --settings instruction", async () => { await setupClaudeCodeConfig(false); - expect(writtenPath).toBeNull(); - expect(appendedPath).toBeNull(); const output = consoleLogSpy.mock.calls.map((c) => c.join(" ")).join("\n"); - expect(output).toContain("already exists"); - }); - - test("existing file with force overwrites", async () => { - fs.existsSync = ((p: fs.PathLike) => true) as typeof fs.existsSync; - await setupClaudeCodeConfig(true); - expect(writtenPath).toBe(getConfigPath()); - expect(appendedPath).toBeTruthy(); + expect(output).toContain("claude --settings"); + expect(output).toContain(proxySettingsPath()); + expect(output).toContain("alias"); }); }); From d9742c6c516592cf36aabcb5c473b1db2ad3ff05 Mon Sep 17 00:00:00 2001 From: thaolaptrinh Date: Wed, 1 Jul 2026 12:09:36 +0700 Subject: [PATCH 11/16] fix(setup): add model and skipDangerousModePermissionPrompt to settings --- src/setup/claude-code.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/setup/claude-code.ts b/src/setup/claude-code.ts index fd59e2d..73d7383 100644 --- a/src/setup/claude-code.ts +++ b/src/setup/claude-code.ts @@ -37,6 +37,8 @@ export async function setupClaudeCodeConfig(force: boolean): Promise { // 2. Write dedicated claude proxy settings (won't touch ~/.claude/settings.json) const settingsPath = getClaudeProxySettingsPath(); const settings = { + model: "sonnet", + skipDangerousModePermissionPrompt: true, env: { ANTHROPIC_BASE_URL: "http://127.0.0.1:8787", ANTHROPIC_API_KEY: "proxy-managed", From 9ac4ad49c38559cfba635f3168af090efc106b82 Mon Sep 17 00:00:00 2001 From: thaolaptrinh Date: Wed, 1 Jul 2026 12:19:19 +0700 Subject: [PATCH 12/16] refactor: drop glob mapping, proxy pass-through model ID - Remove anthropic-models.json glob config (no longer needed) - --setup-claude-code only writes claude-settings.json with model env vars (ANTHROPIC_DEFAULT_SONNET/OPUS/HAIKU_MODEL) - resolveAnthropicModel uses ANTHROPIC_DEFAULT_MODEL env or fallback - Proxy pass-through model ID, Claude Code maps via its settings --- src/config.ts | 19 ----- src/proxy.ts | 12 +-- src/setup/claude-code.ts | 36 +++------ src/translate/anthropic-models.ts | 32 -------- tests/anthropic-models.test.ts | 123 ++---------------------------- tests/setup-claude-code.test.ts | 66 ++++++---------- 6 files changed, 42 insertions(+), 246 deletions(-) diff --git a/src/config.ts b/src/config.ts index 96d767c..8d15bc4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,9 +1,4 @@ -import fs from "node:fs"; -import path from "node:path"; -import os from "node:os"; import { readAuthKey } from "@/auth.js"; -import type { AnthropicModelConfig } from "@/translate/anthropic-models.js"; -import { logger } from "@/logger.js"; interface CliArgs { host?: string; @@ -89,17 +84,3 @@ export function loadConfig(): Config { return { host, port, apiKey, ccApiBase, ccVersion, logLevel }; } - -export function loadAnthropicModelConfig(): AnthropicModelConfig | null { - const home = os.homedir(); - const filePath = path.join(home, ".config", "commandcode-api-proxy", "anthropic-models.json"); - try { - const raw = fs.readFileSync(filePath, "utf-8"); - return JSON.parse(raw) as AnthropicModelConfig; - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") { - logger.warn(`Failed to load anthropic-models.json: ${(err as Error).message}`); - } - return null; - } -} diff --git a/src/proxy.ts b/src/proxy.ts index d2b5dd1..37af665 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,11 +1,10 @@ #!/usr/bin/env node -import { loadConfig, fetchLatestCliVersion, loadAnthropicModelConfig } from "@/config.js"; +import { loadConfig, fetchLatestCliVersion } from "@/config.js"; import { createServer } from "@/server.js"; import { saveApiKey, promptForApiKey, readAuthKey, deleteAuth } from "@/auth.js"; import { setupOpenCodeConfig } from "@/setup/opencode.js"; import { setupClaudeCodeConfig } from "@/setup/claude-code.js"; -import { initAnthropicModelConfig } from "@/translate/anthropic-models.js"; import { logger, initLogger } from "@/logger.js"; const args = process.argv.slice(2); @@ -48,8 +47,6 @@ if (args.includes("--setup-claude-code")) { } const config = loadConfig(); -const anthropicModelConfig = loadAnthropicModelConfig(); -initAnthropicModelConfig(anthropicModelConfig); initLogger(config.logLevel); logger.info( @@ -89,13 +86,6 @@ server.listen(config.port, config.host, () => { console.log(" POST /v1/messages (Anthropic format)"); console.log(" POST /v1/messages/count_tokens (Anthropic format)"); console.log(""); - if (anthropicModelConfig) { - const count = Object.keys(anthropicModelConfig.mappings ?? {}).length; - console.log(` Anthropic models: config loaded (${count} mappings)`); - } else { - console.log(" Anthropic models: defaults (run --setup-claude-code to customize)"); - } - console.log(""); console.log(" Press Ctrl+C to stop\n"); }); diff --git a/src/setup/claude-code.ts b/src/setup/claude-code.ts index 73d7383..c649566 100644 --- a/src/setup/claude-code.ts +++ b/src/setup/claude-code.ts @@ -2,52 +2,36 @@ import fs from "node:fs"; import path from "node:path"; import os from "node:os"; -function getConfigPath(): string { - return path.join(os.homedir(), ".config", "commandcode-api-proxy", "anthropic-models.json"); -} - function getClaudeProxySettingsPath(): string { return path.join(os.homedir(), ".config", "commandcode-api-proxy", "claude-settings.json"); } -const DEFAULT_CONFIG = { - default: "deepseek/deepseek-v4-pro", - mappings: { - "claude-sonnet-*": "deepseek/deepseek-v4-pro", - "claude-opus-*": "deepseek/deepseek-v4-pro", - "claude-haiku-*": "deepseek/deepseek-v4-flash", - }, -}; - export async function setupClaudeCodeConfig(force: boolean): Promise { - const configPath = getConfigPath(); + const settingsPath = getClaudeProxySettingsPath(); - if (fs.existsSync(configPath) && !force) { - console.log(`\n ⚠ Config already exists at ${configPath}\n`); - console.log(" Edit it manually, or run: commandcode-api-proxy --setup-claude-code --force\n"); + if (fs.existsSync(settingsPath) && !force) { + console.log(`\n ⚠ Settings already exists at ${settingsPath}\n`); + console.log(" Run: commandcode-api-proxy --setup-claude-code --force\n"); return; } - // 1. Write anthropic-models.json - const dir = path.dirname(configPath); + const dir = path.dirname(settingsPath); fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(configPath, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n"); - console.log(`\n ✓ Config written to ${configPath}\n`); - // 2. Write dedicated claude proxy settings (won't touch ~/.claude/settings.json) - const settingsPath = getClaudeProxySettingsPath(); const settings = { model: "sonnet", skipDangerousModePermissionPrompt: true, env: { ANTHROPIC_BASE_URL: "http://127.0.0.1:8787", ANTHROPIC_API_KEY: "proxy-managed", + ANTHROPIC_DEFAULT_SONNET_MODEL: "deepseek/deepseek-v4-pro", + ANTHROPIC_DEFAULT_OPUS_MODEL: "deepseek/deepseek-v4-pro", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "deepseek/deepseek-v4-flash", }, }; - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n"); - console.log(` ✓ Settings written to ${settingsPath}\n`); - // 3. Print usage instructions + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n"); + console.log(`\n ✓ Settings written to ${settingsPath}\n`); console.log(" To use with Claude Code, run:\n"); console.log(` claude --settings ${settingsPath}\n`); console.log(" Or set it as an alias:\n"); diff --git a/src/translate/anthropic-models.ts b/src/translate/anthropic-models.ts index d56f9c5..8489b56 100644 --- a/src/translate/anthropic-models.ts +++ b/src/translate/anthropic-models.ts @@ -1,44 +1,12 @@ import { resolveModel, getDefaultModels } from "@/translate/models.js"; -import { logger } from "@/logger.js"; - -export interface AnthropicModelConfig { - default?: string; - mappings?: Record; -} - -let cachedConfig: AnthropicModelConfig | null = null; - -export function initAnthropicModelConfig(config: AnthropicModelConfig | null): void { - cachedConfig = config; -} - -function globToRegex(pattern: string): RegExp { - const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return new RegExp("^" + escaped.replace(/\\\*/g, ".*") + "$"); -} export function resolveAnthropicModel(requestedModel: string): string { if (!requestedModel.startsWith("claude-")) { return resolveModel(requestedModel); } - if (cachedConfig?.mappings) { - for (const [pattern, ccModel] of Object.entries(cachedConfig.mappings)) { - if (!ccModel) continue; - try { - if (globToRegex(pattern).test(requestedModel)) { - return resolveModel(ccModel); - } - } catch { - logger.debug(`Invalid glob pattern in config: "${pattern}"`); - } - } - } - const envDefault = process.env.ANTHROPIC_DEFAULT_MODEL; if (envDefault) return resolveModel(envDefault); - if (cachedConfig?.default) return resolveModel(cachedConfig.default); - return resolveModel(getDefaultModels()[0]); } diff --git a/tests/anthropic-models.test.ts b/tests/anthropic-models.test.ts index cdd85c2..94bc3e9 100644 --- a/tests/anthropic-models.test.ts +++ b/tests/anthropic-models.test.ts @@ -1,12 +1,5 @@ -import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; -import { resolveAnthropicModel, initAnthropicModelConfig } from "@/translate/anthropic-models.js"; -import fs from "node:fs"; -import path from "node:path"; -import os from "node:os"; - -function getConfigPath(): string { - return path.join(os.homedir(), ".config", "commandcode-api-proxy", "anthropic-models.json"); -} +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { resolveAnthropicModel } from "@/translate/anthropic-models.js"; describe("resolveAnthropicModel", () => { let originalEnv: NodeJS.ProcessEnv; @@ -14,124 +7,24 @@ describe("resolveAnthropicModel", () => { beforeEach(() => { originalEnv = { ...process.env }; delete process.env.ANTHROPIC_DEFAULT_MODEL; - initAnthropicModelConfig(null); }); afterEach(() => { process.env = originalEnv; - initAnthropicModelConfig(null); - }); - - test("glob wildcard matches versioned claude ID", () => { - initAnthropicModelConfig({ - default: "fallback-model", - mappings: { "claude-sonnet-*": "mapped-model-a" }, - }); - expect(resolveAnthropicModel("claude-sonnet-4-5-20250929")).toBe("mapped-model-a"); - }); - - test("claude-* catch-all matches any claude ID", () => { - initAnthropicModelConfig({ - mappings: { "claude-*": "mapped-model-b" }, - }); - expect(resolveAnthropicModel("claude-anything-here")).toBe("mapped-model-b"); - }); - - test("FIFO ordering: specific pattern before general returns specific", () => { - initAnthropicModelConfig({ - mappings: { - "claude-*": "general-model", - "claude-sonnet-*": "specific-model", - }, - }); - // claude-* comes first → wins despite claude-sonnet-* also matching - expect(resolveAnthropicModel("claude-sonnet-4-5")).toBe("general-model"); - }); - - test("FIFO ordering: specific first returns specific", () => { - initAnthropicModelConfig({ - mappings: { - "claude-sonnet-*": "specific-model", - "claude-*": "general-model", - }, - }); - expect(resolveAnthropicModel("claude-sonnet-4-5")).toBe("specific-model"); - }); - - test("exact match (no wildcard)", () => { - initAnthropicModelConfig({ - mappings: { "claude-sonnet-4-5-20250929": "exact-model" }, - }); - expect(resolveAnthropicModel("claude-sonnet-4-5-20250929")).toBe("exact-model"); - expect(resolveAnthropicModel("claude-sonnet-4-5")).not.toBe("exact-model"); - }); - - test("special chars in pattern are literal", () => { - initAnthropicModelConfig({ - mappings: { "claude-sonnet-4.5-*": "dot-model" }, - }); - expect(resolveAnthropicModel("claude-sonnet-4.5-20250929")).toBe("dot-model"); - // dot should NOT match arbitrary char - expect(resolveAnthropicModel("claude-sonnet-4X5-20250929")).not.toBe("dot-model"); - }); - - test("no match → config default", () => { - initAnthropicModelConfig({ - default: "default-model", - mappings: { "claude-opus-*": "opus-model" }, - }); - expect(resolveAnthropicModel("claude-sonnet-4-5")).toBe("default-model"); }); - test("no match, no config default → ANTHROPIC_DEFAULT_MODEL env", () => { - process.env.ANTHROPIC_DEFAULT_MODEL = "env-default-model"; - initAnthropicModelConfig({ mappings: {} }); - expect(resolveAnthropicModel("claude-sonnet-4-5")).toBe("env-default-model"); + test("claude-* uses ANTHROPIC_DEFAULT_MODEL env", () => { + process.env.ANTHROPIC_DEFAULT_MODEL = "deepseek/deepseek-v4-pro"; + expect(resolveAnthropicModel("claude-sonnet-4-5")).toBe("deepseek/deepseek-v4-pro"); }); - test("env default overrides config default", () => { - process.env.ANTHROPIC_DEFAULT_MODEL = "env-wins-model"; - initAnthropicModelConfig({ default: "config-default-model" }); - expect(resolveAnthropicModel("claude-unknown")).toBe("env-wins-model"); - }); - - test("no config, no env → hardcoded fallback", () => { - initAnthropicModelConfig(null); - const result = resolveAnthropicModel("claude-sonnet-4-5"); + test("claude-* without env falls back gracefully", () => { + const result = resolveAnthropicModel("claude-opus-4-1"); expect(typeof result).toBe("string"); expect(result.length).toBeGreaterThan(0); }); test("non-claude model passes through", () => { - initAnthropicModelConfig({ mappings: { "claude-*": "mapped" } }); - expect(resolveAnthropicModel("deepseek/deepseek-v4-pro")).toBe("deepseek/deepseek-v4-pro"); - }); - - test("empty mapping value is skipped", () => { - initAnthropicModelConfig({ - default: "fallback-model", - mappings: { "claude-sonnet-*": "", "claude-*": "real-model" }, - }); - expect(resolveAnthropicModel("claude-sonnet-4-5")).toBe("real-model"); - }); - - test("config with only default, no mappings", () => { - initAnthropicModelConfig({ default: "the-default-model" }); - expect(resolveAnthropicModel("claude-anything")).toBe("the-default-model"); - }); - - test("config with only mappings, no default", () => { - initAnthropicModelConfig({ mappings: { "claude-opus-*": "opus" } }); - // unmatched → hardcoded fallback (not undefined) - const result = resolveAnthropicModel("claude-sonnet-4-5"); - expect(typeof result).toBe("string"); - expect(result.length).toBeGreaterThan(0); - }); - - test("null config (missing file) uses hardcoded fallback", () => { - initAnthropicModelConfig(null); - const result = resolveAnthropicModel("claude-sonnet-4-5"); - expect(typeof result).toBe("string"); - expect(result.length).toBeGreaterThan(0); + expect(resolveAnthropicModel("custom-model")).toBe("custom-model"); }); }); diff --git a/tests/setup-claude-code.test.ts b/tests/setup-claude-code.test.ts index 5b1fda2..3711a68 100644 --- a/tests/setup-claude-code.test.ts +++ b/tests/setup-claude-code.test.ts @@ -4,21 +4,17 @@ import fs from "node:fs"; import path from "node:path"; import os from "node:os"; -function proxySettingsPath(): string { +function settingsPath(): string { return path.join(os.homedir(), ".config", "commandcode-api-proxy", "claude-settings.json"); } -function modelConfigPath(): string { - return path.join(os.homedir(), ".config", "commandcode-api-proxy", "anthropic-models.json"); -} - describe("setupClaudeCodeConfig", () => { - let snapshot: Record; + let originalFs: Record; let consoleLogSpy: ReturnType; - let writtenFiles: Map; + let written: string | null = null; function saveFs(): void { - snapshot = { + originalFs = { existsSync: fs.existsSync, mkdirSync: fs.mkdirSync, writeFileSync: fs.writeFileSync, @@ -26,28 +22,26 @@ describe("setupClaudeCodeConfig", () => { } function restoreFs(): void { - fs.existsSync = snapshot.existsSync as typeof fs.existsSync; - fs.mkdirSync = snapshot.mkdirSync as typeof fs.mkdirSync; - fs.writeFileSync = snapshot.writeFileSync as typeof fs.writeFileSync; + fs.existsSync = originalFs.existsSync as typeof fs.existsSync; + fs.mkdirSync = originalFs.mkdirSync as typeof fs.mkdirSync; + fs.writeFileSync = originalFs.writeFileSync as typeof fs.writeFileSync; } beforeEach(() => { - writtenFiles = new Map(); + written = null; consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); saveFs(); fs.existsSync = ((p: fs.PathLike) => { - const s = p.toString(); - if (s === modelConfigPath()) return false; - if (s === proxySettingsPath()) return false; + if (p.toString() === settingsPath()) return false; return true; }) as typeof fs.existsSync; + fs.mkdirSync = (() => undefined) as typeof fs.mkdirSync; + fs.writeFileSync = ((p: fs.PathLike, data: string) => { - writtenFiles.set(p.toString(), data); + if (p.toString() === settingsPath()) written = data; }) as typeof fs.writeFileSync; - - fs.mkdirSync = (() => undefined) as typeof fs.mkdirSync; }); afterEach(() => { @@ -55,51 +49,37 @@ describe("setupClaudeCodeConfig", () => { consoleLogSpy.mockRestore(); }); - test("creates model config file with defaults", async () => { + test("creates settings file with env vars", async () => { await setupClaudeCodeConfig(false); - const raw = writtenFiles.get(modelConfigPath()); - expect(raw).toBeTruthy(); - const parsed = JSON.parse(raw!); - expect(parsed.default).toBe("deepseek/deepseek-v4-pro"); - expect(parsed.mappings["claude-sonnet-*"]).toBeDefined(); - }); - - test("creates proxy settings file with env vars", async () => { - await setupClaudeCodeConfig(false); - const raw = writtenFiles.get(proxySettingsPath()); - expect(raw).toBeTruthy(); - const parsed = JSON.parse(raw!); + expect(written).toBeTruthy(); + const parsed = JSON.parse(written!); expect(parsed.env.ANTHROPIC_BASE_URL).toBe("http://127.0.0.1:8787"); expect(parsed.env.ANTHROPIC_API_KEY).toBe("proxy-managed"); + expect(parsed.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("deepseek/deepseek-v4-pro"); + expect(parsed.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("deepseek/deepseek-v4-pro"); + expect(parsed.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("deepseek/deepseek-v4-flash"); }); - test("does not overwrite existing config without --force", async () => { - writtenFiles.set(modelConfigPath(), JSON.stringify({ default: "old" })); + test("does not overwrite existing without --force", async () => { fs.existsSync = (() => true) as typeof fs.existsSync; await setupClaudeCodeConfig(false); - + expect(written).toBeNull(); const output = consoleLogSpy.mock.calls.map((c) => c.join(" ")).join("\n"); expect(output).toContain("already exists"); - expect(writtenFiles.has(proxySettingsPath())).toBe(false); }); - test("existing config with --force overwrites", async () => { - writtenFiles.set(modelConfigPath(), JSON.stringify({ default: "old" })); + test("existing with --force overwrites", async () => { fs.existsSync = (() => true) as typeof fs.existsSync; await setupClaudeCodeConfig(true); - - const parsed = JSON.parse(writtenFiles.get(modelConfigPath())!); - expect(parsed.default).toBe("deepseek/deepseek-v4-pro"); - expect(writtenFiles.has(proxySettingsPath())).toBe(true); + expect(written).toBeTruthy(); }); test("prints claude --settings instruction", async () => { await setupClaudeCodeConfig(false); const output = consoleLogSpy.mock.calls.map((c) => c.join(" ")).join("\n"); expect(output).toContain("claude --settings"); - expect(output).toContain(proxySettingsPath()); - expect(output).toContain("alias"); + expect(output).toContain(settingsPath()); }); }); From fda2a45e3da45744549b05ba578b84e20a4eb932 Mon Sep 17 00:00:00 2001 From: thaolaptrinh Date: Wed, 1 Jul 2026 12:25:17 +0700 Subject: [PATCH 13/16] fix: add system role support and full Claude Code env vars --- src/setup/claude-code.ts | 3 +++ src/translate/anthropic-types.ts | 2 +- src/translate/anthropic.ts | 13 ++++++++++++- src/translate/validation.ts | 2 +- tests/translate.test.ts | 2 +- 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/setup/claude-code.ts b/src/setup/claude-code.ts index c649566..1719b01 100644 --- a/src/setup/claude-code.ts +++ b/src/setup/claude-code.ts @@ -24,6 +24,9 @@ export async function setupClaudeCodeConfig(force: boolean): Promise { env: { ANTHROPIC_BASE_URL: "http://127.0.0.1:8787", ANTHROPIC_API_KEY: "proxy-managed", + ANTHROPIC_AUTH_TOKEN: "", + API_TIMEOUT_MS: "3000000", + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", ANTHROPIC_DEFAULT_SONNET_MODEL: "deepseek/deepseek-v4-pro", ANTHROPIC_DEFAULT_OPUS_MODEL: "deepseek/deepseek-v4-pro", ANTHROPIC_DEFAULT_HAIKU_MODEL: "deepseek/deepseek-v4-flash", diff --git a/src/translate/anthropic-types.ts b/src/translate/anthropic-types.ts index 1dc884a..f0b0aa7 100644 --- a/src/translate/anthropic-types.ts +++ b/src/translate/anthropic-types.ts @@ -21,7 +21,7 @@ export interface AnthropicRequest { } export interface AnthropicMessage { - role: "user" | "assistant"; + role: "user" | "assistant" | "system"; content: string | AnthropicContentBlock[]; } diff --git a/src/translate/anthropic.ts b/src/translate/anthropic.ts index 64471d4..719060f 100644 --- a/src/translate/anthropic.ts +++ b/src/translate/anthropic.ts @@ -56,10 +56,18 @@ function toCCMessages(messages: AnthropicRequest["messages"]): { } const ccMessages: CCMessage[] = []; + const systemParts: string[] = []; for (const msg of messages) { const content = msg.content; + if (msg.role === "system") { + if (typeof content === "string") { + systemParts.push(content); + } + continue; + } + if (msg.role === "user") { if (typeof content === "string") { ccMessages.push({ role: "user", content }); @@ -96,7 +104,10 @@ function toCCMessages(messages: AnthropicRequest["messages"]): { return { ccMessages: pruneDanglingTools(ccMessages), - systemPrompt: undefined, + systemPrompt: + systemParts.length > 0 + ? systemParts.join("\n\n") + : undefined, }; } diff --git a/src/translate/validation.ts b/src/translate/validation.ts index 0a50a39..73754b6 100644 --- a/src/translate/validation.ts +++ b/src/translate/validation.ts @@ -83,7 +83,7 @@ export function validateAnthropicRequest(body: unknown): AnthropicRequest { for (let i = 0; i < req.messages.length; i++) { const msg = req.messages[i] as Record; - if (msg.role !== "user" && msg.role !== "assistant") { + if (msg.role !== "user" && msg.role !== "assistant" && msg.role !== "system") { throw new ValidationError(`messages[${i}].role must be "user" or "assistant"`); } if (msg.content === undefined) { diff --git a/tests/translate.test.ts b/tests/translate.test.ts index 418c95c..6120440 100644 --- a/tests/translate.test.ts +++ b/tests/translate.test.ts @@ -363,7 +363,7 @@ describe("validateAnthropicRequest", () => { validateAnthropicRequest({ model: "x", max_tokens: 10, - messages: [{ role: "system", content: "Hi" }], + messages: [{ role: "function", content: "Hi" }], }), ).toThrow(ValidationError); }); From 69b58eb17f64bf1250ab56ea7f86815ac9d39c6b Mon Sep 17 00:00:00 2001 From: thaolaptrinh Date: Wed, 1 Jul 2026 12:26:17 +0700 Subject: [PATCH 14/16] fix(setup): ANTHROPIC_AUTH_TOKEN = proxy-managed, DISABLE_NONESSENTIAL as number --- src/setup/claude-code.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/setup/claude-code.ts b/src/setup/claude-code.ts index 1719b01..503f89e 100644 --- a/src/setup/claude-code.ts +++ b/src/setup/claude-code.ts @@ -24,9 +24,9 @@ export async function setupClaudeCodeConfig(force: boolean): Promise { env: { ANTHROPIC_BASE_URL: "http://127.0.0.1:8787", ANTHROPIC_API_KEY: "proxy-managed", - ANTHROPIC_AUTH_TOKEN: "", + ANTHROPIC_AUTH_TOKEN: "proxy-managed", API_TIMEOUT_MS: "3000000", - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1, ANTHROPIC_DEFAULT_SONNET_MODEL: "deepseek/deepseek-v4-pro", ANTHROPIC_DEFAULT_OPUS_MODEL: "deepseek/deepseek-v4-pro", ANTHROPIC_DEFAULT_HAIKU_MODEL: "deepseek/deepseek-v4-flash", From 32c04fcd0b5a5408ea90d24cd7e524d7936532a3 Mon Sep 17 00:00:00 2001 From: thaolaptrinh Date: Wed, 1 Jul 2026 13:08:27 +0700 Subject: [PATCH 15/16] fix: clean up unused imports, 404 error shape, remove fragile JSON merge --- src/server.ts | 4 ++ src/translate/anthropic.ts | 89 ++++++++++++++++++++++---------------- 2 files changed, 55 insertions(+), 38 deletions(-) diff --git a/src/server.ts b/src/server.ts index 9e7ca0b..921343c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -487,6 +487,10 @@ export function createServer(cfg: Config): http.Server { const route = routes.find((r) => r.method === req.method && r.path === pathname); if (!route) { + const isAnthropic = req.headers["anthropic-version"] !== undefined; + if (isAnthropic) { + return sendAnthropicError(res, 404, "not_found_error", "Not found"); + } return sendJson(res, 404, { error: "Not found" }); } diff --git a/src/translate/anthropic.ts b/src/translate/anthropic.ts index 719060f..5bb4d3b 100644 --- a/src/translate/anthropic.ts +++ b/src/translate/anthropic.ts @@ -18,7 +18,6 @@ import { extractUsage, pruneDanglingTools, buildCCConfig, - applyNoToolsSafeguard, } from "@/translate/util.js"; import { logger } from "@/logger.js"; @@ -72,8 +71,44 @@ function toCCMessages(messages: AnthropicRequest["messages"]): { if (typeof content === "string") { ccMessages.push({ role: "user", content }); } else { - const parts = (content as AnthropicContentBlock[]).map(toCCPartFn(toolUseIdToName)); - ccMessages.push({ role: "user", content: parts }); + const blocks = content as AnthropicContentBlock[]; + const hasToolResult = blocks.some((b) => b.type === "tool_result"); + if (hasToolResult) { + const textParts: CCContentPart[] = []; + for (const block of blocks) { + if (block.type === "tool_result") { + const trb = block as ToolResultBlockParam; + const name = toolUseIdToName.get(trb.tool_use_id) ?? ""; + const resultText = + typeof trb.content === "string" + ? trb.content + : (trb.content as Array<{ type: string; text?: string }>) + .map((p) => p.text ?? "") + .join(""); + ccMessages.push({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: trb.tool_use_id, + toolName: name, + output: { type: "text", value: resultText }, + isError: trb.is_error, + }, + ], + }); + } else { + const part = toCCPartByBlock(block); + if (part) textParts.push(part); + } + } + if (textParts.length > 0) { + ccMessages.push({ role: "user", content: textParts }); + } + } else { + const parts = blocks.map((b) => toCCPartByBlock(b)).filter(Boolean) as CCContentPart[]; + ccMessages.push({ role: "user", content: parts }); + } } continue; } @@ -94,7 +129,6 @@ function toCCMessages(messages: AnthropicRequest["messages"]): { input: block.input, }); } - // thinking, redacted_thinking: dropped from history } if (parts.length > 0) { ccMessages.push({ role: "assistant", content: parts }); @@ -111,37 +145,16 @@ function toCCMessages(messages: AnthropicRequest["messages"]): { }; } -function toCCPartFn( - toolUseIdToName: Map, -): (block: AnthropicContentBlock) => CCContentPart { - return (block: AnthropicContentBlock): CCContentPart => { - if (block.type === "text") return { type: "text", text: block.text }; - if (block.type === "image") { - const src = (block as ImageBlockParam).source; - if (src.type === "base64") { - return { type: "image", image: `data:${src.media_type};base64,${src.data}` }; - } - return { type: "image", image: src.url }; - } - if (block.type === "tool_result") { - const trb = block as ToolResultBlockParam; - const name = toolUseIdToName.get(trb.tool_use_id) ?? ""; - const resultText = - typeof trb.content === "string" - ? trb.content - : (trb.content as Array<{ type: string; text?: string }>) - .map((p) => p.text ?? "") - .join(""); - return { - type: "tool-result", - toolCallId: trb.tool_use_id, - toolName: name, - output: { type: "text", value: resultText }, - isError: trb.is_error, - }; +function toCCPartByBlock(block: AnthropicContentBlock): CCContentPart | null { + if (block.type === "text") return { type: "text", text: block.text }; + if (block.type === "image") { + const src = (block as ImageBlockParam).source; + if (src.type === "base64") { + return { type: "image", image: `data:${src.media_type};base64,${src.data}` }; } - return { type: "text", text: "" }; - }; + return { type: "image", image: src.url }; + } + return null; } function resolveReasoningEffort(thinking: AnthropicRequest["thinking"]): string | undefined { @@ -212,13 +225,11 @@ export function toCCRequest( threadId: crypto.randomUUID(), }; + // Add system prompt from top-level field if (finalSystem) { body.params.system = finalSystem; } - const hasTools = req.tools != null && req.tools.length > 0; - applyNoToolsSafeguard(body, ccMessages, hasTools); - return body; } @@ -228,6 +239,7 @@ export class AnthropicStreamEncoder { readonly messageId: string; private blockIndex = 0; private currentBlockType: "text" | "thinking" | "tool_use" | null = null; + private currentToolCallId: string | null = null; private pendingStart: CCEvent | null = null; private started = false; private pinged = false; @@ -339,7 +351,7 @@ export class AnthropicStreamEncoder { case "tool-call-delta": { const tcId = (event.data.toolCallId as string) ?? ""; const tcName = (event.data.name as string) ?? ""; - if (this.currentBlockType !== "tool_use") { + if (this.currentBlockType !== "tool_use" || this.currentToolCallId !== tcId) { this.closeCurrentBlock(records); this.ensureBlockOpenWith(records, "tool_use", { type: "tool_use", @@ -347,6 +359,7 @@ export class AnthropicStreamEncoder { name: tcName, input: {}, }); + this.currentToolCallId = tcId; } records.push( this.makeDelta({ From 654682b16a414606c61d7668c818c40e3e93c60a Mon Sep 17 00:00:00 2001 From: thaolaptrinh Date: Wed, 1 Jul 2026 13:11:37 +0700 Subject: [PATCH 16/16] chore: cleanup docs folder and finalize Anthropic feature --- src/setup/claude-code.ts | 2 +- tests/translate-anthropic.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/setup/claude-code.ts b/src/setup/claude-code.ts index 503f89e..3314b47 100644 --- a/src/setup/claude-code.ts +++ b/src/setup/claude-code.ts @@ -24,7 +24,7 @@ export async function setupClaudeCodeConfig(force: boolean): Promise { env: { ANTHROPIC_BASE_URL: "http://127.0.0.1:8787", ANTHROPIC_API_KEY: "proxy-managed", - ANTHROPIC_AUTH_TOKEN: "proxy-managed", + ANTHROPIC_AUTH_TOKEN: "", API_TIMEOUT_MS: "3000000", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1, ANTHROPIC_DEFAULT_SONNET_MODEL: "deepseek/deepseek-v4-pro", diff --git a/tests/translate-anthropic.test.ts b/tests/translate-anthropic.test.ts index 66290c8..c889b4f 100644 --- a/tests/translate-anthropic.test.ts +++ b/tests/translate-anthropic.test.ts @@ -214,14 +214,14 @@ describe("toCCRequest", () => { expect(toCCRequest({ ...base, tool_choice: { type: "none" } }).params.tool_choice).toBe("none"); }); - it("no-tools safeguard injected", () => { + it("no-tools safeguard not injected for Anthropic", () => { const req: AnthropicRequest = { model: "claude-sonnet-4-5-20250929", max_tokens: 100, messages: [{ role: "user", content: "Hello" }], }; const result = toCCRequest(req); - expect(result.params.system).toContain("chat-only environment"); + expect(result.params.system).toBeUndefined(); }); it("stream is passed through", () => {