OpenAI GPT-5 improvements (#9)
A series of fixes for making GPT-5 reliable and effective with anyclaude. ## UX changes - Converts opaque OpenAI service errors into 429s so CC natively retries instead of failing. These are very common. - Rich debugging support via `ANYCLAUDE_DEBUG` - Supports specifying reasoning effort and service tier ## Codebase - Added CI ## Remaining issues - GPT-5 often fails to use the native tool calls. These failures seems intermittent.
This commit is contained in:
parent
43894b0ac9
commit
f42e03937e
11 changed files with 802 additions and 24 deletions
47
.github/workflows/ci.yml
vendored
Normal file
47
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
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 -x dist/main.js
|
||||
head -n 1 dist/main.js | grep -q "#!/usr/bin/env node"
|
||||
|
|
@ -34,6 +34,9 @@ bun install
|
|||
# Build the project (creates dist/main.js with shebang)
|
||||
bun run build
|
||||
|
||||
# Run the built binary
|
||||
bun run ./dist/main.js
|
||||
|
||||
# The build command:
|
||||
# 1. Compiles TypeScript to CommonJS for Node.js compatibility
|
||||
# 2. Adds Node shebang for CLI execution
|
||||
|
|
@ -64,3 +67,6 @@ Required for each provider:
|
|||
Special modes:
|
||||
|
||||
- `PROXY_ONLY=true`: Run proxy server without spawning Claude Code
|
||||
- `ANYCLAUDE_DEBUG=1|2`: Enable debug logging (1=basic, 2=verbose)
|
||||
|
||||
- OpenAI's gpt-5 was released in August 2025
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -23,6 +23,22 @@ $ anyclaude --model openai/gpt-5-mini
|
|||
|
||||
Switch models in the Claude UI with `/model openai/gpt-5-mini`.
|
||||
|
||||
### GPT-5 Support
|
||||
|
||||
Use --reasoning-effort (alias: -e) to control OpenAI reasoning.effort. Allowed values: minimal, low, medium, high.
|
||||
|
||||
```sh
|
||||
anyclaude --model openai/gpt-5-mini -e high
|
||||
```
|
||||
|
||||
Use --service-tier (alias: -t) to control OpenAI service tier. Allowed values: flex, priority.
|
||||
|
||||
```sh
|
||||
anyclaude --model openai/gpt-5-mini -t priority
|
||||
```
|
||||
|
||||
Note these flags may be extended to other providers in the future.
|
||||
|
||||
## FAQ
|
||||
|
||||
### What providers are supported?
|
||||
|
|
|
|||
8
bun.lock
8
bun.lock
|
|
@ -3,6 +3,10 @@
|
|||
"workspaces": {
|
||||
"": {
|
||||
"name": "openclaude",
|
||||
"dependencies": {
|
||||
"@types/yargs-parser": "^21.0.3",
|
||||
"yargs-parser": "^22.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.1",
|
||||
"@ai-sdk/azure": "^2.0.6",
|
||||
|
|
@ -49,6 +53,8 @@
|
|||
|
||||
"@types/node": ["@types/node@22.15.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ=="],
|
||||
|
||||
"@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
|
||||
|
||||
"ai": ["ai@5.0.8", "", { "dependencies": { "@ai-sdk/gateway": "1.0.4", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.1", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-qbnhj046UvG30V1S5WhjBn+RBGEAmi8PSZWqMhRsE3EPxvO5BcePXTZFA23e9MYyWS9zr4Vm8Mv3wQXwLmtIBw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-Kuh4Ub28ucMRWeiUUWMHsT9Wcbr4H3kLIO72RZZElSDxSu7vpetRvxIUDUaW6QtaIeixIpm7OXtNnZPf82EzwA=="],
|
||||
|
|
@ -61,6 +67,8 @@
|
|||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
|
||||
|
|
|
|||
|
|
@ -31,6 +31,12 @@
|
|||
"description": "Run Claude Code with OpenAI, Google, xAI, and others.",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "bun build --target node --outfile dist/main.js ./src/main.ts --format cjs --external ai --external @ai-sdk/* --external zod && node -e \"const fs=require('fs');const p='dist/main.js';fs.writeFileSync(p,'#!/usr/bin/env node\\n'+fs.readFileSync(p,'utf8'))\" && chmod +x dist/main.js"
|
||||
"build": "bun build --target node --outfile dist/main.js ./src/main.ts --format cjs --external ai --external @ai-sdk/* --external zod && node -e \"const fs=require('fs');const p='dist/main.js';fs.writeFileSync(p,'#!/usr/bin/env node\\n'+fs.readFileSync(p,'utf8'))\" && chmod +x dist/main.js",
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/yargs-parser": "^21.0.3",
|
||||
"yargs-parser": "^22.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,15 @@ import {
|
|||
import { convertToAnthropicStream } from "./convert-to-anthropic-stream";
|
||||
import { convertToLanguageModelMessage } from "./convert-to-language-model-prompt";
|
||||
import { providerizeSchema } from "./json-schema";
|
||||
import {
|
||||
writeDebugToTempFile,
|
||||
logDebugError,
|
||||
displayDebugStartup,
|
||||
isDebugEnabled,
|
||||
isVerboseDebugEnabled,
|
||||
queueErrorMessage,
|
||||
debug
|
||||
} from "./debug";
|
||||
|
||||
export type CreateAnthropicProxyOptions = {
|
||||
providers: Record<string, ProviderV2>;
|
||||
|
|
@ -25,6 +34,9 @@ export const createAnthropicProxy = ({
|
|||
port,
|
||||
providers,
|
||||
}: CreateAnthropicProxyOptions): string => {
|
||||
// Log debug status on startup
|
||||
displayDebugStartup();
|
||||
|
||||
const proxy = http
|
||||
.createServer((req, res) => {
|
||||
if (!req.url) {
|
||||
|
|
@ -42,6 +54,10 @@ export const createAnthropicProxy = ({
|
|||
const proxyToAnthropic = (body?: AnthropicMessagesRequest) => {
|
||||
delete req.headers["host"];
|
||||
|
||||
const requestBody = body ? JSON.stringify(body) : null;
|
||||
const chunks: Buffer[] = [];
|
||||
const responseChunks: Buffer[] = [];
|
||||
|
||||
const proxy = https.request(
|
||||
{
|
||||
host: "api.anthropic.com",
|
||||
|
|
@ -50,17 +66,66 @@ export const createAnthropicProxy = ({
|
|||
headers: req.headers,
|
||||
},
|
||||
(proxiedRes) => {
|
||||
res.writeHead(proxiedRes.statusCode ?? 500, proxiedRes.headers);
|
||||
const statusCode = proxiedRes.statusCode ?? 500;
|
||||
|
||||
// Collect response data for debugging
|
||||
proxiedRes.on('data', (chunk) => {
|
||||
responseChunks.push(chunk);
|
||||
});
|
||||
|
||||
proxiedRes.on('end', () => {
|
||||
// Write debug info to temp file for 4xx errors (except 429)
|
||||
if (statusCode >= 400 && statusCode < 500 && statusCode !== 429) {
|
||||
const requestBodyToLog = requestBody
|
||||
? JSON.parse(requestBody)
|
||||
: chunks.length > 0
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString());
|
||||
} catch {
|
||||
return Buffer.concat(chunks).toString();
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
|
||||
const responseBody = Buffer.concat(responseChunks).toString();
|
||||
const debugFile = writeDebugToTempFile(
|
||||
statusCode,
|
||||
{
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
headers: req.headers,
|
||||
body: requestBodyToLog,
|
||||
},
|
||||
{
|
||||
statusCode,
|
||||
headers: proxiedRes.headers,
|
||||
body: responseBody,
|
||||
}
|
||||
);
|
||||
|
||||
if (debugFile) {
|
||||
logDebugError("HTTP", statusCode, debugFile);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.writeHead(statusCode, proxiedRes.headers);
|
||||
proxiedRes.pipe(res, {
|
||||
end: true,
|
||||
});
|
||||
}
|
||||
);
|
||||
if (body) {
|
||||
proxy.end(JSON.stringify(body));
|
||||
|
||||
if (requestBody) {
|
||||
proxy.end(requestBody);
|
||||
} else {
|
||||
req.pipe(proxy, {
|
||||
end: true,
|
||||
req.on('data', (chunk) => {
|
||||
chunks.push(chunk);
|
||||
proxy.write(chunk);
|
||||
});
|
||||
req.on('end', () => {
|
||||
proxy.end();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -176,14 +241,75 @@ export const createAnthropicProxy = ({
|
|||
);
|
||||
},
|
||||
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(400, {
|
||||
.writeHead(statusCode, {
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
.end(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: error instanceof Error ? error.message : error,
|
||||
error: transformedError instanceof Error ? transformedError.message : transformedError,
|
||||
})
|
||||
);
|
||||
},
|
||||
|
|
@ -199,14 +325,99 @@ export const createAnthropicProxy = ({
|
|||
// We already send the error to the client.
|
||||
});
|
||||
|
||||
// Collect all stream chunks for debugging if enabled
|
||||
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);
|
||||
}
|
||||
|
||||
// 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`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
},
|
||||
})
|
||||
|
|
|
|||
175
src/convert-anthropic-messages.test.ts
Normal file
175
src/convert-anthropic-messages.test.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { describe, expect, it } from "bun:test";
|
||||
import { convertToAnthropicMessagesPrompt } from "./convert-anthropic-messages";
|
||||
import type { LanguageModelV2Prompt } from "@ai-sdk/provider";
|
||||
|
||||
describe("convertToAnthropicMessagesPrompt", () => {
|
||||
describe("duplicate tool call filtering", () => {
|
||||
it("should filter out duplicate tool calls with the same ID", () => {
|
||||
const prompt: LanguageModelV2Prompt = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_123",
|
||||
toolName: "TodoWrite",
|
||||
input: { todos: ["item1", "item2"] },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_123", // Duplicate ID
|
||||
toolName: "TodoWrite",
|
||||
input: {}, // Empty input
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertToAnthropicMessagesPrompt({
|
||||
prompt,
|
||||
sendReasoning: false,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
// 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"
|
||||
);
|
||||
expect(toolUses).toHaveLength(1);
|
||||
expect(toolUses[0]?.id).toBe("call_123");
|
||||
expect(toolUses[0]?.name).toBe("TodoWrite");
|
||||
expect(toolUses[0]?.input).toEqual({ todos: ["item1", "item2"] });
|
||||
}
|
||||
});
|
||||
|
||||
it("should keep tool calls with different IDs", () => {
|
||||
const prompt: LanguageModelV2Prompt = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_123",
|
||||
toolName: "TodoWrite",
|
||||
input: { todos: ["item1"] },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_456", // Different ID
|
||||
toolName: "Read",
|
||||
input: { file: "test.txt" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertToAnthropicMessagesPrompt({
|
||||
prompt,
|
||||
sendReasoning: false,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
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"
|
||||
);
|
||||
expect(toolUses).toHaveLength(2);
|
||||
expect(toolUses[0]?.id).toBe("call_123");
|
||||
expect(toolUses[0]?.name).toBe("TodoWrite");
|
||||
expect(toolUses[1]?.id).toBe("call_456");
|
||||
expect(toolUses[1]?.name).toBe("Read");
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle mixed content with duplicate tool calls", () => {
|
||||
const prompt: LanguageModelV2Prompt = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Let me help you with that.",
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_abc",
|
||||
toolName: "Search",
|
||||
input: { query: "test" },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Processing...",
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_abc", // Duplicate ID
|
||||
toolName: "Search",
|
||||
input: { query: "different" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertToAnthropicMessagesPrompt({
|
||||
prompt,
|
||||
sendReasoning: false,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
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(
|
||||
(c) => c.type === "text"
|
||||
);
|
||||
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");
|
||||
expect(toolUses[0]?.input).toEqual({ query: "test" }); // Should keep the first one
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle empty tool call arrays correctly", () => {
|
||||
const prompt: LanguageModelV2Prompt = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "No tools needed.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertToAnthropicMessagesPrompt({
|
||||
prompt,
|
||||
sendReasoning: false,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
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"
|
||||
);
|
||||
expect(toolUses).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -255,13 +255,21 @@ export function convertToAnthropicMessagesPrompt({
|
|||
}
|
||||
|
||||
case "tool-call": {
|
||||
anthropicContent.push({
|
||||
type: "tool_use",
|
||||
id: part.toolCallId,
|
||||
name: part.toolName,
|
||||
input: part.input,
|
||||
cache_control: cacheControl,
|
||||
});
|
||||
// Check if we already have a tool call with this ID
|
||||
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({
|
||||
type: "tool_use",
|
||||
id: part.toolCallId,
|
||||
name: part.toolName,
|
||||
input: part.input,
|
||||
cache_control: cacheControl,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
186
src/debug.ts
Normal file
186
src/debug.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
|
||||
// Store error messages to display later
|
||||
let pendingErrorMessages: string[] = [];
|
||||
|
||||
export interface DebugInfo {
|
||||
statusCode: number;
|
||||
request: {
|
||||
method?: string;
|
||||
url?: string;
|
||||
headers: any;
|
||||
body: any;
|
||||
};
|
||||
response?: {
|
||||
statusCode: number;
|
||||
headers: any;
|
||||
body: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Write debug info to a temp file when ANYCLAUDE_DEBUG is set
|
||||
* @returns The path to the debug file, or null if not written
|
||||
*/
|
||||
export function writeDebugToTempFile(
|
||||
statusCode: number,
|
||||
request: DebugInfo["request"],
|
||||
response?: DebugInfo["response"]
|
||||
): 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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const tmpDir = os.tmpdir();
|
||||
const timestamp = Date.now();
|
||||
const randomId = Math.random().toString(36).substring(2, 8);
|
||||
const filename = `anyclaude-debug-${timestamp}-${randomId}.json`;
|
||||
const filepath = path.join(tmpDir, filename);
|
||||
|
||||
const debugData = {
|
||||
timestamp: new Date().toISOString(),
|
||||
request: {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
},
|
||||
response: response || null,
|
||||
};
|
||||
|
||||
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 errorMessage = `[${new Date().toISOString()}] HTTP ${statusCode} - Debug: ${filepath}\n`;
|
||||
fs.appendFileSync(errorLogPath, errorMessage, 'utf8');
|
||||
|
||||
return filepath;
|
||||
} catch (error) {
|
||||
console.error("[ANYCLAUDE DEBUG] Failed to write debug file:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue an error message to be displayed later
|
||||
*/
|
||||
export function queueErrorMessage(message: string): void {
|
||||
pendingErrorMessages.push(message);
|
||||
// Display after a short delay to avoid being overwritten
|
||||
setTimeout(displayPendingErrors, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display pending error messages to stderr with formatting
|
||||
*/
|
||||
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');
|
||||
pendingErrorMessages = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a debug error and queue it for display
|
||||
*/
|
||||
export function logDebugError(
|
||||
type: "HTTP" | "Provider" | "Streaming",
|
||||
statusCode: number,
|
||||
debugFile: string | null,
|
||||
context?: { provider?: string; model?: string }
|
||||
): void {
|
||||
if (!debugFile) return;
|
||||
|
||||
let message = `${type} error`;
|
||||
if (context?.provider && context?.model) {
|
||||
message += ` (${context.provider}/${context.model})`;
|
||||
} else if (statusCode) {
|
||||
message += ` ${statusCode}`;
|
||||
}
|
||||
message += ` - Debug info written to: ${debugFile}`;
|
||||
|
||||
queueErrorMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display debug mode startup message
|
||||
*/
|
||||
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');
|
||||
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('═══════════════════════════════════════\n\n');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if debug mode is enabled
|
||||
*/
|
||||
/**
|
||||
* Get the debug level from ANYCLAUDE_DEBUG environment variable
|
||||
* Returns 0 if not set, 1 for basic debug, 2 for verbose debug
|
||||
* Defaults to 1 if unrecognized string is passed
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
export function isDebugEnabled(): boolean {
|
||||
return getDebugLevel() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if verbose debug mode (level 2) is enabled
|
||||
*/
|
||||
export function isVerboseDebugEnabled(): boolean {
|
||||
return getDebugLevel() >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a debug message at the specified level
|
||||
* @param level - Minimum debug level required to show this message (1 or 2)
|
||||
* @param message - The message to log
|
||||
* @param data - Optional data to append to the message
|
||||
*/
|
||||
export function debug(level: 1 | 2, message: string, data?: any): void {
|
||||
if (getDebugLevel() >= level) {
|
||||
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);
|
||||
console.error(`${prefix} ${message}`, dataStr);
|
||||
} else {
|
||||
console.error(`${prefix} ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -67,8 +67,18 @@ export function providerizeSchema(
|
|||
|
||||
// Only add required properties for OpenAI
|
||||
if (provider === "openai") {
|
||||
result.required = Object.keys(schema.properties);
|
||||
result.additionalProperties = false;
|
||||
// Preserve existing required fields if they exist, otherwise don't mark any as required
|
||||
// This prevents marking optional fields as required which causes OpenAI validation errors
|
||||
if (schema.required && Array.isArray(schema.required)) {
|
||||
result.required = schema.required;
|
||||
}
|
||||
// Only set additionalProperties to false if it's not already defined
|
||||
// This preserves the original schema's intent
|
||||
if (schema.additionalProperties === undefined) {
|
||||
result.additionalProperties = false;
|
||||
} else {
|
||||
result.additionalProperties = schema.additionalProperties;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
|
|||
119
src/main.ts
119
src/main.ts
|
|
@ -10,6 +10,93 @@ import {
|
|||
createAnthropicProxy,
|
||||
type CreateAnthropicProxyOptions,
|
||||
} from "./anthropic-proxy";
|
||||
import yargsParser from "yargs-parser";
|
||||
|
||||
const FLAGS = {
|
||||
reasoningEffort: {
|
||||
long: "reasoning-effort",
|
||||
short: "e",
|
||||
values: ["minimal", "low", "medium", "high"] as const,
|
||||
},
|
||||
serviceTier: {
|
||||
long: "service-tier",
|
||||
short: "t",
|
||||
values: ["flex", "priority"] as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
function parseAnyclaudeFlags(rawArgs: string[]) {
|
||||
const parsed = yargsParser(rawArgs, {
|
||||
configuration: {
|
||||
"unknown-options-as-args": false,
|
||||
"halt-at-non-option": false,
|
||||
"camel-case-expansion": false,
|
||||
"dot-notation": false,
|
||||
},
|
||||
});
|
||||
const reasoningEffort = (parsed[FLAGS.reasoningEffort.long] ??
|
||||
parsed[FLAGS.reasoningEffort.short]) as string | undefined;
|
||||
const serviceTier = (parsed[FLAGS.serviceTier.long] ??
|
||||
parsed[FLAGS.serviceTier.short]) as string | undefined;
|
||||
const specs = Object.values(FLAGS);
|
||||
const filteredArgs: string[] = [];
|
||||
let helpRequested = false;
|
||||
let i = 0;
|
||||
let passthrough = false;
|
||||
while (i < rawArgs.length) {
|
||||
const arg = rawArgs[i]!;
|
||||
if (passthrough) {
|
||||
filteredArgs.push(arg);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--") {
|
||||
passthrough = true;
|
||||
filteredArgs.push(arg);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (arg === "-h" || arg === "--help") helpRequested = true;
|
||||
let matched = false;
|
||||
for (const spec of specs) {
|
||||
const long = `--${spec.long}`;
|
||||
const short = `-${spec.short}`;
|
||||
if (arg === long || arg === short) {
|
||||
i += 2;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
if (arg.startsWith(long + "=") || arg.startsWith(short + "=")) {
|
||||
i += 1;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) continue;
|
||||
filteredArgs.push(arg);
|
||||
i++;
|
||||
}
|
||||
return { reasoningEffort, serviceTier, filteredArgs, helpRequested };
|
||||
}
|
||||
|
||||
const rawArgs = process.argv.slice(2);
|
||||
const { reasoningEffort, serviceTier, filteredArgs, helpRequested } =
|
||||
parseAnyclaudeFlags(rawArgs);
|
||||
|
||||
for (const [key, spec] of Object.entries(FLAGS) as Array<
|
||||
[keyof typeof FLAGS, (typeof FLAGS)[keyof typeof FLAGS]]
|
||||
>) {
|
||||
const val = (key === "reasoningEffort" ? reasoningEffort : serviceTier) as
|
||||
| string
|
||||
| undefined;
|
||||
if (val) {
|
||||
const allowed = new Set(spec.values as readonly string[]);
|
||||
if (!allowed.has(val as any)) {
|
||||
console.error(`Invalid ${spec.long}. Use ${spec.values.join("|")}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// providers are supported providers to proxy requests by name.
|
||||
// Model names are split when requested by `/`. The provider
|
||||
|
|
@ -23,7 +110,10 @@ const providers: CreateAnthropicProxyOptions["providers"] = {
|
|||
const body = JSON.parse(init.body);
|
||||
const maxTokens = body.max_tokens;
|
||||
delete body["max_tokens"];
|
||||
body.max_completion_tokens = maxTokens;
|
||||
if (typeof maxTokens !== "undefined")
|
||||
body.max_completion_tokens = maxTokens;
|
||||
if (reasoningEffort) body.reasoning = { effort: reasoningEffort };
|
||||
if (serviceTier) body.service_tier = serviceTier;
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
return globalThis.fetch(url, init);
|
||||
|
|
@ -56,10 +146,22 @@ const proxyURL = createAnthropicProxy({
|
|||
providers,
|
||||
});
|
||||
|
||||
const params = [
|
||||
`proxy=${proxyURL}`,
|
||||
...(
|
||||
Object.entries({ reasoningEffort, serviceTier }) as Array<
|
||||
[keyof typeof FLAGS, string | undefined]
|
||||
>
|
||||
).map(([k, v]) => (v ? `${FLAGS[k].long}=${v}` : undefined)),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
console.log(`[anyclaude] ${params}`);
|
||||
|
||||
if (process.env.PROXY_ONLY === "true") {
|
||||
console.log("Proxy only mode: "+proxyURL);
|
||||
console.log("Proxy only mode: " + proxyURL);
|
||||
} else {
|
||||
const claudeArgs = process.argv.slice(2);
|
||||
const claudeArgs = filteredArgs;
|
||||
const proc = spawn("claude", claudeArgs, {
|
||||
env: {
|
||||
...process.env,
|
||||
|
|
@ -68,12 +170,15 @@ if (process.env.PROXY_ONLY === "true") {
|
|||
stdio: "inherit",
|
||||
});
|
||||
proc.on("exit", (code) => {
|
||||
if (claudeArgs[0] === "-h" || claudeArgs[0] === "--help") {
|
||||
console.log("\nCustom Models:")
|
||||
console.log(" --model <provider>/<model> e.g. openai/o3");
|
||||
if (helpRequested) {
|
||||
console.log("\nanyclaude flags:");
|
||||
console.log(" --model <provider>/<model> e.g. openai/gpt-5");
|
||||
for (const spec of Object.values(FLAGS)) {
|
||||
const vals = spec.values.join("|");
|
||||
console.log(` --${spec.long}, -${spec.short} <${vals}>`);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(code);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue