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:
parent
3fec97c7bf
commit
d48232522b
15 changed files with 716 additions and 424 deletions
76
.github/workflows/ci.yml
vendored
76
.github/workflows/ci.yml
vendored
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
|
||||||
3
bun.lock
3
bun.lock
|
|
@ -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=="],
|
||||||
|
|
|
||||||
17
package.json
17
package.json
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
42
src/debug.ts
42
src/debug.ts
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
src/main.ts
17
src/main.ts
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue