GPT-5 Improvements (#12)

- Fixed major bug where tool descriptions were never sent to the LLM (!!)
- A large variety of making server errors recoverable
- Thinking traces now appear for OpenAI models
This commit is contained in:
Ammar Bandukwala 2025-08-11 21:04:41 -05:00 • committed by GitHub
commit d48232522b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 716 additions and 424 deletions

View file

@ -2,57 +2,57 @@ name: CI
on: on:
push: push:
branches: [ main, master ] branches: [main, master]
pull_request: pull_request:
branches: [ main, master ] branches: [main, master]
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
with: with:
bun-version: latest bun-version: latest
- name: Cache dependencies - name: Cache dependencies
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
~/.bun/install/cache ~/.bun/install/cache
node_modules node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }} key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
restore-keys: | restore-keys: |
${{ runner.os }}-bun- ${{ runner.os }}-bun-
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Run tests - name: Run tests
run: bun test run: bun test
- name: Type check - name: Type check
run: bun run typecheck run: bun run typecheck
- name: Build - name: Build
run: bun run build run: bun run build
- name: Verify build output - name: Verify build output
run: | run: |
test -f dist/main.js test -f dist/main.js
test -f dist/main.cjs test -f dist/main.cjs
test -x dist/main.js test -x dist/main.js
head -n 1 dist/main.js | grep -q "#!/usr/bin/env node" head -n 1 dist/main.js | grep -q "#!/usr/bin/env node"
- name: Install Claude Code CLI - name: Install Claude Code CLI
run: npm install -g @anthropic-ai/claude-code run: npm install -g @anthropic-ai/claude-code
- name: Test with Node.js - name: Test with Node.js
run: | run: |
# Test that the build works with --help # Test that the build works with --help
node dist/main.js --help node dist/main.js --help
# Verify it shows Claude Code help output # Verify it shows Claude Code help output
node dist/main.js --help | grep -q "Usage: claude" node dist/main.js --help | grep -q "Usage: claude"

View file

