diff --git a/.env.example b/.env.example index 29c7006..fff5d79 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Command Code API key (if not set, reads from ~/.commandcode/auth.json) +# Command Code API key (if not set, reads from ~/.config/commandcode-api-proxy/auth.json) CC_API_KEY= # Proxy config diff --git a/README.md b/README.md index 0b55551..177186c 100644 --- a/README.md +++ b/README.md @@ -73,14 +73,21 @@ commandcode-api-proxy auth logout Equivalent env vars (lower priority than CLI flags): -| Env var | Description | -| ---------------- | -------------------------------------------- | -| `HOST` | Bind address | -| `PORT` | Port | -| `CC_API_KEY` | Command Code API key | -| `CC_API_BASE` | Upstream API base URL | -| `CC_CLI_VERSION` | CLI version sent upstream | -| `LOG_LEVEL` | Log level (`info`, `debug`, etc.) | +| Env var | Description | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `HOST` | Bind address | +| `PORT` | Port | +| `CC_API_KEY` | Command Code API key | +| `CC_API_BASE` | Upstream API base URL | +| `CC_CLI_VERSION` | CLI version sent upstream | +| `LOG_LEVEL` | Log level (`info`, `debug`, etc.) | +| `CORS_ORIGIN` | `Access-Control-Allow-Origin` value. `*` by default; empty string disables CORS. Restrict before exposing on a network. | + +> **Security:** the proxy forwards your paid Command Code key upstream and +> accepts any auth token from clients (`proxy-managed`), so it is designed for +> **localhost** use (`HOST=127.0.0.1`). Do not bind it to `0.0.0.0` on an +> untrusted network without restricting `CORS_ORIGIN` and putting your own auth +> in front. ## Endpoints @@ -140,11 +147,11 @@ npx commandcode-api-proxy --setup-claude-code If a settings file already exists, re-run with `--force` to overwrite it. -| Claude Code tier | Env var | Maps to | -| ---------------- | -------------------------------- | --------------------------- | -| `sonnet` | `ANTHROPIC_DEFAULT_SONNET_MODEL` | `deepseek/deepseek-v4-pro` | -| `opus` | `ANTHROPIC_DEFAULT_OPUS_MODEL` | `deepseek/deepseek-v4-pro` | -| `haiku` | `ANTHROPIC_DEFAULT_HAIKU_MODEL` | `deepseek/deepseek-v4-flash`| +| Claude Code tier | Env var | Maps to | +| ---------------- | -------------------------------- | ---------------------------- | +| `sonnet` | `ANTHROPIC_DEFAULT_SONNET_MODEL` | `deepseek/deepseek-v4-pro` | +| `opus` | `ANTHROPIC_DEFAULT_OPUS_MODEL` | `deepseek/deepseek-v4-pro` | +| `haiku` | `ANTHROPIC_DEFAULT_HAIKU_MODEL` | `deepseek/deepseek-v4-flash` | Edit those env vars in the settings file to point at other CC models. To force every `claude-*` request to a single model regardless of tier, set @@ -165,25 +172,46 @@ claude-proxy Short names work in addition to full model IDs: -| Alias | Maps to | -| -------------------------------------------------- | ------------------------------ | -| `deepseek-v4-pro`, `deepseek-v4`, `deepseek-pro` | `deepseek/deepseek-v4-pro` | -| `deepseek-v4-flash`, `deepseek-flash` | `deepseek/deepseek-v4-flash` | -| `kimi-k2.6`, `kimi2.6` | `moonshotai/Kimi-K2.6` | -| `kimi-k2.5`, `kimi2.5` | `moonshotai/Kimi-K2.5` | -| `glm-5.1` | `zai-org/GLM-5.1` | -| `glm-5` | `zai-org/GLM-5` | -| `minimax-m2.7`, `minimax2.7` | `MiniMaxAI/MiniMax-M2.7` | -| `minimax-m2.5`, `minimax2.5` | `MiniMaxAI/MiniMax-M2.5` | -| `qwen3.6-max`, `qwen-3.6-max` | `Qwen/Qwen3.6-Max-Preview` | -| `qwen3.6-plus`, `qwen-3.6-plus` | `Qwen/Qwen3.6-Plus` | -| `qwen3.7-max`, `qwen-3.7-max` | `Qwen/Qwen3.7-Max` | -| `qwen3.7-plus`, `qwen-3.7-plus` | `Qwen/Qwen3.7-Plus` | -| `step3.5`, `step-3.5-flash` | `stepfun/Step-3.5-Flash` | -| `mimo-v2.5`, `mimo2.5` | `xiaomi/mimo-v2.5` | +| Alias | Maps to | +| ------------------------------------------------ | ------------------------------------- | +| `deepseek-v4-pro`, `deepseek-v4`, `deepseek-pro` | `deepseek/deepseek-v4-pro` | +| `deepseek-v4-flash`, `deepseek-flash` | `deepseek/deepseek-v4-flash` | +| `glm-5.2`, `glm5.2` | `zai-org/GLM-5.2` | +| `glm-5.2-fast`, `glm5.2-fast` | `zai-org/GLM-5.2-Fast` | +| `glm-5.1` | `zai-org/GLM-5.1` | +| `glm-5` | `zai-org/GLM-5` | +| `minimax-m3`, `minimax3` | `MiniMaxAI/MiniMax-M3` | +| `minimax-m2.7`, `minimax2.7` | `MiniMaxAI/MiniMax-M2.7` | +| `minimax-m2.5`, `minimax2.5` | `MiniMaxAI/MiniMax-M2.5` | +| `kimi-k3`, `kimi3` | `moonshotai/Kimi-K3` | +| `kimi-k2.7-code`, `kimi-code` | `moonshotai/Kimi-K2.7-Code` | +| `kimi-k2.7-highspeed`, `kimi-highspeed` | `moonshotai/Kimi-K2.7-Code-Highspeed` | +| `kimi-k2.6`, `kimi2.6` | `moonshotai/Kimi-K2.6` | +| `kimi-k2.5`, `kimi2.5` | `moonshotai/Kimi-K2.5` | +| `qwen3.7-max`, `qwen-3.7-max` | `Qwen/Qwen3.7-Max` | +| `qwen3.7-plus`, `qwen-3.7-plus` | `Qwen/Qwen3.7-Plus` | +| `qwen3.6-max`, `qwen-3.6-max` | `Qwen/Qwen3.6-Max-Preview` | +| `qwen3.6-plus`, `qwen-3.6-plus` | `Qwen/Qwen3.6-Plus` | +| `step-3.7-flash`, `step3.7` | `stepfun/Step-3.7-Flash` | +| `step3.5`, `step-3.5-flash` | `stepfun/Step-3.5-Flash` | +| `mimo-v2.5-pro`, `mimo-pro` | `xiaomi/mimo-v2.5-pro` | +| `mimo-v2.5`, `mimo2.5` | `xiaomi/mimo-v2.5` | +| `grok-4.5`, `grok4.5` | `xai/grok-4.5` | +| `nemotron`, `nemotron-3-ultra` | `nvidia/nemotron-3-ultra-550b-a55b` | +| `inkling` | `thinkingmachines/inkling` | +| `hy3` | `tencent/Hy3` | Any model ID is passed through as-is — the proxy does not validate against a fixed list. +### Reasoning effort + +Some models support a `reasoning_effort` (`low` | `medium` | `high` | `xhigh` | `max`), and +each accepts a different subset. The proxy sends the closest valid level for the model +(e.g. `deepseek-v4-pro` supports only `high`/`max`, so `low` is clipped up to `high` and +`max` is reachable). Models without a discrete effort set ignore the value. Pass the +effort via OpenAI's `reasoning_effort`, or Anthropic's `thinking.budget_tokens` +(larger budget → higher effort). + ## License MIT diff --git a/docker-compose.yml b/docker-compose.yml index 81f3c32..306e76d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,11 +5,15 @@ services: container_name: commandcode-api-proxy restart: unless-stopped ports: - - "8787:8787" + # Bind host-side to localhost only so the proxy (which forwards your paid + # CC key with no real auth) is not exposed to the LAN. Remove the + # 127.0.0.1: prefix only if you intentionally want network access. + - "127.0.0.1:8787:8787" environment: - HOST=0.0.0.0 - PORT=8787 - CC_API_KEY=${CC_API_KEY:-} - LOG_LEVEL=${LOG_LEVEL:-info} volumes: - - ~/.commandcode:/home/node/.commandcode:ro + # auth.json lives at ~/.config/commandcode-api-proxy/auth.json (see auth.ts). + - ~/.config/commandcode-api-proxy:/home/node/.config/commandcode-api-proxy:ro diff --git a/src/config.ts b/src/config.ts index 8d15bc4..bdbf8b5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,6 +14,7 @@ export interface Config { ccApiBase: string; ccVersion: string; logLevel: string; + corsOrigin: string; } /** @@ -81,6 +82,9 @@ export function loadConfig(): Config { const ccApiBase = process.env.CC_API_BASE || "https://api.commandcode.ai"; const ccVersion = process.env.CC_CLI_VERSION || cachedVersion || DEFAULT_CC_VERSION; const logLevel = process.env.LOG_LEVEL || "info"; + // `*` is fine for a localhost proxy; restrict (e.g. to an origin or leave + // empty to disable) before exposing the proxy on a network. + const corsOrigin = process.env.CORS_ORIGIN ?? "*"; - return { host, port, apiKey, ccApiBase, ccVersion, logLevel }; + return { host, port, apiKey, ccApiBase, ccVersion, logLevel, corsOrigin }; } diff --git a/src/models.json b/src/models.json index 33195cc..210a6e5 100644 --- a/src/models.json +++ b/src/models.json @@ -2,18 +2,30 @@ "builtin": [ "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", - "MiniMaxAI/MiniMax-M2.7", - "MiniMaxAI/MiniMax-M2.5", + "zai-org/GLM-5.2", + "zai-org/GLM-5.2-Fast", "zai-org/GLM-5.1", "zai-org/GLM-5", + "MiniMaxAI/MiniMax-M3", + "MiniMaxAI/MiniMax-M2.7", + "MiniMaxAI/MiniMax-M2.5", + "moonshotai/Kimi-K3", + "moonshotai/Kimi-K2.7-Code", + "moonshotai/Kimi-K2.7-Code-Highspeed", "moonshotai/Kimi-K2.6", "moonshotai/Kimi-K2.5", + "Qwen/Qwen3.7-Max", + "Qwen/Qwen3.7-Plus", "Qwen/Qwen3.6-Max-Preview", "Qwen/Qwen3.6-Plus", + "stepfun/Step-3.7-Flash", "stepfun/Step-3.5-Flash", + "xiaomi/mimo-v2.5-pro", "xiaomi/mimo-v2.5", - "Qwen/Qwen3.7-Max", - "Qwen/Qwen3.7-Plus" + "xai/grok-4.5", + "nvidia/nemotron-3-ultra-550b-a55b", + "thinkingmachines/inkling", + "tencent/Hy3" ], "shortAliases": { "deepseek-v4-pro": "deepseek/deepseek-v4-pro", @@ -21,60 +33,113 @@ "deepseek-pro": "deepseek/deepseek-v4-pro", "deepseek-v4-flash": "deepseek/deepseek-v4-flash", "deepseek-flash": "deepseek/deepseek-v4-flash", - "kimi-k2.6": "moonshotai/Kimi-K2.6", - "kimi-k2.5": "moonshotai/Kimi-K2.5", - "kimi2.6": "moonshotai/Kimi-K2.6", - "kimi2.5": "moonshotai/Kimi-K2.5", + "glm-5.2": "zai-org/GLM-5.2", + "glm5.2": "zai-org/GLM-5.2", + "glm-5.2-fast": "zai-org/GLM-5.2-Fast", + "glm5.2-fast": "zai-org/GLM-5.2-Fast", "glm-5.1": "zai-org/GLM-5.1", "glm-5": "zai-org/GLM-5", + "minimax-m3": "MiniMaxAI/MiniMax-M3", + "minimax3": "MiniMaxAI/MiniMax-M3", "minimax-m2.7": "MiniMaxAI/MiniMax-M2.7", "minimax2.7": "MiniMaxAI/MiniMax-M2.7", "minimax-m2.5": "MiniMaxAI/MiniMax-M2.5", "minimax2.5": "MiniMaxAI/MiniMax-M2.5", + "kimi-k3": "moonshotai/Kimi-K3", + "kimi3": "moonshotai/Kimi-K3", + "kimi-k2.7-code": "moonshotai/Kimi-K2.7-Code", + "kimi2.7-code": "moonshotai/Kimi-K2.7-Code", + "kimi-code": "moonshotai/Kimi-K2.7-Code", + "kimi-k2.7-highspeed": "moonshotai/Kimi-K2.7-Code-Highspeed", + "kimi-highspeed": "moonshotai/Kimi-K2.7-Code-Highspeed", + "kimi-k2.6": "moonshotai/Kimi-K2.6", + "kimi2.6": "moonshotai/Kimi-K2.6", + "kimi-k2.5": "moonshotai/Kimi-K2.5", + "kimi2.5": "moonshotai/Kimi-K2.5", + "qwen3.7-max": "Qwen/Qwen3.7-Max", + "qwen-3.7-max": "Qwen/Qwen3.7-Max", + "qwen3.7-plus": "Qwen/Qwen3.7-Plus", + "qwen-3.7-plus": "Qwen/Qwen3.7-Plus", "qwen-3.6-max": "Qwen/Qwen3.6-Max-Preview", "qwen3.6-max": "Qwen/Qwen3.6-Max-Preview", "qwen-3.6-plus": "Qwen/Qwen3.6-Plus", "qwen3.6-plus": "Qwen/Qwen3.6-Plus", + "step-3.7-flash": "stepfun/Step-3.7-Flash", + "step3.7": "stepfun/Step-3.7-Flash", "step-3.5-flash": "stepfun/Step-3.5-Flash", "step3.5": "stepfun/Step-3.5-Flash", + "mimo-v2.5-pro": "xiaomi/mimo-v2.5-pro", + "mimo-pro": "xiaomi/mimo-v2.5-pro", "mimo-v2.5": "xiaomi/mimo-v2.5", "mimo2.5": "xiaomi/mimo-v2.5", - "qwen3.7-max": "Qwen/Qwen3.7-Max", - "qwen-3.7-max": "Qwen/Qwen3.7-Max", - "qwen3.7-plus": "Qwen/Qwen3.7-Plus", - "qwen-3.7-plus": "Qwen/Qwen3.7-Plus" + "grok-4.5": "xai/grok-4.5", + "grok4.5": "xai/grok-4.5", + "nemotron": "nvidia/nemotron-3-ultra-550b-a55b", + "nemotron-3-ultra": "nvidia/nemotron-3-ultra-550b-a55b", + "inkling": "thinkingmachines/inkling", + "hy3": "tencent/Hy3" }, "contextWindows": { "deepseek/deepseek-v4-pro": 1048576, "deepseek/deepseek-v4-flash": 1048576, - "MiniMaxAI/MiniMax-M2.7": 204800, - "MiniMaxAI/MiniMax-M2.5": 204800, + "zai-org/GLM-5.2": 1048576, + "zai-org/GLM-5.2-Fast": 1048576, "zai-org/GLM-5.1": 200000, "zai-org/GLM-5": 200000, - "moonshotai/Kimi-K2.6": 262144, - "moonshotai/Kimi-K2.5": 262144, + "MiniMaxAI/MiniMax-M3": 1048576, + "MiniMaxAI/MiniMax-M2.7": 204800, + "MiniMaxAI/MiniMax-M2.5": 200000, + "moonshotai/Kimi-K3": 1048576, + "moonshotai/Kimi-K2.7-Code": 256000, + "moonshotai/Kimi-K2.7-Code-Highspeed": 262000, + "moonshotai/Kimi-K2.6": 256000, + "moonshotai/Kimi-K2.5": 256000, + "Qwen/Qwen3.7-Max": 1048576, + "Qwen/Qwen3.7-Plus": 1048576, "Qwen/Qwen3.6-Max-Preview": 262144, "Qwen/Qwen3.6-Plus": 1048576, - "stepfun/Step-3.5-Flash": 262144, + "stepfun/Step-3.7-Flash": 256000, + "stepfun/Step-3.5-Flash": 1048576, + "xiaomi/mimo-v2.5-pro": 1048576, "xiaomi/mimo-v2.5": 1048576, - "Qwen/Qwen3.7-Max": 1048576, - "Qwen/Qwen3.7-Plus": 1048576 + "xai/grok-4.5": 500000, + "nvidia/nemotron-3-ultra-550b-a55b": 1048576, + "thinkingmachines/inkling": 256000, + "tencent/Hy3": 262144 }, "modelNames": { "deepseek/deepseek-v4-pro": "DeepSeek V4 Pro", "deepseek/deepseek-v4-flash": "DeepSeek V4 Flash", - "MiniMaxAI/MiniMax-M2.7": "MiniMax M2.7", - "MiniMaxAI/MiniMax-M2.5": "MiniMax M2.5", + "zai-org/GLM-5.2": "GLM-5.2", + "zai-org/GLM-5.2-Fast": "GLM-5.2 Fast", "zai-org/GLM-5.1": "GLM-5.1", "zai-org/GLM-5": "GLM-5", + "MiniMaxAI/MiniMax-M3": "MiniMax M3", + "MiniMaxAI/MiniMax-M2.7": "MiniMax M2.7", + "MiniMaxAI/MiniMax-M2.5": "MiniMax M2.5", + "moonshotai/Kimi-K3": "Kimi K3", + "moonshotai/Kimi-K2.7-Code": "Kimi K2.7 Code", + "moonshotai/Kimi-K2.7-Code-Highspeed": "Kimi K2.7 Code Highspeed", "moonshotai/Kimi-K2.6": "Kimi K2.6", "moonshotai/Kimi-K2.5": "Kimi K2.5", + "Qwen/Qwen3.7-Max": "Qwen 3.7 Max", + "Qwen/Qwen3.7-Plus": "Qwen 3.7 Plus", "Qwen/Qwen3.6-Max-Preview": "Qwen 3.6 Max Preview", "Qwen/Qwen3.6-Plus": "Qwen 3.6 Plus", + "stepfun/Step-3.7-Flash": "Step 3.7 Flash", "stepfun/Step-3.5-Flash": "Step 3.5 Flash", + "xiaomi/mimo-v2.5-pro": "MiMo V2.5 Pro", "xiaomi/mimo-v2.5": "MiMo V2.5", - "Qwen/Qwen3.7-Max": "Qwen 3.7 Max", - "Qwen/Qwen3.7-Plus": "Qwen 3.7 Plus" + "xai/grok-4.5": "Grok 4.5", + "nvidia/nemotron-3-ultra-550b-a55b": "Nemotron 3 Ultra", + "thinkingmachines/inkling": "Inkling", + "tencent/Hy3": "Hy3" + }, + "reasoningEfforts": { + "deepseek/deepseek-v4-pro": ["high", "max"], + "deepseek/deepseek-v4-flash": ["high", "max"], + "zai-org/GLM-5.2": ["high", "max"], + "xai/grok-4.5": ["low", "medium", "high"] }, "maxOutputTokens": { "deepseek/deepseek-v4-pro": 393216, diff --git a/src/proxy.ts b/src/proxy.ts index 37af665..4b09c31 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -89,11 +89,25 @@ server.listen(config.port, config.host, () => { console.log(" Press Ctrl+C to stop\n"); }); -process.on("SIGINT", () => { - console.log("\n Shutting down..."); - server.close(() => process.exit(0)); +// Never let an unexpected async error crash the proxy silently — log and keep +// serving. (Route handlers already catch their own errors; this is a backstop.) +process.on("unhandledRejection", (reason) => { + logger.error("[fatal] Unhandled promise rejection:", reason); +}); +process.on("uncaughtException", (err) => { + logger.error("[fatal] Uncaught exception:", err); }); -process.on("SIGTERM", () => { +const shutdown = (signal: string): void => { + logger.info(`Received ${signal}, shutting down...`); + // Don't hang forever waiting on a stuck streaming connection. + const force = setTimeout(() => { + logger.warn("Forcing shutdown after 10s timeout"); + process.exit(1); + }, 10_000); + force.unref(); server.close(() => process.exit(0)); -}); +}; + +process.on("SIGINT", () => shutdown("SIGINT")); +process.on("SIGTERM", () => shutdown("SIGTERM")); diff --git a/src/server.ts b/src/server.ts index 921343c..ee3c340 100644 --- a/src/server.ts +++ b/src/server.ts @@ -26,6 +26,7 @@ import type { AnthropicRequest } from "@/translate/anthropic-types.js"; let config: Config; let modelList: string[] = getDefaultModels(); +let corsOrigin = "*"; function updateModelList(models: string[]): void { if (models.length > 0) modelList = models; @@ -35,20 +36,46 @@ function updateModelList(models: string[]): void { // Request body parser // ────────────────────────────────────────── +const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MiB + +export class BodyParseError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message); + this.name = "BodyParseError"; + } +} + function parseBody(req: http.IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; - req.on("data", (chunk: Buffer) => chunks.push(chunk)); + let size = 0; + let tooLarge = false; + req.on("data", (chunk: Buffer) => { + if (tooLarge) return; + size += chunk.length; + if (size > MAX_BODY_BYTES) { + tooLarge = true; + reject(new BodyParseError(413, "Request body too large")); + return; + } + chunks.push(chunk); + }); req.on("end", () => { + if (tooLarge) return; const raw = Buffer.concat(chunks).toString("utf-8"); if (!raw) return resolve(null); try { resolve(JSON.parse(raw)); } catch { - reject(new Error("Invalid JSON body")); + reject(new BodyParseError(400, "Invalid JSON body")); } }); - req.on("error", reject); + req.on("error", (err) => { + if (!tooLarge) reject(err); + }); }); } @@ -70,10 +97,10 @@ function extractApiKey(req: http.IncomingMessage): string | null { // If the client sent "proxy-managed" or no key, fall back to the proxy's configured key if (!key || key === "proxy-managed" || key === "placeholder") { - logger.debug(`client key sentinel, using config key (prefix: "${config.apiKey?.slice(0, 8)}")`); + logger.debug(`client key sentinel, using config key (length: ${config.apiKey?.length ?? 0})`); return config.apiKey; } - logger.debug(`using client's own key (prefix: "${key.slice(0, 8)}")`); + logger.debug(`using client's own key (length: ${key.length})`); return key; } @@ -82,6 +109,8 @@ function extractApiKey(req: http.IncomingMessage): string | null { // ────────────────────────────────────────── function sendJson(res: http.ServerResponse, status: number, data: unknown): void { + // Client may have disconnected mid-request; never write to a dead socket. + if (res.headersSent || res.writableEnded || res.destroyed) return; res.writeHead(status, { "Content-Type": "application/json", ...corsHeaders(), @@ -109,16 +138,20 @@ function sendAnthropicError( type: string, message: string, ): void { + if (res.headersSent || res.writableEnded || res.destroyed) return; 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": "*", + const origin = corsOrigin; + const headers: Record = { "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization, x-api-key", }; + // Empty CORS_ORIGIN disables the header entirely (browser blocks cross-origin). + if (origin) headers["Access-Control-Allow-Origin"] = origin; + return headers; } // ────────────────────────────────────────── @@ -196,8 +229,10 @@ async function handleChatCompletions( let rawBody: unknown; try { rawBody = await parseBody(req); - } catch { - return sendOpenAIError(res, 400, "Invalid JSON body"); + } catch (err) { + const status = err instanceof BodyParseError ? err.status : 400; + const message = err instanceof BodyParseError ? err.message : "Invalid JSON body"; + return sendOpenAIError(res, status, message); } let openAIReq; @@ -285,8 +320,15 @@ async function handleMessages(req: http.IncomingMessage, res: http.ServerRespons let rawBody: unknown; try { rawBody = await parseBody(req); - } catch { - return sendAnthropicError(res, 400, "invalid_request_error", "Invalid JSON body"); + } catch (err) { + const status = err instanceof BodyParseError ? err.status : 400; + const message = err instanceof BodyParseError ? err.message : "Invalid JSON body"; + return sendAnthropicError( + res, + status, + status === 413 ? "api_error" : "invalid_request_error", + message, + ); } let anthropicReq: AnthropicRequest; @@ -365,8 +407,15 @@ async function handleCountTokens( let rawBody: unknown; try { rawBody = await parseBody(req); - } catch { - return sendAnthropicError(res, 400, "invalid_request_error", "Invalid JSON body"); + } catch (err) { + const status = err instanceof BodyParseError ? err.status : 400; + const message = err instanceof BodyParseError ? err.message : "Invalid JSON body"; + return sendAnthropicError( + res, + status, + status === 413 ? "api_error" : "invalid_request_error", + message, + ); } const body = rawBody as Record; @@ -455,6 +504,7 @@ interface RouteEntry { export function createServer(cfg: Config): http.Server { config = cfg; + corsOrigin = cfg.corsOrigin; // Start fetching model list in background (only if we have a key to use). if (cfg.apiKey) { diff --git a/src/setup/opencode.ts b/src/setup/opencode.ts index 056802f..90ec8e1 100644 --- a/src/setup/opencode.ts +++ b/src/setup/opencode.ts @@ -2,22 +2,43 @@ import fs from "node:fs"; import path from "node:path"; import modelsData from "@/models.json" with { type: "json" }; +/** Every reasoning-effort tier CC/OpenCode know about, ordered low → max. */ +const EFFORT_UNIVERSE = ["low", "medium", "high", "xhigh", "max"] as const; + 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 models: Record = {}; + const reasoningEfforts: Record = + ((modelsData as Record).reasoningEfforts as Record) ?? {}; + const models: Record> = {}; for (const id of modelsData.builtin) { const key = id.split("/").pop() ?? id; - models[key] = { + const entry: Record = { name: modelNames[id] ?? key, limit: { context: contextWindows[id] ?? 128_000, output: maxOutputTokens[id] ?? 128_000, }, }; + // OpenCode's `/variants` picker lists a model's `variants`. OpenCode also + // auto-generates generic `WIDELY_SUPPORTED_EFFORTS` (low/medium/high) for any + // reasoning-capable openai-compatible model and mergeDeep's them with our + // config — which would surface effort levels the model doesn't actually + // support. Declare the full effort universe and `disabled:true` the ones + // this model rejects; OpenCode filters disabled variants out (pickBy). + const efforts = reasoningEfforts[id]; + if (efforts && efforts.length > 0) { + entry.reasoning = true; + const variants: Record> = {}; + for (const e of EFFORT_UNIVERSE) { + variants[e] = efforts.includes(e) ? { reasoningEffort: e } : { disabled: true }; + } + entry.variants = variants; + } + models[key] = entry; } return { npm: "@ai-sdk/openai-compatible", diff --git a/src/stream.ts b/src/stream.ts index a18f51c..34e57ba 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -1,4 +1,3 @@ -import { Transform, type TransformCallback } from "node:stream"; import type { CCEvent } from "@/translate/types.js"; /** @@ -45,54 +44,6 @@ export function parseCCLine(line: string): ParsedChunk { } } -/** - * Node.js Transform stream that takes raw NDJSON buffer chunks from the CC API - * and emits parsed CCEvent objects. - * - * Usage: - * ccStream.pipe(ndjsonParser) - * ndjsonParser.on('data', (event: CCEvent) => { ... }) - * ndjsonParser.on('error', (err) => { ... }) - * ndjsonParser.on('end', () => { ... }) - */ -export class NDJSONParser extends Transform { - private buffer = ""; - - constructor() { - super({ readableObjectMode: true, writableObjectMode: false }); - } - - _transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void { - this.buffer += chunk.toString("utf-8"); - const lines = this.buffer.split("\n"); - // Keep the last (possibly incomplete) line in the buffer - this.buffer = lines.pop() ?? ""; - - for (const line of lines) { - const result = parseCCLine(line); - if (result.type === "event" && result.event) { - this.push(result.event); - } else if (result.type === "done") { - // Stream complete, will be handled by 'end' event - continue; - } - } - - callback(); - } - - _flush(callback: TransformCallback): void { - // Process any remaining data in buffer - if (this.buffer.trim()) { - const result = parseCCLine(this.buffer); - if (result.type === "event" && result.event) { - this.push(result.event); - } - } - callback(); - } -} - /** * Format an object as an SSE message string. */ diff --git a/src/translate/anthropic.ts b/src/translate/anthropic.ts index 5bb4d3b..11be46d 100644 --- a/src/translate/anthropic.ts +++ b/src/translate/anthropic.ts @@ -12,18 +12,21 @@ import type { ContentBlockStartShape, DeltaShape, } from "@/translate/anthropic-types.js"; -import type { CCMessage, CCContentPart, CCRequestBody, CCEvent } from "@/translate/types.js"; +import type { + CCMessage, + CCContentPart, + CCRequestBody, + CCToolChoice, + CCEvent, +} from "@/translate/types.js"; import { resolveAnthropicModel } from "@/translate/anthropic-models.js"; -import { - extractUsage, - pruneDanglingTools, - buildCCConfig, -} from "@/translate/util.js"; +import { resolveEffortForModel } from "@/translate/models.js"; +import { extractUsage, pruneDanglingTools, buildCCConfig } from "@/translate/util.js"; import { logger } from "@/logger.js"; // ── Constants ── -const REASONING_THRESHOLDS = { LOW: 2000, MEDIUM: 8000 } as const; +const REASONING_THRESHOLDS = { LOW: 2000, MEDIUM: 8000, HIGH: 16000, XHIGH: 32000 } as const; const ANTHROPIC_STOP_REASON_MAP: Record = { stop: "end_turn", length: "max_tokens", @@ -138,10 +141,7 @@ function toCCMessages(messages: AnthropicRequest["messages"]): { return { ccMessages: pruneDanglingTools(ccMessages), - systemPrompt: - systemParts.length > 0 - ? systemParts.join("\n\n") - : undefined, + systemPrompt: systemParts.length > 0 ? systemParts.join("\n\n") : undefined, }; } @@ -159,18 +159,24 @@ function toCCPartByBlock(block: AnthropicContentBlock): CCContentPart | null { 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"; + const b = thinking.budget_tokens; + if (b <= REASONING_THRESHOLDS.LOW) return "low"; + if (b <= REASONING_THRESHOLDS.MEDIUM) return "medium"; + if (b <= REASONING_THRESHOLDS.HIGH) return "high"; + if (b <= REASONING_THRESHOLDS.XHIGH) return "xhigh"; + return "max"; } -function resolveToolChoice(anthropic: AnthropicRequest): string | undefined { +/** + * Map an Anthropic `tool_choice` to the object form CC's bridge requires. + * CC accepts `{type:"auto"|"any"|"tool", name?}` only. Anthropic "any" → "any"; + * Anthropic "none" has no CC equivalent, so we omit it (default auto). + */ +function resolveToolChoice(anthropic: AnthropicRequest): CCToolChoice | 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"; + if (!tc || tc.type === "auto" || tc.type === "none") return undefined; + if (tc.type === "any") return { type: "any" }; + if (tc.type === "tool") return { type: "tool", name: tc.name }; return undefined; } @@ -220,7 +226,7 @@ export function toCCRequest( stop: req.stop_sequences, tools: ccTools, tool_choice: resolveToolChoice(req), - reasoning_effort: resolveReasoningEffort(req.thinking), + reasoning_effort: resolveEffortForModel(resolvedModel, resolveReasoningEffort(req.thinking)), }, threadId: crypto.randomUUID(), }; @@ -309,6 +315,11 @@ export class AnthropicStreamEncoder { const finishReason = (event.data.finishReason as string) ?? "stop"; const usage = extractUsage(event.data as Record); + // CC's `start` event carries no usage — input/output token counts are only + // known at `finish`. Anthropic's SDK merges `message_delta.usage` over the + // `message_start.usage`, so emitting them here corrects the final values + // (message_start reported input_tokens as 0 because start was empty). + const cachedTokens = usage?.promptTokensDetails?.cachedTokens; records.push({ event: "message_delta", data: { @@ -318,7 +329,9 @@ export class AnthropicStreamEncoder { stop_sequence: null, }, usage: { + input_tokens: usage?.promptTokens ?? 0, output_tokens: usage?.completionTokens ?? 0, + ...(cachedTokens != null ? { cache_read_input_tokens: cachedTokens } : {}), }, }, }); @@ -542,7 +555,7 @@ export function buildAnthropicResponse( input_tokens: usage?.promptTokens ?? 0, output_tokens: usage?.completionTokens ?? 0, cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, + cache_read_input_tokens: usage?.promptTokensDetails?.cachedTokens ?? 0, }, }; } diff --git a/src/translate/models.ts b/src/translate/models.ts index da4fd32..8b8060c 100644 --- a/src/translate/models.ts +++ b/src/translate/models.ts @@ -4,6 +4,11 @@ import modelsData from "@/models.json" with { type: "json" }; const BUILTIN_MODELS: string[] = modelsData.builtin; const SHORT_ALIASES: Record = modelsData.shortAliases; +/** Canonical model id → the discrete reasoning-effort levels CC accepts for it. */ +const REASONING_EFFORTS: Record = modelsData.reasoningEfforts ?? {}; + +/** Rank ordering of effort levels (low → max). Used to clip to the nearest valid. */ +const EFFORT_RANK: Record = { low: 0, medium: 1, high: 2, xhigh: 3, max: 4 }; /** * Fetch available models from CC provider API. Returns the model list (used by @@ -33,9 +38,55 @@ export function resolveModel(model: string): string { if (!model || model === "default") { return BUILTIN_MODELS[0]; } - const aliased = SHORT_ALIASES[model]; + // Alias lookup is case-insensitive so callers can pass the bare model name + // with original casing (e.g. "GLM-5.2") as well as the lowercase short alias. + const aliased = SHORT_ALIASES[model] ?? SHORT_ALIASES[model.toLowerCase()]; if (aliased) return aliased; // Already a full model ID (contains "/") — pass through untouched. if (model.includes("/")) return model; + // Bare name without an org prefix (e.g. "GLM-5.2", "Kimi-K3", or + // "nemotron-3-ultra-550b-a55b" — which has no short alias). Match it against + // the builtin catalog by last path segment so it still resolves to a full ID. + const lower = model.toLowerCase(); + for (const id of BUILTIN_MODELS) { + const last = id.split("/").pop() ?? id; + if (last.toLowerCase() === lower) return id; + } return model; } + +/** + * Resolve a requested reasoning effort for a specific (canonical) model. + * + * CC accepts `params.reasoning_effort` but each model supports a different set + * of levels, and CC silently coerces unsupported values (it does not 400). To + * honor caller intent without sending a value the model can't use: + * - If the model isn't catalogued (no known effort set), pass the request + * through unchanged — we don't know better, so don't regress. + * - If the model has a known effort set, clip an unsupported request to the + * nearest valid level (highest supported rank ≤ requested, else the lowest + * supported). E.g. deepseek-v4-pro supports only {high, max}, so a request + * for "low"/"medium" becomes "high", and "max" stays reachable. + * - If no effort was requested, return undefined and let CC pick its default. + */ +export function resolveEffortForModel( + canonicalModel: string, + requested?: string, +): string | undefined { + const supported = REASONING_EFFORTS[canonicalModel]; + // Uncatalogued/empty effort set, or nothing requested → preserve as-is + // (undefined or the value). The empty-array guard matters: `[]` is truthy, + // and reduce() on it below would otherwise throw. + if (!supported || supported.length === 0 || !requested) return requested; + + if (supported.includes(requested)) return requested; + + const rank = (e: string): number => EFFORT_RANK[e] ?? 2; + const reqRank = rank(requested); + const atOrBelow = supported.filter((e) => rank(e) <= reqRank); + if (atOrBelow.length > 0) { + return atOrBelow.reduce((best, e) => (rank(e) > rank(best) ? e : best)); + } + // Requested rank is below every supported level → use the lowest supported. + return supported.reduce((best, e) => (rank(e) < rank(best) ? e : best)); +} diff --git a/src/translate/openai.ts b/src/translate/openai.ts index 88a8c99..5d2d720 100644 --- a/src/translate/openai.ts +++ b/src/translate/openai.ts @@ -10,10 +10,11 @@ import type { CCContentPart, CCTool, CCRequestBody, + CCToolChoice, CCEvent, UsageData, } from "@/translate/types.js"; -import { resolveModel } from "@/translate/models.js"; +import { resolveModel, resolveEffortForModel } from "@/translate/models.js"; import { applyNoToolsSafeguard, extractUsage, @@ -133,12 +134,18 @@ function toCCTools(tools: OpenAITool[] | undefined): CCTool[] | undefined { })); } -function resolveToolChoice(tc: ToolChoice | undefined): string | undefined { - if (!tc || tc === "auto") return undefined; - if (tc === "none") return "none"; - if (tc === "required") return "required"; +/** + * Map an OpenAI `tool_choice` to the object form CC's bridge requires. + * CC validates `params.tool_choice` as an object and rejects bare strings. + * Accepted types: "auto" | "any" | "tool". There is no "required" (use "any") + * and no "none" — "none" has no CC equivalent, so we fall back to the default + * (omit) and let the model decide. + */ +function resolveToolChoice(tc: ToolChoice | undefined): CCToolChoice | undefined { + if (!tc || tc === "auto" || tc === "none") return undefined; + if (tc === "required") return { type: "any" }; if (typeof tc === "object" && tc.type === "function") { - return tc.function.name; + return { type: "tool", name: tc.function.name }; } return undefined; } @@ -164,7 +171,7 @@ export function toCCRequest( temperature: req.temperature, top_p: req.top_p, stop: req.stop, - reasoning_effort: req.reasoning_effort, + reasoning_effort: resolveEffortForModel(resolvedModel, req.reasoning_effort), tools: toCCTools(req.tools), tool_choice: resolveToolChoice(req.tool_choice), }, diff --git a/src/translate/types.ts b/src/translate/types.ts index 37485a6..937d3f9 100644 --- a/src/translate/types.ts +++ b/src/translate/types.ts @@ -14,7 +14,7 @@ export interface OpenAIChatRequest { stop?: string | string[]; tools?: OpenAITool[]; tool_choice?: ToolChoice; - reasoning_effort?: "low" | "medium" | "high"; + reasoning_effort?: "low" | "medium" | "high" | "xhigh" | "max"; } export interface OpenAIMessage { @@ -99,11 +99,19 @@ export interface CCRequestBody { stream: boolean; reasoning_effort?: string; tools?: CCTool[]; - tool_choice?: string; + /** + * CC's bridge validates `tool_choice` as an object (it rejects a bare + * string with 400 "expected object, received string"). The accepted shape + * is a discriminated union: `{type:"auto"}`, `{type:"any"}`, or + * `{type:"tool", name}`. There is NO "required" (use "any") and NO "none". + */ + tool_choice?: CCToolChoice; }; threadId: string; } +export type CCToolChoice = { type: "auto" } | { type: "any" } | { type: "tool"; name: string }; + export type CCEventType = | "start" | "text-delta" diff --git a/src/upstream.ts b/src/upstream.ts index 9251b5d..129893b 100644 --- a/src/upstream.ts +++ b/src/upstream.ts @@ -22,9 +22,7 @@ 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 header (key length: ${apiKey.length})`); return { "Content-Type": "application/json", Accept: "application/json, */*", @@ -68,8 +66,29 @@ function generateTraceparent(): string { * regardless of the downstream client's `stream` flag. For non-streaming * downstream requests, the caller drains the returned `stream` into events. * + * Retryable failures (HTTP 5xx/429, timeouts, network errors) are retried up + * to MAX_RETRIES times with linear backoff — but ONLY before the stream starts. + * A client-initiated abort (caller already disconnected) is never retried. + * * Returns a Readable of parsed CCEvents. Callers MUST consume or destroy it. */ +const MAX_RETRIES = 2; +const RETRY_BACKOFF_MS = 500; + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + const t = setTimeout(resolve, ms); + signal?.addEventListener( + "abort", + () => { + clearTimeout(t); + resolve(); + }, + { once: true }, + ); + }); +} + export async function sendToCC( body: CCRequestBody, options: UpstreamOptions, @@ -81,45 +100,71 @@ export async function sendToCC( // CC's API is always streaming — force it on so the upstream stays a stream. body.params.stream = true; - const controller = new AbortController(); - const combinedSignal = signal ? combineSignals(signal, controller.signal) : controller.signal; - - const timeout = setTimeout(() => controller.abort(new Error("Upstream timeout")), timeoutMs); - - try { - const response = await fetch(url, { - method: "POST", - headers: buildHeaders(apiKey, ccVersion, body), - body: JSON.stringify(body), - signal: combinedSignal, - }); - - clearTimeout(timeout); - - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - const isRetryable = response.status >= 500 || response.status === 429; - throw new UpstreamError( - `CC API ${response.status}: ${errorText || response.statusText}`, - response.status, - isRetryable, - ); - } + let lastError: UpstreamError | null = null; + + for (let attempt = 1; attempt <= MAX_RETRIES + 1; attempt++) { + // Per-attempt timeout so one dead connection can't burn the whole budget. + const controller = new AbortController(); + const combinedSignal = signal ? combineSignals(signal, controller.signal) : controller.signal; + const timeout = setTimeout(() => controller.abort(new Error("Upstream timeout")), timeoutMs); + + try { + const response = await fetch(url, { + method: "POST", + headers: buildHeaders(apiKey, ccVersion, body), + body: JSON.stringify(body), + signal: combinedSignal, + }); + clearTimeout(timeout); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + const retryable = response.status >= 500 || response.status === 429; + lastError = new UpstreamError( + `CC API ${response.status}: ${errorText || response.statusText}`, + response.status, + retryable, + ); + if (retryable && attempt <= MAX_RETRIES) { + logger.warn(`CC upstream ${response.status}, retrying ${attempt}/${MAX_RETRIES}...`); + await sleep(RETRY_BACKOFF_MS * attempt, signal); + continue; + } + throw lastError; + } - if (!response.body) { - throw new UpstreamError("CC API returned no body", 0, true); - } + if (!response.body) { + throw new UpstreamError("CC API returned no body", 0, true); + } + + return { stream: nodeReaderToStream(response.body.getReader()) }; + } catch (err) { + clearTimeout(timeout); + if (err instanceof UpstreamError) throw err; - const stream = nodeReaderToStream(response.body.getReader()); - return { stream }; - } catch (err) { - clearTimeout(timeout); - if (err instanceof UpstreamError) throw err; - if ((err as Error).name === "AbortError") { - throw new UpstreamError("Request aborted", 0, true); + // Distinguish a client-initiated abort (caller is gone — never retry, it + // only wastes a request) from a timeout/network blip (retryable). + const aborted = (err as Error).name === "AbortError"; + if (aborted && signal?.aborted) { + throw new UpstreamError("Request aborted", 0, true); + } + lastError = new UpstreamError( + aborted ? "Upstream timeout" : `Upstream request failed: ${(err as Error).message}`, + 0, + true, + ); + if (attempt <= MAX_RETRIES) { + logger.warn( + `CC upstream ${aborted ? "timeout" : "error"}, retrying ${attempt}/${MAX_RETRIES}...`, + ); + await sleep(RETRY_BACKOFF_MS * attempt, signal); + continue; + } + throw lastError; } - throw new UpstreamError(`Upstream request failed: ${(err as Error).message}`, 0, true); } + + throw lastError ?? new UpstreamError("Upstream request failed after retries", 0, true); } /** diff --git a/tests/setup-opencode.test.ts b/tests/setup-opencode.test.ts new file mode 100644 index 0000000..2237b72 --- /dev/null +++ b/tests/setup-opencode.test.ts @@ -0,0 +1,57 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { setupOpenCodeConfig } from "@/setup/opencode.js"; +import fs from "node:fs"; + +describe("setupOpenCodeConfig", () => { + let written: string | null = null; + let consoleLogSpy: ReturnType; + + beforeEach(() => { + written = null; + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(fs, "existsSync").mockImplementation(() => false); + vi.spyOn(fs, "mkdirSync").mockImplementation(() => undefined as unknown as string); + vi.spyOn(fs, "writeFileSync").mockImplementation(((_path, data) => { + written = data.toString(); + }) as typeof fs.writeFileSync); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("writes a commandcode provider with all builtin models", async () => { + await setupOpenCodeConfig("local"); + expect(written).not.toBeNull(); + const config = JSON.parse(written as string); + const cc = config.provider.commandcode; + expect(cc.npm).toBe("@ai-sdk/openai-compatible"); + expect(cc.options.baseURL).toBe("http://127.0.0.1:8787/v1"); + expect(Object.keys(cc.models).length).toBeGreaterThan(10); + }); + + test("emits reasoning variants for effort-capable models (powers /variants)", async () => { + await setupOpenCodeConfig("local"); + const config = JSON.parse(written as string); + const models = config.provider.commandcode.models; + + // deepseek-v4-pro supports {high, max}; the other tiers are disabled so + // OpenCode's generic low/medium/high don't leak into the picker. + const ds = models["deepseek-v4-pro"].variants; + expect(ds.high).toEqual({ reasoningEffort: "high" }); + expect(ds.max).toEqual({ reasoningEffort: "max" }); + expect(ds.low).toEqual({ disabled: true }); + expect(ds.medium).toEqual({ disabled: true }); + expect(models["deepseek-v4-pro"].reasoning).toBe(true); + + // grok-4.5 supports {low, medium, high}; xhigh/max disabled + const grok = models["grok-4.5"].variants; + expect(grok.low).toEqual({ reasoningEffort: "low" }); + expect(grok.medium).toEqual({ reasoningEffort: "medium" }); + expect(grok.high).toEqual({ reasoningEffort: "high" }); + expect(grok.max).toEqual({ disabled: true }); + + // models without discrete efforts get no variants field + expect(models["Qwen3.7-Max"].variants).toBeUndefined(); + }); +}); diff --git a/tests/stream.test.ts b/tests/stream.test.ts index ffdeddf..5548dc8 100644 --- a/tests/stream.test.ts +++ b/tests/stream.test.ts @@ -1,12 +1,5 @@ import { describe, it, expect } from "vitest"; -import { Readable } from "node:stream"; -import { - parseCCLine, - formatSSE, - formatSSEDone, - formatAnthropicSSE, - NDJSONParser, -} from "@/stream.js"; +import { parseCCLine, formatSSE, formatSSEDone, formatAnthropicSSE } from "@/stream.js"; describe("parseCCLine", () => { it("parses a CC event with data: prefix", () => { @@ -79,104 +72,6 @@ describe("formatSSEDone", () => { }); }); -describe("NDJSONParser", () => { - it("parses NDJSON lines into CCEvent objects", async () => { - const input = Buffer.from( - '{"type":"start","data":{"model":"test"}}\n{"type":"text-delta","data":{"text":"Hi"}}\n', - ); - const stream = Readable.from([input]); - const parser = stream.pipe(new NDJSONParser()); - - const events: any[] = []; - for await (const event of parser) { - events.push(event); - } - - expect(events).toHaveLength(2); - expect(events[0].type).toBe("start"); - expect(events[1].type).toBe("text-delta"); - expect(events[1].data.text).toBe("Hi"); - }); - - it("handles data: prefix", async () => { - const input = Buffer.from('data: {"type":"text-delta","data":{"text":"Hello"}}\n'); - const stream = Readable.from([input]); - const parser = stream.pipe(new NDJSONParser()); - - const events: any[] = []; - for await (const event of parser) { - events.push(event); - } - - expect(events).toHaveLength(1); - expect(events[0].type).toBe("text-delta"); - }); - - it("skips empty lines and [DONE]", async () => { - const input = Buffer.from( - '{"type":"start","data":{}}\n\ndata: [DONE]\n{"type":"finish","data":{}}\n', - ); - const stream = Readable.from([input]); - const parser = stream.pipe(new NDJSONParser()); - - const events: any[] = []; - for await (const event of parser) { - events.push(event); - } - - expect(events).toHaveLength(2); - expect(events[0].type).toBe("start"); - expect(events[1].type).toBe("finish"); - }); - - it("handles fragmented chunks (partial lines)", async () => { - const parser = new NDJSONParser(); - const events: any[] = []; - parser.on("data", (event: any) => events.push(event)); - - // Push partial line, then the rest - parser.write(Buffer.from('{"type":"text-delta","data":{"tex')); - parser.write(Buffer.from('t":"World"}}\n')); - parser.end(); - - await new Promise((resolve) => parser.on("end", resolve)); - - expect(events).toHaveLength(1); - expect(events[0].type).toBe("text-delta"); - expect(events[0].data.text).toBe("World"); - }); - - it("handles multiple events in a single chunk", async () => { - const input = Buffer.from( - '{"type":"start","data":{}}\n{"type":"text-delta","data":{"text":"A"}}\n{"type":"finish","data":{}}\n', - ); - const stream = Readable.from([input]); - const parser = stream.pipe(new NDJSONParser()); - - const events: any[] = []; - for await (const event of parser) { - events.push(event); - } - - expect(events).toHaveLength(3); - }); - - it("flushes remaining buffer on end", async () => { - const parser = new NDJSONParser(); - const events: any[] = []; - parser.on("data", (event: any) => events.push(event)); - - // Push a line without newline — should be flushed on end - parser.write(Buffer.from('{"type":"text-delta","data":{"text":"flushed"}}')); - parser.end(); - - await new Promise((resolve) => parser.on("end", resolve)); - - expect(events).toHaveLength(1); - expect(events[0].data.text).toBe("flushed"); - }); -}); - describe("formatAnthropicSSE", () => { it("emits event + data lines", () => { const result = formatAnthropicSSE("message_start", { diff --git a/tests/translate-anthropic.test.ts b/tests/translate-anthropic.test.ts index c889b4f..7265076 100644 --- a/tests/translate-anthropic.test.ts +++ b/tests/translate-anthropic.test.ts @@ -85,37 +85,34 @@ describe("toCCRequest", () => { expect(part.toolName).toBe("weather"); }); - it("thinking maps to reasoning_effort", () => { - const req: AnthropicRequest = { + // claude-* resolves to the default model (deepseek-v4-pro), whose CC effort + // set is {high, max}. So low/medium budgets clip up to "high", and very large + // budgets reach "max". See resolveEffortForModel. + it("thinking budget maps to a valid effort for the resolved model (clipped)", () => { + const base = (budget_tokens: number): AnthropicRequest => ({ model: "claude-sonnet-4-5-20250929", - max_tokens: 4000, - thinking: { type: "enabled", budget_tokens: 3000 }, + max_tokens: 64000, + thinking: { type: "enabled", budget_tokens }, 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"); + }); + expect(toCCRequest(base(1500)).params.reasoning_effort).toBe("high"); // low → high + expect(toCCRequest(base(3000)).params.reasoning_effort).toBe("high"); // medium → high + expect(toCCRequest(base(10000)).params.reasoning_effort).toBe("high"); // high → high + expect(toCCRequest(base(24000)).params.reasoning_effort).toBe("high"); // xhigh → high (not in set) + expect(toCCRequest(base(40000)).params.reasoning_effort).toBe("max"); // max → max }); - 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 }, + it("thinking budget clips to the model's own effort set (grok-4.5: low/med/high)", () => { + const base = (budget_tokens: number): AnthropicRequest => ({ + model: "grok-4.5", + max_tokens: 64000, + thinking: { type: "enabled", budget_tokens }, messages: [{ role: "user", content: "Hi" }], - }; - const result = toCCRequest(req); - expect(result.params.reasoning_effort).toBe("high"); + }); + expect(toCCRequest(base(1500)).params.reasoning_effort).toBe("low"); + expect(toCCRequest(base(3000)).params.reasoning_effort).toBe("medium"); + expect(toCCRequest(base(10000)).params.reasoning_effort).toBe("high"); + expect(toCCRequest(base(40000)).params.reasoning_effort).toBe("high"); // max → high (grok cap) }); it("thinking not set = no reasoning_effort", () => { @@ -194,7 +191,7 @@ describe("toCCRequest", () => { expect(parts[0].text).toBe("The answer is 42"); }); - it("tool_choice mapping", () => { + it("tool_choice mapping (CC object form)", () => { const base: AnthropicRequest = { model: "claude-sonnet-4-5-20250929", max_tokens: 100, @@ -202,16 +199,21 @@ describe("toCCRequest", () => { tools: [{ name: "calc", input_schema: {} }], }; + // CC's bridge requires an OBJECT; auto/none have no CC equivalent and are omitted. 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: "none" } }).params.tool_choice, + ).toBeUndefined(); + // Anthropic "any" → CC {type:"any"} (CC has no "required"). + expect(toCCRequest({ ...base, tool_choice: { type: "any" } }).params.tool_choice).toEqual({ + type: "any", + }); + // Anthropic "tool" → CC {type:"tool", name}. 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"); + ).toEqual({ type: "tool", name: "calc" }); }); it("no-tools safeguard not injected for Anthropic", () => { @@ -280,6 +282,28 @@ describe("AnthropicStreamEncoder", () => { } }); + it("message_delta carries input_tokens/cache_read from finish usage (start carries none)", () => { + const encoder = new AnthropicStreamEncoder("m"); + encoder.emit({ type: "start", data: {} }); // empty start, like real CC + encoder.emit({ type: "text-delta", data: { text: "hi" } }); + const finish = encoder.emit({ + type: "finish", + data: { + finishReason: "stop", + totalUsage: { inputTokens: 7856, outputTokens: 67, cachedInputTokens: 7424 }, + }, + }); + const md = finish.find((c) => c.event === "message_delta"); + const usage = md!.data.usage as { + input_tokens: number; + output_tokens: number; + cache_read_input_tokens?: number; + }; + expect(usage.input_tokens).toBe(7856); + expect(usage.output_tokens).toBe(67); + expect(usage.cache_read_input_tokens).toBe(7424); + }); + it("thinking block emits signature_delta on close", () => { const encoder = new AnthropicStreamEncoder("m"); encoder.emit({ type: "start", data: {} }); diff --git a/tests/translate.test.ts b/tests/translate.test.ts index 6120440..2296f0b 100644 --- a/tests/translate.test.ts +++ b/tests/translate.test.ts @@ -32,6 +32,17 @@ describe("resolveModel", () => { expect(resolveModel("anthropic/claude-sonnet-4")).toBe("anthropic/claude-sonnet-4"); expect(resolveModel("custom/model-name")).toBe("custom/model-name"); }); + + it("resolves bare last-segment names case-insensitively (OpenCode sends these)", () => { + // OpenCode's provider config keys models by the bare last path segment with + // original casing. The proxy must still map them to the full canonical ID. + expect(resolveModel("GLM-5.2")).toBe("zai-org/GLM-5.2"); + expect(resolveModel("MiniMax-M3")).toBe("MiniMaxAI/MiniMax-M3"); + expect(resolveModel("Kimi-K3")).toBe("moonshotai/Kimi-K3"); + expect(resolveModel("Kimi-K2.7-Code-Highspeed")).toBe("moonshotai/Kimi-K2.7-Code-Highspeed"); + // Bare name with no short alias still resolves via catalog last-segment. + expect(resolveModel("nemotron-3-ultra-550b-a55b")).toBe("nvidia/nemotron-3-ultra-550b-a55b"); + }); }); // ────────────────────────────────────────── @@ -214,6 +225,44 @@ describe("toCCRequest", () => { expect(toolResultIds).not.toContain("call_missing"); }); + + it("tool_choice maps to CC object form (no bare strings)", () => { + const base: OpenAIChatRequest = { + model: "default", + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "calc", parameters: {} } }], + }; + // auto/none → omitted (CC has no "none"; default is auto) + expect(toCCRequest({ ...base, tool_choice: "auto" }).params.tool_choice).toBeUndefined(); + expect(toCCRequest({ ...base, tool_choice: "none" }).params.tool_choice).toBeUndefined(); + // required → {type:"any"} (CC has no "required") + expect(toCCRequest({ ...base, tool_choice: "required" }).params.tool_choice).toEqual({ + type: "any", + }); + // specific function → {type:"tool", name} + expect( + toCCRequest({ + ...base, + tool_choice: { type: "function", function: { name: "calc" } }, + }).params.tool_choice, + ).toEqual({ type: "tool", name: "calc" }); + }); + + it("reasoning_effort passes through xhigh/max and clips per model", () => { + const withModel = (model: string, effort: string): OpenAIChatRequest => ({ + model, + messages: [{ role: "user", content: "hi" }], + reasoning_effort: effort as OpenAIChatRequest["reasoning_effort"], + }); + // deepseek-v4-pro accepts {high, max}: max passes, low clips up to high. + expect(toCCRequest(withModel("deepseek-v4-pro", "max")).params.reasoning_effort).toBe("max"); + expect(toCCRequest(withModel("deepseek-v4-pro", "low")).params.reasoning_effort).toBe("high"); + expect(toCCRequest(withModel("deepseek-v4-pro", "xhigh")).params.reasoning_effort).toBe("high"); + // grok-4.5 accepts {low, medium, high}: xhigh clips down to high. + expect(toCCRequest(withModel("grok-4.5", "xhigh")).params.reasoning_effort).toBe("high"); + // Non-effort model (not catalogued) → pass through unchanged. + expect(toCCRequest(withModel("Qwen/Qwen3.7-Max", "high")).params.reasoning_effort).toBe("high"); + }); }); // ────────────────────────────────────────── diff --git a/tests/upstream.test.ts b/tests/upstream.test.ts index df5660d..e2e02fc 100644 --- a/tests/upstream.test.ts +++ b/tests/upstream.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { Readable } from "node:stream"; -import { buildHeaders, collectEvents } from "@/upstream.js"; +import { buildHeaders, collectEvents, sendToCC, UpstreamError } from "@/upstream.js"; import type { CCEvent, CCRequestBody } from "@/translate/types.js"; const sampleBody = (overrides: Partial = {}): CCRequestBody => ({ @@ -85,3 +85,83 @@ describe("collectEvents", () => { await expect(collectEvents(stream)).rejects.toThrow("boom"); }); }); + +// Minimal fake Response for sendToCC retry tests. +function fakeResponse(opts: { + ok: boolean; + status: number; + text?: string; + bodyLines?: string[]; +}): Response { + const enc = new TextEncoder(); + const lines = opts.bodyLines ?? []; + let i = 0; + const reader = { + read: async () => + i < lines.length + ? { done: false, value: enc.encode(lines[i++]) } + : { done: true, value: undefined }, + }; + return { + ok: opts.ok, + status: opts.status, + statusText: "", + text: async () => opts.text ?? "", + body: { getReader: () => reader }, + } as unknown as Response; +} + +describe("sendToCC retry", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("retries on 5xx then succeeds", async () => { + const mock = vi.fn(); + mock.mockResolvedValueOnce(fakeResponse({ ok: false, status: 503, text: "down" })); + mock.mockResolvedValueOnce( + fakeResponse({ + ok: true, + status: 200, + bodyLines: [ + '{"type":"start","data":{}}\n', + '{"type":"finish","data":{"finishReason":"stop","totalUsage":{"inputTokens":5,"outputTokens":2}}}\n', + ], + }), + ); + globalThis.fetch = mock as unknown as typeof fetch; + + const { stream } = await sendToCC(sampleBody(), { + apiBase: "https://example.test", + apiKey: "k", + ccVersion: "0.0.0", + }); + expect(mock).toHaveBeenCalledTimes(2); + const events = await collectEvents(stream); + expect(events.some((e) => e.type === "finish")).toBe(true); + }); + + it("does not retry on non-retryable 4xx", async () => { + const mock = vi.fn().mockResolvedValue(fakeResponse({ ok: false, status: 400, text: "bad" })); + globalThis.fetch = mock as unknown as typeof fetch; + + await expect( + sendToCC(sampleBody(), { apiBase: "https://example.test", apiKey: "k", ccVersion: "0.0.0" }), + ).rejects.toMatchObject({ statusCode: 400 }); + expect(mock).toHaveBeenCalledTimes(1); + }); + + it("gives up after exhausting retries on persistent 5xx", async () => { + const mock = vi + .fn() + .mockResolvedValue(fakeResponse({ ok: false, status: 502, text: "bad gw" })); + globalThis.fetch = mock as unknown as typeof fetch; + + await expect( + sendToCC(sampleBody(), { apiBase: "https://example.test", apiKey: "k", ccVersion: "0.0.0" }), + ).rejects.toBeInstanceOf(UpstreamError); + // 1 initial + 2 retries = 3 attempts. + expect(mock).toHaveBeenCalledTimes(3); + }); +});