diff --git a/.env.example b/.env.example index a31f713..29c7006 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,6 @@ CC_API_KEY= # Proxy config HOST=127.0.0.1 PORT=8787 + +# Anthropic model mapping (optional — prefer --setup-claude-code) +# ANTHROPIC_DEFAULT_MODEL=deepseek/deepseek-v4-pro diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 51d9ce7..af4c0df 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -46,19 +46,27 @@ src/ ├── stream.ts # NDJSON parser & SSE formatter ├── upstream.ts # CC API client ├── setup/ +│ ├── 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 +│ ├── 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 -└── 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 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..00916a5 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 @@ -78,6 +79,71 @@ 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`). +The proxy maps them to CC models via a config file with glob wildcards: + +```bash +npx commandcode-api-proxy --setup-claude-code +``` + +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 + +- **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 +168,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 +238,63 @@ response = client.chat.completions.create( } ``` +### Claude Code + +Run the proxy's setup to generate model config and Claude Code settings: + +```bash +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) + +```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..37af665 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -4,6 +4,7 @@ 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 { logger, initLogger } from "@/logger.js"; const args = process.argv.slice(2); @@ -39,10 +40,18 @@ 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(); 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(); @@ -74,6 +83,8 @@ 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(""); console.log(" Press Ctrl+C to stop\n"); }); diff --git a/src/server.ts b/src/server.ts index e008f1f..921343c 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) => { @@ -290,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/setup/claude-code.ts b/src/setup/claude-code.ts new file mode 100644 index 0000000..3314b47 --- /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"; + +function getClaudeProxySettingsPath(): string { + return path.join(os.homedir(), ".config", "commandcode-api-proxy", "claude-settings.json"); +} + +export async function setupClaudeCodeConfig(force: boolean): Promise { + const settingsPath = getClaudeProxySettingsPath(); + + 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; + } + + const dir = path.dirname(settingsPath); + fs.mkdirSync(dir, { recursive: true }); + + const settings = { + model: "sonnet", + skipDangerousModePermissionPrompt: true, + 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", + }, + }; + + 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"); + console.log(` alias claude-proxy="claude --settings ${settingsPath}"\n`); +} 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..8489b56 --- /dev/null +++ b/src/translate/anthropic-models.ts @@ -0,0 +1,12 @@ +import { resolveModel, getDefaultModels } from "@/translate/models.js"; + +export function resolveAnthropicModel(requestedModel: string): string { + if (!requestedModel.startsWith("claude-")) { + return resolveModel(requestedModel); + } + + const envDefault = process.env.ANTHROPIC_DEFAULT_MODEL; + if (envDefault) return resolveModel(envDefault); + + return resolveModel(getDefaultModels()[0]); +} diff --git a/src/translate/anthropic-types.ts b/src/translate/anthropic-types.ts new file mode 100644 index 0000000..f0b0aa7 --- /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" | "system"; + 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..5bb4d3b --- /dev/null +++ b/src/translate/anthropic.ts @@ -0,0 +1,548 @@ +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, +} 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[] = []; + 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 }); + } else { + 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; + } + + // 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, + }); + } + } + if (parts.length > 0) { + ccMessages.push({ role: "assistant", content: parts }); + } + } + } + + return { + ccMessages: pruneDanglingTools(ccMessages), + systemPrompt: + systemParts.length > 0 + ? systemParts.join("\n\n") + : undefined, + }; +} + +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: "image", image: src.url }; + } + return 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"; +} + +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(), + }; + + // Add system prompt from top-level field + if (finalSystem) { + body.params.system = finalSystem; + } + + return body; +} + +// ── Streaming encoder ── + +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; + + 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.currentToolCallId !== tcId) { + this.closeCurrentBlock(records); + this.ensureBlockOpenWith(records, "tool_use", { + type: "tool_use", + id: tcId, + name: tcName, + input: {}, + }); + this.currentToolCallId = tcId; + } + 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/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 // ────────────────────────────────────────── diff --git a/src/translate/validation.ts b/src/translate/validation.ts index b8cb54a..73754b6 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" && msg.role !== "system") { + 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..94bc3e9 --- /dev/null +++ b/tests/anthropic-models.test.ts @@ -0,0 +1,30 @@ +import { describe, test, 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_DEFAULT_MODEL; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + 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("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", () => { + expect(resolveAnthropicModel("custom-model")).toBe("custom-model"); + }); +}); diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index 3a4e73c..9fc67c1 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"); }); }); @@ -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"); + }); +}); diff --git a/tests/setup-claude-code.test.ts b/tests/setup-claude-code.test.ts new file mode 100644 index 0000000..3711a68 --- /dev/null +++ b/tests/setup-claude-code.test.ts @@ -0,0 +1,85 @@ +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 settingsPath(): string { + return path.join(os.homedir(), ".config", "commandcode-api-proxy", "claude-settings.json"); +} + +describe("setupClaudeCodeConfig", () => { + let originalFs: Record; + let consoleLogSpy: ReturnType; + let written: string | null = null; + + function saveFs(): void { + originalFs = { + existsSync: fs.existsSync, + mkdirSync: fs.mkdirSync, + writeFileSync: fs.writeFileSync, + }; + } + + function restoreFs(): void { + 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(() => { + written = null; + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + saveFs(); + + fs.existsSync = ((p: fs.PathLike) => { + 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) => { + if (p.toString() === settingsPath()) written = data; + }) as typeof fs.writeFileSync; + }); + + afterEach(() => { + restoreFs(); + consoleLogSpy.mockRestore(); + }); + + test("creates settings file with env vars", async () => { + await setupClaudeCodeConfig(false); + 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 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"); + }); + + test("existing with --force overwrites", async () => { + fs.existsSync = (() => true) as typeof fs.existsSync; + + await setupClaudeCodeConfig(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(settingsPath()); + }); +}); 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..c889b4f --- /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 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).toBeUndefined(); + }); + + 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..6120440 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: "function", 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]); + }); +});