diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e8ba15..79a2d9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,57 +2,57 @@ name: CI on: push: - branches: [ main, master ] + branches: [main, master] pull_request: - branches: [ main, master ] + branches: [main, master] jobs: test: runs-on: ubuntu-latest - + steps: - - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Cache dependencies - uses: actions/cache@v4 - with: - path: | - ~/.bun/install/cache - node_modules - key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }} - restore-keys: | - ${{ runner.os }}-bun- - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Run tests - run: bun test - - - name: Type check - run: bun run typecheck - - - name: Build - run: bun run build - - - name: Verify build output - run: | - test -f dist/main.js - test -f dist/main.cjs - test -x dist/main.js - head -n 1 dist/main.js | grep -q "#!/usr/bin/env node" - - - name: Install Claude Code CLI - run: npm install -g @anthropic-ai/claude-code - - - name: Test with Node.js - run: | - # Test that the build works with --help - node dist/main.js --help - # Verify it shows Claude Code help output - node dist/main.js --help | grep -q "Usage: claude" \ No newline at end of file + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + ~/.bun/install/cache + node_modules + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run tests + run: bun test + + - name: Type check + run: bun run typecheck + + - name: Build + run: bun run build + + - name: Verify build output + run: | + test -f dist/main.js + test -f dist/main.cjs + test -x dist/main.js + head -n 1 dist/main.js | grep -q "#!/usr/bin/env node" + + - name: Install Claude Code CLI + run: npm install -g @anthropic-ai/claude-code + + - name: Test with Node.js + run: | + # Test that the build works with --help + node dist/main.js --help + # Verify it shows Claude Code help output + node dist/main.js --help | grep -q "Usage: claude" diff --git a/AGENTS.md b/AGENTS.md index 57a9438..12d0c3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ # Repository Guidelines ## Project Structure & Module Organization + - `src/`: TypeScript sources. Key files: `main.ts` (CLI entry), `anthropic-proxy.ts` (HTTP proxy), `convert-*.ts` (format converters), `detect-mimetype.ts`, `json-schema.ts`. - `dist/`: Bundled CLI entry `main.js` (created by build). - `package.json`, `bun.lock`: Bun-based build and deps; `bin.anyclaude` points to `dist/main.js`. @@ -8,6 +9,7 @@ - Assets/config: `README.md`, `CLAUDE.md`, `tsconfig.json`, `demo.png`. ## Build, Test, and Development Commands + - Install: `bun install`. - Build: `bun run build` (outputs `dist/main.js` with Node shebang). - Run CLI (after build): `./dist/main.js --model openai/gpt-5-mini`. @@ -17,6 +19,7 @@ - Nix shell: `direnv allow` (or `nix develop`); format Nix/shell files with `nix fmt`. ## Coding Style & Naming Conventions + - Language: TypeScript (ESNext, strict mode enabled). - Indentation: 2 spaces; keep lines reasonable; use explicit imports (`verbatimModuleSyntax`). - Files: kebab-case `.ts` (e.g., `convert-to-anthropic-stream.ts`). @@ -24,16 +27,19 @@ - Exports: prefer named exports; keep modules single‑purpose and small. ## Testing Guidelines + - No test runner is configured yet. If adding tests: - Place under `src/**/*.test.ts` or `src/__tests__/`. - Prioritize pure units (converters, schema, MIME detection). Avoid live provider calls by default; gate with env vars. - Add a `test` script in `package.json` and document how to run it in `README.md`. ## Commit & Pull Request Guidelines + - Commits: imperative, concise subjects (e.g., "Add Nix development environment and Claude guidance file"). Include rationale in the body when helpful. - PRs: clear description, linked issues, commands used to verify (with relevant env vars), and expected behavior. Avoid committing secrets; scrub logs. - Keep scope small; update `README.md`/`CLAUDE.md` when behavior or env vars change. ## Security & Configuration Tips + - Never commit API keys. Use `direnv` for local secrets and keep `.envrc` minimal. - Provider envs: `OPENAI_*`, `GOOGLE_*`, `XAI_*`, `AZURE_*`, optional `ANTHROPIC_*`. Use `PROXY_ONLY=true` to inspect the proxy without launching Claude. diff --git a/README.md b/README.md index 0e83cbb..1e6bbc2 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Use Claude Code with OpenAI, Google, xAI, and other providers. - Extremely simple setup - just a basic command wrapper - Uses the AI SDK for simple support of new providers - Works with Claude Code GitHub Actions +- Optimized for OpenAI's gpt-5 series @@ -51,14 +52,10 @@ See [the providers](./src/main.ts#L17) for the implementation. Set a custom OpenAI endpoint with `OPENAI_API_URL` to use OpenRouter +`ANTHROPIC_MODEL` and `ANTHROPIC_SMALL_MODEL` are supported with the `/` syntax. + ### How does this work? Claude Code has added support for customizing the Anthropic endpoint with `ANTHROPIC_BASE_URL`. anyclaude spawns a simple HTTP server that translates between Anthropic's format and the [AI SDK](https://github.com/vercel/ai) format, enabling support for any [AI SDK](https://github.com/vercel/ai) provider (e.g., Google, OpenAI, etc.) - -## Do other models work better in Claude Code? - -Not really, but it's fun to experiment with them. - -`ANTHROPIC_MODEL` and `ANTHROPIC_SMALL_MODEL` are supported with the `/` syntax. diff --git a/bun.lock b/bun.lock index 4502346..aa817c9 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "@types/json-schema": "^7.0.15", "@types/yargs-parser": "^21.0.3", "ai": "^5.0.8", + "prettier": "^3.6.2", "zod": "3.25.76", }, "peerDependencies": { @@ -67,6 +68,8 @@ "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], + "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], diff --git a/package.json b/package.json index ef96301..494dab0 100644 --- a/package.json +++ b/package.json @@ -18,15 +18,16 @@ "dist/main.cjs" ], "devDependencies": { - "@types/bun": "latest", - "@types/json-schema": "^7.0.15", - "@types/yargs-parser": "^21.0.3", "@ai-sdk/anthropic": "^2.0.1", "@ai-sdk/azure": "^2.0.6", "@ai-sdk/google": "^2.0.3", "@ai-sdk/openai": "^2.0.6", "@ai-sdk/xai": "^2.0.3", + "@types/bun": "latest", + "@types/json-schema": "^7.0.15", + "@types/yargs-parser": "^21.0.3", "ai": "^5.0.8", + "prettier": "^3.6.2", "zod": "3.25.76" }, "peerDependencies": { @@ -38,10 +39,20 @@ "build": "bun build --target node --format cjs --outfile dist/main.cjs ./src/main.ts && node -e \"const fs=require('fs');const f='dist/main.cjs';fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace(/import\\.meta\\.url/g,'__filename'))\" && printf '#!/usr/bin/env node\\nrequire(\"./main.cjs\");\\n' > dist/main.js && chmod +x dist/main.js", "test": "bun test", "typecheck": "tsc --noEmit", + "fmt": "prettier --write .", "install:global": "bun run build && npm pack --silent && npm install -g anyclaude-*.tgz" }, "dependencies": { "yargs-parser": "^22.0.0", "json-schema": "^0.4.0" + }, + "prettier": { + "printWidth": 80, + "singleQuote": false, + "semi": true, + "trailingComma": "es5", + "arrowParens": "always", + "bracketSpacing": true, + "endOfLine": "lf" } } diff --git a/src/anthropic-api-types.ts b/src/anthropic-api-types.ts index af56cc1..061b616 100644 --- a/src/anthropic-api-types.ts +++ b/src/anthropic-api-types.ts @@ -52,14 +52,14 @@ export interface AnthropicRedactedThinkingContent { type AnthropicContentSource = | { - type: "base64"; - media_type: string; - data: string; - } + type: "base64"; + media_type: string; + data: string; + } | { - type: "url"; - url: string; - }; + type: "url"; + url: string; + }; export interface AnthropicImageContent { type: "image"; @@ -91,25 +91,25 @@ export interface AnthropicToolResultContent { export type AnthropicTool = | { - name: string; - description: string | undefined; - input_schema: JSONSchema7; - } + name: string; + description: string | undefined; + input_schema: JSONSchema7; + } | { - name: string; - type: "computer_20250124" | "computer_20241022"; - display_width_px: number; - display_height_px: number; - display_number: number; - } + name: string; + type: "computer_20250124" | "computer_20241022"; + display_width_px: number; + display_height_px: number; + display_number: number; + } | { - name: string; - type: "text_editor_20250124" | "text_editor_20241022"; - } + name: string; + type: "text_editor_20250124" | "text_editor_20241022"; + } | { - name: string; - type: "bash_20250124" | "bash_20241022"; - }; + name: string; + type: "bash_20250124" | "bash_20241022"; + }; export type AnthropicToolChoice = | { type: "auto" | "any" } @@ -122,65 +122,70 @@ export type AnthropicStreamUsage = { export type AnthropicStreamChunk = | { - type: "message_start"; - message: AnthropicAssistantMessage & { - id: string; - model: string; - stop_reason: string | null; - stop_sequence: string | null; + type: "message_start"; + message: AnthropicAssistantMessage & { + id: string; + model: string; + stop_reason: string | null; + stop_sequence: string | null; + usage: AnthropicStreamUsage; + }; + } + | { + type: "content_block_start"; + index: number; + content_block: + | { + type: "text"; + text: string; + } + | { + type: "thinking"; + thinking: string; + signature?: string; + } + | { + type: "tool_use"; + id: string; + name: string; + input: any; + }; + } + | { + type: "content_block_delta"; + index: number; + delta: + | { + type: "text_delta"; + text: string; + } + | { + type: "input_json_delta"; + partial_json: string; + }; + } + | { + type: "content_block_stop"; + index: number; + } + | { + type: "message_delta"; + delta: { + stop_reason: string; + stop_sequence: string | null; + }; usage: AnthropicStreamUsage; - }; - } - | { - type: "content_block_start"; - index: number; - content_block: - | { - type: "text"; - text: string; } - | { - type: "tool_use"; - id: string; - name: string; - input: any; - }; - } | { - type: "content_block_delta"; - index: number; - delta: - | { - type: "text_delta"; - text: string; + type: "message_stop"; } - | { - type: "input_json_delta"; - partial_json: string; + | { + type: "error"; + error: { + type: "api_error"; + message: string; + }; }; - } - | { - type: "content_block_stop"; - index: number; - } - | { - type: "message_delta"; - delta: { - stop_reason: string; - stop_sequence: string | null; - }; - usage: AnthropicStreamUsage; - } - | { - type: "message_stop"; - } - | { - type: "error"; - error: { - type: "api_error"; - message: string; - }; - }; export type AnthropicMessagesRequest = { model: string; diff --git a/src/anthropic-proxy.ts b/src/anthropic-proxy.ts index 36b312a..2860120 100644 --- a/src/anthropic-proxy.ts +++ b/src/anthropic-proxy.ts @@ -11,14 +11,14 @@ import { import { convertToAnthropicStream } from "./convert-to-anthropic-stream"; import { convertToLanguageModelMessage } from "./convert-to-language-model-prompt"; import { providerizeSchema } from "./json-schema"; -import { - writeDebugToTempFile, - logDebugError, +import { + writeDebugToTempFile, + logDebugError, displayDebugStartup, isDebugEnabled, isVerboseDebugEnabled, queueErrorMessage, - debug + debug, } from "./debug"; export type CreateAnthropicProxyOptions = { @@ -26,6 +26,90 @@ export type CreateAnthropicProxyOptions = { port?: number; }; +/** + * Converts provider-specific errors to Anthropic-compatible error formats. + * This ensures Claude Code can properly handle and potentially retry errors. + * + * @see https://docs.anthropic.com/en/api/errors + * @see https://docs.anthropic.com/en/api/streaming#error-handling + */ +function convertProviderErrorToAnthropic( + chunk: any, + providerName: string, + model: string +): { converted: any; wasConverted: boolean; errorType: string } { + // Check if this is an OpenAI server error + const isOpenAIServerError = + providerName === "openai" && chunk.error?.code === "server_error"; + + // Check if this is an OpenAI rate limit error for context length + const isOpenAIRateLimitError = + providerName === "openai" && + chunk.error?.message?.error?.code === "rate_limit_exceeded" && + chunk.error?.message?.error?.type === "tokens"; + + if (isOpenAIServerError) { + debug( + 1, + `OpenAI server error detected for ${model}. Transforming to 429 rate limit error to trigger Claude Code's automatic retry...` + ); + + // Transform OpenAI server errors to 429 rate limit errors + // This triggers Claude Code's built-in retry mechanism + return { + converted: { + type: "error", + sequence_number: chunk.sequence_number, + error: { + type: "rate_limit_error", + code: "rate_limit_error", + message: + "OpenAI server temporarily unavailable. Please retry your request.", + param: null, + }, + }, + wasConverted: true, + errorType: "server_error", + }; + } + + if (isOpenAIRateLimitError) { + debug( + 1, + `OpenAI rate limit (context length) error detected for ${model}. Request too large.` + ); + + // Transform OpenAI context length errors to Anthropic's request_too_large format + // This properly signals to Claude Code that the request exceeds size limits and should NOT be retried + // According to Anthropic docs, request_too_large (413) is used when request exceeds maximum allowed bytes + return { + converted: { + type: "error", + error: { + type: "request_too_large", + message: `Request exceeds context length limit for ${model}: ${ + chunk.error?.message?.error?.message || "Context length exceeded" + }`, + }, + }, + wasConverted: true, + errorType: "rate_limit_context", + }; + } + + // No conversion needed - return original + debug( + 1, + `Streaming error chunk detected for ${providerName}/${model}:`, + chunk + ); + return { + converted: chunk, + wasConverted: false, + errorType: "other", + }; +} + // createAnthropicProxy creates a proxy server that accepts // Anthropic Message API requests and proxies them through // the appropriate provider - converting the results back @@ -36,7 +120,7 @@ export const createAnthropicProxy = ({ }: CreateAnthropicProxyOptions): string => { // Log debug status on startup displayDebugStartup(); - + const proxy = http .createServer((req, res) => { if (!req.url) { @@ -67,18 +151,18 @@ export const createAnthropicProxy = ({ }, (proxiedRes) => { const statusCode = proxiedRes.statusCode ?? 500; - + // Collect response data for debugging - proxiedRes.on('data', (chunk) => { + proxiedRes.on("data", (chunk) => { responseChunks.push(chunk); }); - proxiedRes.on('end', () => { + proxiedRes.on("end", () => { // Write debug info to temp file for 4xx errors (except 429) if (statusCode >= 400 && statusCode < 500 && statusCode !== 429) { - const requestBodyToLog = requestBody + const requestBodyToLog = requestBody ? JSON.parse(requestBody) - : chunks.length > 0 + : chunks.length > 0 ? (() => { try { return JSON.parse(Buffer.concat(chunks).toString()); @@ -120,11 +204,11 @@ export const createAnthropicProxy = ({ if (requestBody) { proxy.end(requestBody); } else { - req.on('data', (chunk) => { + req.on("data", (chunk) => { chunks.push(chunk); proxy.write(chunk); }); - req.on('end', () => { + req.on("end", () => { proxy.end(); }); } @@ -183,140 +267,227 @@ export const createAnthropicProxy = ({ system = body.system.map((s) => s.text).join("\n"); } - const tools = body.tools?.reduce((acc, tool) => { - acc[tool.name] = { - description: tool.name, - inputSchema: jsonSchema( - providerizeSchema(providerName, tool.input_schema) - ), - }; - return acc; - }, {} as Record); + const tools = body.tools?.reduce( + (acc, tool) => { + acc[tool.name] = { + description: tool.description || tool.name, + inputSchema: jsonSchema( + providerizeSchema(providerName, tool.input_schema) + ), + }; + return acc; + }, + {} as Record + ); - const stream = streamText({ - model: provider.languageModel(model), - system, - tools, - messages: coreMessages, - maxOutputTokens: body.max_tokens, - temperature: body.temperature, + let stream; + try { + stream = streamText({ + model: provider.languageModel(model), + system, + tools, + messages: coreMessages, + maxOutputTokens: body.max_tokens, + temperature: body.temperature, - onFinish: ({ response, usage, finishReason }) => { - // If the body is already being streamed, - // we don't need to do any conversion here. - if (body.stream) { - return; - } + onFinish: ({ response, usage, finishReason }) => { + // If the body is already being streamed, + // we don't need to do any conversion here. + if (body.stream) { + return; + } - // There should only be one message. - const message = response.messages[0]; - if (!message) { - throw new Error("No message found"); - } + // There should only be one message. + const message = response.messages[0]; + if (!message) { + throw new Error("No message found"); + } - const prompt = convertToAnthropicMessagesPrompt({ - prompt: [convertToLanguageModelMessage(message, {})], - sendReasoning: true, - warnings: [], - }); - const promptMessage = prompt.prompt.messages[0]; - if (!promptMessage) { - throw new Error("No prompt message found"); - } + const prompt = convertToAnthropicMessagesPrompt({ + prompt: [convertToLanguageModelMessage(message, {})], + sendReasoning: true, + warnings: [], + }); + const promptMessage = prompt.prompt.messages[0]; + if (!promptMessage) { + throw new Error("No prompt message found"); + } - res.writeHead(200, { "Content-Type": "application/json" }).end( + res.writeHead(200, { "Content-Type": "application/json" }).end( + JSON.stringify({ + id: "msg_" + Date.now(), + type: "message", + role: promptMessage.role, + content: promptMessage.content, + model: body.model, + stop_reason: mapAnthropicStopReason(finishReason), + stop_sequence: null, + usage: { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + }, + }) + ); + }, + onError: ({ error }) => { + let statusCode = 400; // Provider errors are returned as 400 + let transformedError = error; + + // Check if this is an OpenAI server error that we should transform + const isOpenAIServerError = + providerName === "openai" && + error && + typeof error === "object" && + "error" in error && + (error as any).error?.code === "server_error"; + + if (isOpenAIServerError) { + debug( + 1, + `OpenAI server error detected in onError for ${model}. Transforming to 429 to trigger retry...` + ); + // Transform to rate limit error to trigger retry + statusCode = 429; + transformedError = { + type: "error", + error: { + type: "rate_limit_error", + message: + "OpenAI server temporarily unavailable. Please retry your request.", + }, + }; + } else if ( + // Check if this is an OpenAI rate limit error (non-streaming) + providerName === "openai" && + error && + typeof error === "object" && + "error" in error && + (error as any).error?.code === "rate_limit_exceeded" + ) { + debug( + 1, + `OpenAI rate limit error detected in onError for ${model}. Transforming to 429 to trigger retry...` + ); + // Transform to rate limit error to trigger retry + statusCode = 429; + transformedError = { + type: "error", + error: { + type: "rate_limit_error", + message: + (error as any).error?.message || + "Rate limit exceeded. Please retry your request.", + }, + }; + } + + // Write comprehensive debug info to temp file + const debugFile = writeDebugToTempFile( + statusCode, + { + method: "POST", + url: req.url, + headers: req.headers, + body: body, + }, + { + statusCode, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: providerName, + model: model, + originalError: + error instanceof Error + ? { + message: error.message, + stack: error.stack, + name: error.name, + } + : error, + error: + transformedError instanceof Error + ? { + message: transformedError.message, + stack: transformedError.stack, + name: transformedError.name, + } + : transformedError, + wasTransformed: isOpenAIServerError, + _debugInfo: { + requestSize: JSON.stringify(body).length, + toolCount: body.tools?.length || 0, + systemPromptLength: + body.system?.reduce( + (acc, s) => acc + s.text.length, + 0 + ) || 0, + messageCount: body.messages.length, + }, + }), + } + ); + + if (debugFile) { + logDebugError("Provider", statusCode, debugFile, { + provider: providerName, + model, + }); + } + + res + .writeHead(statusCode, { + "Content-Type": "application/json", + }) + .end( + JSON.stringify({ + type: "error", + error: + transformedError instanceof Error + ? transformedError.message + : transformedError, + }) + ); + }, + }); + } catch (error) { + // Handle connection errors and other synchronous errors from streamText + debug(1, `Connection error for ${providerName}/${model}:`, error); + + // Return a 503 Service Unavailable to trigger Claude Code's retry + res.writeHead(503, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + type: "error", + error: { + type: "overloaded_error", + message: `Connection failed to ${providerName}. The service may be temporarily unavailable.`, + }, + }) + ); + return; + } + + if (!body.stream) { + try { + await stream.consumeStream(); + } catch (error) { + debug( + 1, + `Error consuming stream for ${providerName}/${model}:`, + error + ); + // Return a 503 to trigger retry + res.writeHead(503, { "Content-Type": "application/json" }); + res.end( JSON.stringify({ - id: "msg_" + Date.now(), - type: "message", - role: promptMessage.role, - content: promptMessage.content, - model: body.model, - stop_reason: mapAnthropicStopReason(finishReason), - stop_sequence: null, - usage: { - input_tokens: usage.inputTokens, - output_tokens: usage.outputTokens, + type: "error", + error: { + type: "overloaded_error", + message: `Failed to process response from ${providerName}. The service may be temporarily unavailable.`, }, }) ); - }, - onError: ({ error }) => { - let statusCode = 400; // Provider errors are returned as 400 - let transformedError = error; - - // Check if this is an OpenAI server error that we should transform - const isOpenAIServerError = providerName === 'openai' && - error && typeof error === 'object' && - 'error' in error && (error as any).error?.code === 'server_error'; - - if (isOpenAIServerError) { - debug(1, `OpenAI server error detected in onError for ${model}. Transforming to 429 to trigger retry...`); - // Transform to rate limit error to trigger retry - statusCode = 429; - transformedError = { - type: "error", - error: { - type: "rate_limit_error", - message: "OpenAI server temporarily unavailable. Please retry your request." - } - }; - } - - // Write comprehensive debug info to temp file - const debugFile = writeDebugToTempFile( - statusCode, - { - method: "POST", - url: req.url, - headers: req.headers, - body: body, - }, - { - statusCode, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - provider: providerName, - model: model, - originalError: error instanceof Error ? { - message: error.message, - stack: error.stack, - name: error.name, - } : error, - error: transformedError instanceof Error ? { - message: transformedError.message, - stack: transformedError.stack, - name: transformedError.name, - } : transformedError, - wasTransformed: isOpenAIServerError, - _debugInfo: { - requestSize: JSON.stringify(body).length, - toolCount: body.tools?.length || 0, - systemPromptLength: body.system?.reduce((acc, s) => acc + s.text.length, 0) || 0, - messageCount: body.messages.length - } - }), - } - ); - - if (debugFile) { - logDebugError("Provider", statusCode, debugFile, { provider: providerName, model }); - } - - res - .writeHead(statusCode, { - "Content-Type": "application/json", - }) - .end( - JSON.stringify({ - type: "error", - error: transformedError instanceof Error ? transformedError.message : transformedError, - }) - ); - }, - }); - - if (!body.stream) { - await stream.consumeStream(); + } return; } @@ -329,99 +500,133 @@ export const createAnthropicProxy = ({ const streamChunks: any[] = []; const startTime = Date.now(); - await convertToAnthropicStream(stream.fullStream).pipeTo( - new WritableStream({ - write(chunk) { - // Collect chunks for debug dump (only in verbose mode to save memory) - if (isVerboseDebugEnabled()) { - streamChunks.push({ - timestamp: Date.now() - startTime, - chunk: chunk - }); - } - - // Check for streaming errors and log them (but don't interrupt the stream) - if (chunk.type === "error") { - // Store original error for debugging - const originalError = { ...chunk }; - - // Check if this is an OpenAI server error (any sequence) - const isOpenAIServerError = providerName === 'openai' && - (chunk as any).error?.code === 'server_error'; - - if (isOpenAIServerError) { - debug(1, `OpenAI server error detected for ${model} at sequence ${(chunk as any).sequence_number}. This is a known transient issue with OpenAI.`); - debug(1, `Transforming to 429 rate limit error to trigger Claude Code's automatic retry...`); - - // Transform OpenAI server errors to 429 rate limit errors - // This should trigger Claude Code's built-in retry mechanism - chunk = { - type: "error", - sequence_number: (chunk as any).sequence_number, - error: { - type: "rate_limit_error" as any, - code: "rate_limit_error", - message: "OpenAI server temporarily unavailable. Please retry your request.", - param: null - } - } as any; - } else { - // Log other errors normally - debug(1, `Streaming error chunk detected for ${providerName}/${model} at ${Date.now() - startTime}ms:`, chunk); + try { + await convertToAnthropicStream(stream.fullStream).pipeTo( + new WritableStream({ + write(chunk) { + // Collect chunks for debug dump (only in verbose mode to save memory) + if (isVerboseDebugEnabled()) { + streamChunks.push({ + timestamp: Date.now() - startTime, + chunk: chunk, + }); } - - // Write comprehensive debug info including full stream dump - const debugFile = writeDebugToTempFile( - 400, // Streaming errors are sent as 400 - { - method: "POST", - url: req.url, - headers: req.headers, - body: body, - }, - { - statusCode: 400, - headers: { "Content-Type": "text/event-stream" }, - body: JSON.stringify({ - provider: providerName, - model: model, - streamingError: originalError, - transformedError: isOpenAIServerError ? chunk : null, - wasTransformed: isOpenAIServerError, - fullChunk: JSON.stringify(originalError), - streamDuration: Date.now() - startTime, - streamChunkCount: streamChunks.length, - allStreamChunks: streamChunks, - _debugInfo: { - requestSize: JSON.stringify(body).length, - toolCount: body.tools?.length || 0, - systemPromptLength: body.system?.reduce((acc, s) => acc + s.text.length, 0) || 0, - messageCount: body.messages.length - } - }), - } - ); - if (debugFile) { - logDebugError("Streaming", 400, debugFile, { provider: providerName, model }); - } else if (isDebugEnabled()) { - queueErrorMessage(`Failed to write debug file for streaming error`); + // Check for streaming errors and convert them to Anthropic format + if (chunk.type === "error") { + // Store original error for debugging + const originalError = { ...chunk }; + + // Convert provider-specific errors to Anthropic format + const errorConversion = convertProviderErrorToAnthropic( + chunk, + providerName, + model + ); + chunk = errorConversion.converted; + + // Write comprehensive debug info including full stream dump + const debugFile = writeDebugToTempFile( + 400, // Streaming errors are sent as 400 + { + method: "POST", + url: req.url, + headers: req.headers, + body: body, + }, + { + statusCode: 400, + headers: { "Content-Type": "text/event-stream" }, + body: JSON.stringify({ + provider: providerName, + model: model, + streamingError: originalError, + transformedError: errorConversion.wasConverted + ? chunk + : null, + wasTransformed: errorConversion.wasConverted, + errorType: errorConversion.errorType, + fullChunk: JSON.stringify(originalError), + streamDuration: Date.now() - startTime, + streamChunkCount: streamChunks.length, + allStreamChunks: streamChunks, + _debugInfo: { + requestSize: JSON.stringify(body).length, + toolCount: body.tools?.length || 0, + systemPromptLength: + body.system?.reduce( + (acc, s) => acc + s.text.length, + 0 + ) || 0, + messageCount: body.messages.length, + }, + }), + } + ); + + if (debugFile) { + logDebugError("Streaming", 400, debugFile, { + provider: providerName, + model, + }); + } else if (isDebugEnabled()) { + queueErrorMessage( + `Failed to write debug file for streaming error` + ); + } } - } - - // Write all chunks (including errors) to the stream - matching original behavior - res.write( - `event: ${chunk.type}\ndata: ${JSON.stringify(chunk)}\n\n` - ); - }, - close() { - if (streamChunks.length > 0) { - debug(2, `Stream completed for ${providerName}/${model}: ${streamChunks.length} chunks in ${Date.now() - startTime}ms`); - } - res.end(); - }, - }) - ); + + // Write all chunks (including errors) to the stream - matching original behavior + res.write( + `event: ${chunk.type}\ndata: ${JSON.stringify(chunk)}\n\n` + ); + }, + close() { + if (streamChunks.length > 0) { + debug( + 2, + `Stream completed for ${providerName}/${model}: ${ + streamChunks.length + } chunks in ${Date.now() - startTime}ms` + ); + } + res.end(); + }, + }) + ); + } catch (error) { + debug( + 1, + `Error in stream processing for ${providerName}/${model}:`, + error + ); + + // If we haven't started writing the response yet, send a proper error + if (!res.headersSent) { + res.writeHead(503, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + type: "error", + error: { + type: "overloaded_error", + message: `Stream processing failed for ${providerName}. The service may be temporarily unavailable.`, + }, + }) + ); + } else { + // If we've already started streaming, send an error event + res.write( + `event: error\ndata: ${JSON.stringify({ + type: "error", + error: { + type: "overloaded_error", + message: `Stream interrupted. The service may be temporarily unavailable.`, + }, + })}\n\n` + ); + res.end(); + } + } })().catch((err) => { res.writeHead(500, { "Content-Type": "application/json", diff --git a/src/convert-anthropic-messages.test.ts b/src/convert-anthropic-messages.test.ts index 47b98c1..ee2ff19 100644 --- a/src/convert-anthropic-messages.test.ts +++ b/src/convert-anthropic-messages.test.ts @@ -34,7 +34,7 @@ describe("convertToAnthropicMessagesPrompt", () => { // Should only have one tool_use in the output const assistantMessage = result.prompt.messages[0]; expect(assistantMessage?.role).toBe("assistant"); - + if (assistantMessage?.role === "assistant") { const toolUses = assistantMessage.content.filter( (c) => c.type === "tool_use" @@ -75,7 +75,7 @@ describe("convertToAnthropicMessagesPrompt", () => { const assistantMessage = result.prompt.messages[0]; expect(assistantMessage?.role).toBe("assistant"); - + if (assistantMessage?.role === "assistant") { const toolUses = assistantMessage.content.filter( (c) => c.type === "tool_use" @@ -125,7 +125,7 @@ describe("convertToAnthropicMessagesPrompt", () => { const assistantMessage = result.prompt.messages[0]; expect(assistantMessage?.role).toBe("assistant"); - + if (assistantMessage?.role === "assistant") { // Should have 2 text blocks and 1 tool_use (duplicate filtered) const textBlocks = assistantMessage.content.filter( @@ -134,7 +134,7 @@ describe("convertToAnthropicMessagesPrompt", () => { const toolUses = assistantMessage.content.filter( (c) => c.type === "tool_use" ); - + expect(textBlocks).toHaveLength(2); expect(toolUses).toHaveLength(1); expect(toolUses[0]?.id).toBe("call_abc"); @@ -163,7 +163,7 @@ describe("convertToAnthropicMessagesPrompt", () => { const assistantMessage = result.prompt.messages[0]; expect(assistantMessage?.role).toBe("assistant"); - + if (assistantMessage?.role === "assistant") { const toolUses = assistantMessage.content.filter( (c) => c.type === "tool_use" @@ -172,4 +172,4 @@ describe("convertToAnthropicMessagesPrompt", () => { } }); }); -}); \ No newline at end of file +}); diff --git a/src/convert-anthropic-messages.ts b/src/convert-anthropic-messages.ts index f0b69c8..7ef0461 100644 --- a/src/convert-anthropic-messages.ts +++ b/src/convert-anthropic-messages.ts @@ -15,7 +15,7 @@ import type { AnthropicToolResultContent, } from "./anthropic-api-types"; import type { ModelMessage, FilePart, TextPart, ToolCallPart } from "ai"; -import type { ReasoningUIPart } from 'ai'; +import type { ReasoningUIPart } from "ai"; export function convertToAnthropicMessagesPrompt({ prompt, @@ -85,7 +85,9 @@ export function convertToAnthropicMessagesPrompt({ const isLastPart = j === content.length - 1; const cacheControl = getCacheControl(part.providerOptions) ?? - (isLastPart ? getCacheControl(message.providerOptions) : undefined); + (isLastPart + ? getCacheControl(message.providerOptions) + : undefined); if (part.type === "text") { anthropicContent.push({ @@ -103,12 +105,13 @@ export function convertToAnthropicMessagesPrompt({ part.data instanceof URL ? { type: "url", url: part.data.toString() } : { - type: "base64", - media_type: "application/pdf", - data: typeof part.data === "string" - ? part.data - : convertUint8ArrayToBase64(part.data), - }, + type: "base64", + media_type: "application/pdf", + data: + typeof part.data === "string" + ? part.data + : convertUint8ArrayToBase64(part.data), + }, cache_control: cacheControl, }); } else if (mediaType?.startsWith("image/")) { @@ -118,12 +121,13 @@ export function convertToAnthropicMessagesPrompt({ part.data instanceof URL ? { type: "url", url: part.data.toString() } : { - type: "base64", - media_type: mediaType ?? "image/jpeg", - data: typeof part.data === "string" - ? part.data - : convertUint8ArrayToBase64(part.data), - }, + type: "base64", + media_type: mediaType ?? "image/jpeg", + data: + typeof part.data === "string" + ? part.data + : convertUint8ArrayToBase64(part.data), + }, cache_control: cacheControl, }); } else { @@ -141,7 +145,9 @@ export function convertToAnthropicMessagesPrompt({ const isLastPart = i === content.length - 1; const cacheControl = getCacheControl(part.providerOptions) ?? - (isLastPart ? getCacheControl(message.providerOptions) : undefined); + (isLastPart + ? getCacheControl(message.providerOptions) + : undefined); // Map LanguageModelV2ToolResultPart.output to Anthropic tool_result content let toolResultContent: AnthropicToolResultContent["content"]; @@ -167,14 +173,26 @@ export function convertToAnthropicMessagesPrompt({ case "content": toolResultContent = part.output.value.map((c) => c.type === "text" - ? { type: "text" as const, text: c.text, cache_control: undefined } - : c.mediaType === "application/pdf" - ? { type: "text" as const, text: "[document content omitted]", cache_control: undefined } - : { - type: "image" as const, - source: { type: "base64" as const, media_type: c.mediaType, data: c.data }, + ? { + type: "text" as const, + text: c.text, cache_control: undefined, } + : c.mediaType === "application/pdf" + ? { + type: "text" as const, + text: "[document content omitted]", + cache_control: undefined, + } + : { + type: "image" as const, + source: { + type: "base64" as const, + media_type: c.mediaType, + data: c.data, + }, + cache_control: undefined, + } ); isError = false; break; @@ -259,7 +277,7 @@ export function convertToAnthropicMessagesPrompt({ const existingToolCall = anthropicContent.find( (c) => c.type === "tool_use" && c.id === part.toolCallId ); - + // Skip duplicate tool calls (OpenAI doesn't allow duplicate IDs) if (!existingToolCall) { anthropicContent.push({ diff --git a/src/convert-to-anthropic-stream.ts b/src/convert-to-anthropic-stream.ts index 02a4c65..73e15cc 100644 --- a/src/convert-to-anthropic-stream.ts +++ b/src/convert-to-anthropic-stream.ts @@ -9,6 +9,7 @@ export function convertToAnthropicStream( stream: ReadableStream>> ): ReadableStream { let index = 0; // content block index within the current message + let reasoningBuffer = ""; // Buffer for accumulating reasoning text const transform = new TransformStream< TextStreamPart>, @@ -126,14 +127,38 @@ export function convertToAnthropicStream( }); break; } + case "reasoning-start": { + // Start a new thinking content block for OpenAI reasoning + controller.enqueue({ + type: "content_block_start", + index, + content_block: { type: "thinking" as any, thinking: "" }, + }); + reasoningBuffer = ""; // Clear the buffer + break; + } + case "reasoning-delta": { + // Accumulate reasoning text and send as delta + reasoningBuffer += chunk.text; + controller.enqueue({ + type: "content_block_delta", + index, + delta: { type: "text_delta", text: chunk.text }, + }); + break; + } + case "reasoning-end": { + // End the thinking content block + controller.enqueue({ type: "content_block_stop", index }); + index += 1; + reasoningBuffer = ""; // Clear the buffer + break; + } case "start": case "abort": case "raw": case "source": case "file": - case "reasoning-start": - case "reasoning-delta": - case "reasoning-end": // ignore for Anthropic stream mapping break; default: { diff --git a/src/convert-to-language-model-prompt.ts b/src/convert-to-language-model-prompt.ts index eaaa8eb..5b5a208 100644 --- a/src/convert-to-language-model-prompt.ts +++ b/src/convert-to-language-model-prompt.ts @@ -169,9 +169,7 @@ function convertPartToLanguageModelPart( string, { mimeType: string | undefined; data: Uint8Array } > -): - | LanguageModelV2TextPart - | LanguageModelV2FilePart { +): LanguageModelV2TextPart | LanguageModelV2FilePart { if (part.type === "text") { return { type: "text", diff --git a/src/debug.ts b/src/debug.ts index 1cce71f..02017dd 100644 --- a/src/debug.ts +++ b/src/debug.ts @@ -31,8 +31,13 @@ export function writeDebugToTempFile( ): string | null { // Log 4xx errors (except 429) when ANYCLAUDE_DEBUG is set const debugEnabled = process.env.ANYCLAUDE_DEBUG; - - if (!debugEnabled || statusCode === 429 || statusCode < 400 || statusCode >= 500) { + + if ( + !debugEnabled || + statusCode === 429 || + statusCode < 400 || + statusCode >= 500 + ) { return null; } @@ -54,13 +59,13 @@ export function writeDebugToTempFile( response: response || null, }; - fs.writeFileSync(filepath, JSON.stringify(debugData, null, 2), 'utf8'); - + fs.writeFileSync(filepath, JSON.stringify(debugData, null, 2), "utf8"); + // Also write a simpler error log file that's easier to tail - const errorLogPath = path.join(tmpDir, 'anyclaude-errors.log'); + const errorLogPath = path.join(tmpDir, "anyclaude-errors.log"); const errorMessage = `[${new Date().toISOString()}] HTTP ${statusCode} - Debug: ${filepath}\n`; - fs.appendFileSync(errorLogPath, errorMessage, 'utf8'); - + fs.appendFileSync(errorLogPath, errorMessage, "utf8"); + return filepath; } catch (error) { console.error("[ANYCLAUDE DEBUG] Failed to write debug file:", error); @@ -83,13 +88,13 @@ export function queueErrorMessage(message: string): void { function displayPendingErrors(): void { if (pendingErrorMessages.length > 0) { // Use stderr and add newlines to separate from Claude's output - process.stderr.write('\n\n═══════════════════════════════════════\n'); - process.stderr.write('ANYCLAUDE DEBUG - Errors detected:\n'); - process.stderr.write('═══════════════════════════════════════\n'); - pendingErrorMessages.forEach(msg => { - process.stderr.write(msg + '\n'); + process.stderr.write("\n\n═══════════════════════════════════════\n"); + process.stderr.write("ANYCLAUDE DEBUG - Errors detected:\n"); + process.stderr.write("═══════════════════════════════════════\n"); + pendingErrorMessages.forEach((msg) => { + process.stderr.write(msg + "\n"); }); - process.stderr.write('═══════════════════════════════════════\n\n'); + process.stderr.write("═══════════════════════════════════════\n\n"); pendingErrorMessages = []; } } @@ -104,7 +109,7 @@ export function logDebugError( context?: { provider?: string; model?: string } ): void { if (!debugFile) return; - + let message = `${type} error`; if (context?.provider && context?.model) { message += ` (${context.provider}/${context.model})`; @@ -112,7 +117,7 @@ export function logDebugError( message += ` ${statusCode}`; } message += ` - Debug info written to: ${debugFile}`; - + queueErrorMessage(message); } @@ -123,15 +128,15 @@ export function displayDebugStartup(): void { const level = getDebugLevel(); if (level > 0) { const tmpDir = os.tmpdir(); - const errorLogPath = path.join(tmpDir, 'anyclaude-errors.log'); - process.stderr.write('\n═══════════════════════════════════════\n'); + const errorLogPath = path.join(tmpDir, "anyclaude-errors.log"); + process.stderr.write("\n═══════════════════════════════════════\n"); process.stderr.write(`ANYCLAUDE DEBUG MODE ENABLED (Level ${level})\n`); process.stderr.write(`Error log: ${errorLogPath}\n`); process.stderr.write(`Debug files: ${tmpDir}/anyclaude-debug-*.json\n`); if (level >= 2) { - process.stderr.write('Verbose: Duplicate filtering details enabled\n'); + process.stderr.write("Verbose: Duplicate filtering details enabled\n"); } - process.stderr.write('═══════════════════════════════════════\n\n'); + process.stderr.write("═══════════════════════════════════════\n\n"); } } @@ -146,10 +151,10 @@ export function displayDebugStartup(): void { export function getDebugLevel(): number { const debugValue = process.env.ANYCLAUDE_DEBUG; if (!debugValue) return 0; - + const level = parseInt(debugValue, 10); if (isNaN(level)) return 1; // Default to level 1 for any non-numeric value - + return Math.max(0, Math.min(2, level)); // Clamp to 0-2 range } @@ -172,15 +177,16 @@ export function isVerboseDebugEnabled(): boolean { */ export function debug(level: 1 | 2, message: string, data?: any): void { if (getDebugLevel() >= level) { - const prefix = '[ANYCLAUDE DEBUG]'; + const prefix = "[ANYCLAUDE DEBUG]"; if (data !== undefined) { // For objects/errors, stringify with a length limit - const dataStr = typeof data === 'object' ? - JSON.stringify(data).substring(0, 200) : - String(data); + const dataStr = + typeof data === "object" + ? JSON.stringify(data).substring(0, 200) + : String(data); console.error(`${prefix} ${message}`, dataStr); } else { console.error(`${prefix} ${message}`); } } -} \ No newline at end of file +} diff --git a/src/invalid-data-content-error.ts b/src/invalid-data-content-error.ts index 5cc3f2e..1fa2c8b 100644 --- a/src/invalid-data-content-error.ts +++ b/src/invalid-data-content-error.ts @@ -1,4 +1,4 @@ -import { AISDKError } from 'ai'; +import { AISDKError } from "ai"; const name = "AI_InvalidDataContentError"; const marker = `vercel.ai.error.${name}`; diff --git a/src/json-schema.ts b/src/json-schema.ts index 0187a4c..b8a90ef 100644 --- a/src/json-schema.ts +++ b/src/json-schema.ts @@ -22,7 +22,10 @@ export function providerizeSchema( let processedProperty = property as JSONSchema7; // Remove uri format for OpenAI and Google - if ((provider === "openai" || provider === "google") && processedProperty.format === "uri") { + if ( + (provider === "openai" || provider === "google") && + processedProperty.format === "uri" + ) { processedProperty = { ...processedProperty }; delete processedProperty.format; } diff --git a/src/main.ts b/src/main.ts index 8082947..13d5b74 100644 --- a/src/main.ts +++ b/src/main.ts @@ -112,8 +112,23 @@ const providers: CreateAnthropicProxyOptions["providers"] = { delete body["max_tokens"]; if (typeof maxTokens !== "undefined") body.max_completion_tokens = maxTokens; - if (reasoningEffort) body.reasoning = { effort: reasoningEffort }; + + // Set up reasoning parameters for OpenAI + if (reasoningEffort) { + body.reasoning = { + effort: reasoningEffort, + summary: "auto", // Request reasoning summaries from OpenAI + }; + } else { + // Always request reasoning summaries for models that support it + body.reasoning = { summary: "auto" }; + } + + // Enable automatic truncation to prevent context length errors + body.parallel_tool_calls = true; + if (serviceTier) body.service_tier = serviceTier; + init.body = JSON.stringify(body); } return globalThis.fetch(url, init);