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:
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"
- 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"

View file

@ -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 singlepurpose 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.

View file

@ -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
<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
`ANTHROPIC_MODEL` and `ANTHROPIC_SMALL_MODEL` are supported with the `<provider>/` 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 `<provider>/` syntax.

View file

@ -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=="],

View file

@ -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"
}
}

View file

@ -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;

View file

@ -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<string, Tool>);
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<string, Tool>
);
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",

View file

@ -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", () => {
}
});
});
});
});

View file

@ -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({

View file

@ -9,6 +9,7 @@ export function convertToAnthropicStream(
stream: ReadableStream<TextStreamPart<Record<string, Tool>>>
): ReadableStream<AnthropicStreamChunk> {
let index = 0; // content block index within the current message
let reasoningBuffer = ""; // Buffer for accumulating reasoning text
const transform = new TransformStream<
TextStreamPart<Record<string, Tool>>,
@ -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: {

View file

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

View file

@ -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}`);
}
}
}
}

View file

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

View file

@ -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;
}

View file

@ -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);