@ -1,6 +1,7 @@
# Repository Guidelines # Repository Guidelines
## Project Structure & Module Organization ## 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`. - `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). - `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`. - `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`. - Assets/config: `README.md`, `CLAUDE.md`, `tsconfig.json`, `demo.png`.
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- Install: `bun install`. - Install: `bun install`.
- Build: `bun run build` (outputs `dist/main.js` with Node shebang). - Build: `bun run build` (outputs `dist/main.js` with Node shebang).
- Run CLI (after build): `./dist/main.js --model openai/gpt-5-mini`. - 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`. - Nix shell: `direnv allow` (or `nix develop`); format Nix/shell files with `nix fmt`.
## Coding Style & Naming Conventions ## Coding Style & Naming Conventions
- Language: TypeScript (ESNext, strict mode enabled). - Language: TypeScript (ESNext, strict mode enabled).
- Indentation: 2 spaces; keep lines reasonable; use explicit imports (`verbatimModuleSyntax`). - Indentation: 2 spaces; keep lines reasonable; use explicit imports (`verbatimModuleSyntax`).
- Files: kebab-case `.ts` (e.g., `convert-to-anthropic-stream.ts`). - 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. - Exports: prefer named exports; keep modules single‑purpose and small.
## Testing Guidelines ## Testing Guidelines
- No test runner is configured yet. If adding tests: - No test runner is configured yet. If adding tests:
- Place under `src/**/*.test.ts` or `src/__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. - 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`. - Add a `test` script in `package.json` and document how to run it in `README.md`.
## Commit & Pull Request Guidelines ## 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. - 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. - 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. - Keep scope small; update `README.md`/`CLAUDE.md` when behavior or env vars change.
## Security & Configuration Tips ## Security & Configuration Tips
- Never commit API keys. Use `direnv` for local secrets and keep `.envrc` minimal. - 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. - Provider envs: `OPENAI_*`, `GOOGLE_*`, `XAI_*`, `AZURE_*`, optional `ANTHROPIC_*`. Use `PROXY_ONLY=true` to inspect the proxy without launching Claude.

View file

@ -7,6 +7,7 @@ Use Claude Code with OpenAI, Google, xAI, and other providers.
- Extremely simple setup - just a basic command wrapper - Extremely simple setup - just a basic command wrapper
- Uses the AI SDK for simple support of new providers - Uses the AI SDK for simple support of new providers
- Works with Claude Code GitHub Actions - Works with Claude Code GitHub Actions
- Optimized for OpenAI's gpt-5 series
<img src="./demo.png" width="65%"> <img src="./demo.png" width="65%">
@ -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 Set a custom OpenAI endpoint with `OPENAI_API_URL` to use OpenRouter
`ANTHROPIC_MODEL` and `ANTHROPIC_SMALL_MODEL` are supported with the `<provider>/` syntax.
### How does this work? ### How does this work?
Claude Code has added support for customizing the Anthropic endpoint with `ANTHROPIC_BASE_URL`. 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.) 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 `<provider>/` syntax.

View file

@ -17,6 +17,7 @@
"@types/json-schema": "^7.0.15", "@types/json-schema": "^7.0.15",
"@types/yargs-parser": "^21.0.3", "@types/yargs-parser": "^21.0.3",
"ai": "^5.0.8", "ai": "^5.0.8",
"prettier": "^3.6.2",
"zod": "3.25.76", "zod": "3.25.76",
}, },
"peerDependencies": { "peerDependencies": {
@ -67,6 +68,8 @@
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], "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=="], "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=="], "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],

View file

@ -18,15 +18,16 @@
"dist/main.cjs" "dist/main.cjs"
], ],
"devDependencies": { "devDependencies": {
"@types/bun": "latest",
"@types/json-schema": "^7.0.15",
"@types/yargs-parser": "^21.0.3",
"@ai-sdk/anthropic": "^2.0.1", "@ai-sdk/anthropic": "^2.0.1",
"@ai-sdk/azure": "^2.0.6", "@ai-sdk/azure": "^2.0.6",
"@ai-sdk/google": "^2.0.3", "@ai-sdk/google": "^2.0.3",
"@ai-sdk/openai": "^2.0.6", "@ai-sdk/openai": "^2.0.6",
"@ai-sdk/xai": "^2.0.3", "@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", "ai": "^5.0.8",
"prettier": "^3.6.2",
"zod": "3.25.76" "zod": "3.25.76"
}, },
"peerDependencies": { "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", "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", "test": "bun test",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"fmt": "prettier --write .",
"install:global": "bun run build && npm pack --silent && npm install -g anyclaude-*.tgz" "install:global": "bun run build && npm pack --silent && npm install -g anyclaude-*.tgz"
}, },
"dependencies": { "dependencies": {
"yargs-parser": "^22.0.0", "yargs-parser": "^22.0.0",
"json-schema": "^0.4.0" "json-schema": "^0.4.0"
},
"prettier": {
"printWidth": 80,
"singleQuote": false,
"semi": true,
"trailingComma": "es5",
"arrowParens": "always",
"bracketSpacing": true,
"endOfLine": "lf"
} }
} }

View file

@ -52,14 +52,14 @@ export interface AnthropicRedactedThinkingContent {
type AnthropicContentSource = type AnthropicContentSource =
| { | {
type: "base64"; type: "base64";
media_type: string; media_type: string;
data: string; data: string;
} }
| { | {
type: "url"; type: "url";
url: string; url: string;
}; };
export interface AnthropicImageContent { export interface AnthropicImageContent {
type: "image"; type: "image";
@ -91,25 +91,25 @@ export interface AnthropicToolResultContent {
export type AnthropicTool = export type AnthropicTool =
| { | {
name: string; name: string;
description: string | undefined; description: string | undefined;
input_schema: JSONSchema7; input_schema: JSONSchema7;
} }
| { | {
name: string; name: string;
type: "computer_20250124" | "computer_20241022"; type: "computer_20250124" | "computer_20241022";
display_width_px: number; display_width_px: number;
display_height_px: number; display_height_px: number;
display_number: number; display_number: number;
} }
| { | {
name: string; name: string;
type: "text_editor_20250124" | "text_editor_20241022"; type: "text_editor_20250124" | "text_editor_20241022";
} }
| { | {
name: string; name: string;
type: "bash_20250124" | "bash_20241022"; type: "bash_20250124" | "bash_20241022";
}; };
export type AnthropicToolChoice = export type AnthropicToolChoice =
| { type: "auto" | "any" } | { type: "auto" | "any" }
@ -122,65 +122,70 @@ export type AnthropicStreamUsage = {
export type AnthropicStreamChunk = export type AnthropicStreamChunk =
| { | {
type: "message_start"; type: "message_start";
message: AnthropicAssistantMessage & { message: AnthropicAssistantMessage & {
id: string; id: string;
model: string; model: string;
stop_reason: string | null; stop_reason: string | null;
stop_sequence: 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; 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"; type: "message_stop";
index: number;
delta:
| {
type: "text_delta";
text: string;
} }
| { | {
type: "input_json_delta"; type: "error";
partial_json: string; 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 = { export type AnthropicMessagesRequest = {
model: string; model: string;

View file

@ -18,7 +18,7 @@ import {
isDebugEnabled, isDebugEnabled,
isVerboseDebugEnabled, isVerboseDebugEnabled,
queueErrorMessage, queueErrorMessage,
debug debug,
} from "./debug"; } from "./debug";
export type CreateAnthropicProxyOptions = { export type CreateAnthropicProxyOptions = {
@ -26,6 +26,90 @@ export type CreateAnthropicProxyOptions = {
port?: number; 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 // createAnthropicProxy creates a proxy server that accepts
// Anthropic Message API requests and proxies them through // Anthropic Message API requests and proxies them through
// the appropriate provider - converting the results back // the appropriate provider - converting the results back
@ -69,11 +153,11 @@ export const createAnthropicProxy = ({
const statusCode = proxiedRes.statusCode ?? 500; const statusCode = proxiedRes.statusCode ?? 500;
// Collect response data for debugging // Collect response data for debugging
proxiedRes.on('data', (chunk) => { proxiedRes.on("data", (chunk) => {
responseChunks.push(chunk); responseChunks.push(chunk);
}); });
proxiedRes.on('end', () => { proxiedRes.on("end", () => {
// Write debug info to temp file for 4xx errors (except 429) // Write debug info to temp file for 4xx errors (except 429)
if (statusCode >= 400 && statusCode < 500 && statusCode !== 429) { if (statusCode >= 400 && statusCode < 500 && statusCode !== 429) {
const requestBodyToLog = requestBody const requestBodyToLog = requestBody
@ -120,11 +204,11 @@ export const createAnthropicProxy = ({
if (requestBody) { if (requestBody) {
proxy.end(requestBody); proxy.end(requestBody);
} else { } else {
req.on('data', (chunk) => { req.on("data", (chunk) => {
chunks.push(chunk); chunks.push(chunk);
proxy.write(chunk); proxy.write(chunk);
}); });
req.on('end', () => { req.on("end", () => {
proxy.end(); proxy.end();
}); });
} }
@ -183,140 +267,227 @@ export const createAnthropicProxy = ({
system = body.system.map((s) => s.text).join("\n"); system = body.system.map((s) => s.text).join("\n");
} }
const tools = body.tools?.reduce((acc, tool) => { const tools = body.tools?.reduce(
acc[tool.name] = { (acc, tool) => {
description: tool.name, acc[tool.name] = {
inputSchema: jsonSchema( description: tool.description || tool.name,
providerizeSchema(providerName, tool.input_schema) inputSchema: jsonSchema(
), providerizeSchema(providerName, tool.input_schema)
}; ),
return acc; };
}, {} as Record<string, Tool>); return acc;
},
{} as Record<string, Tool>
);
const stream = streamText({ let stream;
model: provider.languageModel(model), try {
system, stream = streamText({
tools, model: provider.languageModel(model),
messages: coreMessages, system,
maxOutputTokens: body.max_tokens, tools,
temperature: body.temperature, messages: coreMessages,
maxOutputTokens: body.max_tokens,
temperature: body.temperature,
onFinish: ({ response, usage, finishReason }) => { onFinish: ({ response, usage, finishReason }) => {
// If the body is already being streamed, // If the body is already being streamed,
// we don't need to do any conversion here. // we don't need to do any conversion here.
if (body.stream) { if (body.stream) {
return; return;
} }
// There should only be one message. // There should only be one message.
const message = response.messages[0]; const message = response.messages[0];
if (!message) { if (!message) {
throw new Error("No message found"); throw new Error("No message found");
} }
const prompt = convertToAnthropicMessagesPrompt({ const prompt = convertToAnthropicMessagesPrompt({
prompt: [convertToLanguageModelMessage(message, {})], prompt: [convertToLanguageModelMessage(message, {})],
sendReasoning: true, sendReasoning: true,
warnings: [], warnings: [],
}); });
const promptMessage = prompt.prompt.messages[0]; const promptMessage = prompt.prompt.messages[0];
if (!promptMessage) { if (!promptMessage) {
throw new Error("No prompt message found"); 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({ JSON.stringify({
id: "msg_" + Date.now(), type: "error",
type: "message", error: {
role: promptMessage.role, type: "overloaded_error",
content: promptMessage.content, message: `Failed to process response from ${providerName}. The service may be temporarily unavailable.`,
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."
}
};
}
// 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; return;
} }
@ -329,99 +500,133 @@ export const createAnthropicProxy = ({
const streamChunks: any[] = []; const streamChunks: any[] = [];
const startTime = Date.now(); const startTime = Date.now();
await convertToAnthropicStream(stream.fullStream).pipeTo( try {
new WritableStream({ await convertToAnthropicStream(stream.fullStream).pipeTo(
write(chunk) { new WritableStream({
// Collect chunks for debug dump (only in verbose mode to save memory) write(chunk) {
if (isVerboseDebugEnabled()) { // Collect chunks for debug dump (only in verbose mode to save memory)
streamChunks.push({ if (isVerboseDebugEnabled()) {
timestamp: Date.now() - startTime, streamChunks.push({
chunk: chunk timestamp: Date.now() - startTime,
}); chunk: chunk,
} });
}
// Check for streaming errors and log them (but don't interrupt the stream) // Check for streaming errors and convert them to Anthropic format
if (chunk.type === "error") { if (chunk.type === "error") {
// Store original error for debugging // Store original error for debugging
const originalError = { ...chunk }; const originalError = { ...chunk };
// Check if this is an OpenAI server error (any sequence) // Convert provider-specific errors to Anthropic format
const isOpenAIServerError = providerName === 'openai' && const errorConversion = convertProviderErrorToAnthropic(
(chunk as any).error?.code === 'server_error'; chunk,
providerName,
model
);
chunk = errorConversion.converted;
if (isOpenAIServerError) { // Write comprehensive debug info including full stream dump
debug(1, `OpenAI server error detected for ${model} at sequence ${(chunk as any).sequence_number}. This is a known transient issue with OpenAI.`); const debugFile = writeDebugToTempFile(
debug(1, `Transforming to 429 rate limit error to trigger Claude Code's automatic retry...`); 400, // Streaming errors are sent as 400
{
// Transform OpenAI server errors to 429 rate limit errors method: "POST",
// This should trigger Claude Code's built-in retry mechanism url: req.url,
chunk = { headers: req.headers,
type: "error", body: body,
sequence_number: (chunk as any).sequence_number, },
error: { {
type: "rate_limit_error" as any, statusCode: 400,
code: "rate_limit_error", headers: { "Content-Type": "text/event-stream" },
message: "OpenAI server temporarily unavailable. Please retry your request.", body: JSON.stringify({
param: null 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,
},
}),
} }
} as any; );
} else {
// Log other errors normally
debug(1, `Streaming error chunk detected for ${providerName}/${model} at ${Date.now() - startTime}ms:`, chunk);
}
// Write comprehensive debug info including full stream dump if (debugFile) {
const debugFile = writeDebugToTempFile( logDebugError("Streaming", 400, debugFile, {
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, provider: providerName,
model: model, model,
streamingError: originalError, });
transformedError: isOpenAIServerError ? chunk : null, } else if (isDebugEnabled()) {
wasTransformed: isOpenAIServerError, queueErrorMessage(
fullChunk: JSON.stringify(originalError), `Failed to write debug file for streaming error`
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 // Write all chunks (including errors) to the stream - matching original behavior
res.write( res.write(
`event: ${chunk.type}\ndata: ${JSON.stringify(chunk)}\n\n` `event: ${chunk.type}\ndata: ${JSON.stringify(chunk)}\n\n`
); );
}, },
close() { close() {
if (streamChunks.length > 0) { if (streamChunks.length > 0) {
debug(2, `Stream completed for ${providerName}/${model}: ${streamChunks.length} chunks in ${Date.now() - startTime}ms`); debug(
} 2,
res.end(); `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) => { })().catch((err) => {
res.writeHead(500, { res.writeHead(500, {
"Content-Type": "application/json", "Content-Type": "application/json",

View file

@ -15,7 +15,7 @@ import type {
AnthropicToolResultContent, AnthropicToolResultContent,
} from "./anthropic-api-types"; } from "./anthropic-api-types";
import type { ModelMessage, FilePart, TextPart, ToolCallPart } from "ai"; import type { ModelMessage, FilePart, TextPart, ToolCallPart } from "ai";
import type { ReasoningUIPart } from 'ai'; import type { ReasoningUIPart } from "ai";
export function convertToAnthropicMessagesPrompt({ export function convertToAnthropicMessagesPrompt({
prompt, prompt,
@ -85,7 +85,9 @@ export function convertToAnthropicMessagesPrompt({
const isLastPart = j === content.length - 1; const isLastPart = j === content.length - 1;
const cacheControl = const cacheControl =
getCacheControl(part.providerOptions) ?? getCacheControl(part.providerOptions) ??
(isLastPart ? getCacheControl(message.providerOptions) : undefined); (isLastPart
? getCacheControl(message.providerOptions)
: undefined);
if (part.type === "text") { if (part.type === "text") {
anthropicContent.push({ anthropicContent.push({
@ -103,12 +105,13 @@ export function convertToAnthropicMessagesPrompt({
part.data instanceof URL part.data instanceof URL
? { type: "url", url: part.data.toString() } ? { type: "url", url: part.data.toString() }
: { : {
type: "base64", type: "base64",
media_type: "application/pdf", media_type: "application/pdf",
data: typeof part.data === "string" data:
? part.data typeof part.data === "string"
: convertUint8ArrayToBase64(part.data), ? part.data
}, : convertUint8ArrayToBase64(part.data),
},
cache_control: cacheControl, cache_control: cacheControl,
}); });
} else if (mediaType?.startsWith("image/")) { } else if (mediaType?.startsWith("image/")) {
@ -118,12 +121,13 @@ export function convertToAnthropicMessagesPrompt({
part.data instanceof URL part.data instanceof URL
? { type: "url", url: part.data.toString() } ? { type: "url", url: part.data.toString() }
: { : {
type: "base64", type: "base64",
media_type: mediaType ?? "image/jpeg", media_type: mediaType ?? "image/jpeg",
data: typeof part.data === "string" data:
? part.data typeof part.data === "string"
: convertUint8ArrayToBase64(part.data), ? part.data
}, : convertUint8ArrayToBase64(part.data),
},
cache_control: cacheControl, cache_control: cacheControl,
}); });
} else { } else {
@ -141,7 +145,9 @@ export function convertToAnthropicMessagesPrompt({
const isLastPart = i === content.length - 1; const isLastPart = i === content.length - 1;
const cacheControl = const cacheControl =
getCacheControl(part.providerOptions) ?? getCacheControl(part.providerOptions) ??
(isLastPart ? getCacheControl(message.providerOptions) : undefined); (isLastPart
? getCacheControl(message.providerOptions)
: undefined);
// Map LanguageModelV2ToolResultPart.output to Anthropic tool_result content // Map LanguageModelV2ToolResultPart.output to Anthropic tool_result content
let toolResultContent: AnthropicToolResultContent["content"]; let toolResultContent: AnthropicToolResultContent["content"];
@ -167,14 +173,26 @@ export function convertToAnthropicMessagesPrompt({
case "content": case "content":
toolResultContent = part.output.value.map((c) => toolResultContent = part.output.value.map((c) =>
c.type === "text" c.type === "text"
? { type: "text" as const, text: c.text, cache_control: undefined } ? {
: c.mediaType === "application/pdf" type: "text" as const,
? { type: "text" as const, text: "[document content omitted]", cache_control: undefined } text: c.text,
: {
type: "image" as const,
source: { type: "base64" as const, media_type: c.mediaType, data: c.data },
cache_control: undefined, 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; isError = false;
break; break;

View file

@ -9,6 +9,7 @@ export function convertToAnthropicStream(
stream: ReadableStream<TextStreamPart<Record<string, Tool>>> stream: ReadableStream<TextStreamPart<Record<string, Tool>>>
): ReadableStream<AnthropicStreamChunk> { ): ReadableStream<AnthropicStreamChunk> {
let index = 0; // content block index within the current message let index = 0; // content block index within the current message
let reasoningBuffer = ""; // Buffer for accumulating reasoning text
const transform = new TransformStream< const transform = new TransformStream<
TextStreamPart<Record<string, Tool>>, TextStreamPart<Record<string, Tool>>,
@ -126,14 +127,38 @@ export function convertToAnthropicStream(
}); });
break; 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 "start":
case "abort": case "abort":
case "raw": case "raw":
case "source": case "source":
case "file": case "file":
case "reasoning-start":
case "reasoning-delta":
case "reasoning-end":
// ignore for Anthropic stream mapping // ignore for Anthropic stream mapping
break; break;
default: { default: {

View file

@ -169,9 +169,7 @@ function convertPartToLanguageModelPart(
string, string,
{ mimeType: string | undefined; data: Uint8Array } { mimeType: string | undefined; data: Uint8Array }
> >
): ): LanguageModelV2TextPart | LanguageModelV2FilePart {
| LanguageModelV2TextPart
| LanguageModelV2FilePart {
if (part.type === "text") { if (part.type === "text") {
return { return {
type: "text", type: "text",

View file

@ -32,7 +32,12 @@ export function writeDebugToTempFile(
// Log 4xx errors (except 429) when ANYCLAUDE_DEBUG is set // Log 4xx errors (except 429) when ANYCLAUDE_DEBUG is set
const debugEnabled = process.env.ANYCLAUDE_DEBUG; const debugEnabled = process.env.ANYCLAUDE_DEBUG;
if (!debugEnabled || statusCode === 429 || statusCode < 400 || statusCode >= 500) { if (
!debugEnabled ||
statusCode === 429 ||
statusCode < 400 ||
statusCode >= 500
) {
return null; return null;
} }
@ -54,12 +59,12 @@ export function writeDebugToTempFile(
response: response || null, 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 // 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`; const errorMessage = `[${new Date().toISOString()}] HTTP ${statusCode} - Debug: ${filepath}\n`;
fs.appendFileSync(errorLogPath, errorMessage, 'utf8'); fs.appendFileSync(errorLogPath, errorMessage, "utf8");
return filepath; return filepath;
} catch (error) { } catch (error) {
@ -83,13 +88,13 @@ export function queueErrorMessage(message: string): void {
function displayPendingErrors(): void { function displayPendingErrors(): void {
if (pendingErrorMessages.length > 0) { if (pendingErrorMessages.length > 0) {
// Use stderr and add newlines to separate from Claude's output // Use stderr and add newlines to separate from Claude's output
process.stderr.write('\n\n═══════════════════════════════════════\n'); process.stderr.write("\n\n═══════════════════════════════════════\n");
process.stderr.write('ANYCLAUDE DEBUG - Errors detected:\n'); process.stderr.write("ANYCLAUDE DEBUG - Errors detected:\n");
process.stderr.write('═══════════════════════════════════════\n'); process.stderr.write("═══════════════════════════════════════\n");
pendingErrorMessages.forEach(msg => { pendingErrorMessages.forEach((msg) => {
process.stderr.write(msg + '\n'); process.stderr.write(msg + "\n");
}); });
process.stderr.write('═══════════════════════════════════════\n\n'); process.stderr.write("═══════════════════════════════════════\n\n");
pendingErrorMessages = []; pendingErrorMessages = [];
} }
} }
@ -123,15 +128,15 @@ export function displayDebugStartup(): void {
const level = getDebugLevel(); const level = getDebugLevel();
if (level > 0) { if (level > 0) {
const tmpDir = os.tmpdir(); const tmpDir = os.tmpdir();
const errorLogPath = path.join(tmpDir, 'anyclaude-errors.log'); const errorLogPath = path.join(tmpDir, "anyclaude-errors.log");
process.stderr.write('\n═══════════════════════════════════════\n'); process.stderr.write("\n═══════════════════════════════════════\n");
process.stderr.write(`ANYCLAUDE DEBUG MODE ENABLED (Level ${level})\n`); process.stderr.write(`ANYCLAUDE DEBUG MODE ENABLED (Level ${level})\n`);
process.stderr.write(`Error log: ${errorLogPath}\n`); process.stderr.write(`Error log: ${errorLogPath}\n`);
process.stderr.write(`Debug files: ${tmpDir}/anyclaude-debug-*.json\n`); process.stderr.write(`Debug files: ${tmpDir}/anyclaude-debug-*.json\n`);
if (level >= 2) { 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");
} }
} }
@ -172,12 +177,13 @@ export function isVerboseDebugEnabled(): boolean {
*/ */
export function debug(level: 1 | 2, message: string, data?: any): void { export function debug(level: 1 | 2, message: string, data?: any): void {
if (getDebugLevel() >= level) { if (getDebugLevel() >= level) {
const prefix = '[ANYCLAUDE DEBUG]'; const prefix = "[ANYCLAUDE DEBUG]";
if (data !== undefined) { if (data !== undefined) {
// For objects/errors, stringify with a length limit // For objects/errors, stringify with a length limit
const dataStr = typeof data === 'object' ? const dataStr =
JSON.stringify(data).substring(0, 200) : typeof data === "object"
String(data); ? JSON.stringify(data).substring(0, 200)
: String(data);
console.error(`${prefix} ${message}`, dataStr); console.error(`${prefix} ${message}`, dataStr);
} else { } else {
console.error(`${prefix} ${message}`); console.error(`${prefix} ${message}`);

View file

@ -1,4 +1,4 @@
import { AISDKError } from 'ai'; import { AISDKError } from "ai";
const name = "AI_InvalidDataContentError"; const name = "AI_InvalidDataContentError";
const marker = `vercel.ai.error.${name}`; const marker = `vercel.ai.error.${name}`;

View file

@ -22,7 +22,10 @@ export function providerizeSchema(
let processedProperty = property as JSONSchema7; let processedProperty = property as JSONSchema7;
// Remove uri format for OpenAI and Google // 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 }; processedProperty = { ...processedProperty };
delete processedProperty.format; delete processedProperty.format;
} }

View file

@ -112,8 +112,23 @@ const providers: CreateAnthropicProxyOptions["providers"] = {
delete body["max_tokens"]; delete body["max_tokens"];
if (typeof maxTokens !== "undefined") if (typeof maxTokens !== "undefined")
body.max_completion_tokens = maxTokens; 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; if (serviceTier) body.service_tier = serviceTier;
init.body = JSON.stringify(body); init.body = JSON.stringify(body);
} }
return globalThis.fetch(url, init); return globalThis.fetch(url, init);