From f42e03937e63f9ff46faaafd4de97ee343045b79 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Sun, 10 Aug 2025 15:50:08 -0500 Subject: [PATCH] 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. --- .github/workflows/ci.yml | 47 ++++++ CLAUDE.md | 6 + README.md | 16 ++ bun.lock | 8 + package.json | 8 +- src/anthropic-proxy.ts | 225 ++++++++++++++++++++++++- src/convert-anthropic-messages.test.ts | 175 +++++++++++++++++++ src/convert-anthropic-messages.ts | 22 ++- src/debug.ts | 186 ++++++++++++++++++++ src/json-schema.ts | 14 +- src/main.ts | 119 ++++++++++++- 11 files changed, 802 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/convert-anthropic-messages.test.ts create mode 100644 src/debug.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4deb993 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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" \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 0665b16..5a35039 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 89745b3..f3cecc1 100644 --- a/README.md +++ b/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? diff --git a/bun.lock b/bun.lock index e733246..e9df3c0 100644 --- a/bun.lock +++ b/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=="], diff --git a/package.json b/package.json index 2f8417a..28d2b46 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/anthropic-proxy.ts b/src/anthropic-proxy.ts index 147e7b0..36b312a 100644 --- a/src/anthropic-proxy.ts +++ b/src/anthropic-proxy.ts @@ -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; @@ -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(); }, }) diff --git a/src/convert-anthropic-messages.test.ts b/src/convert-anthropic-messages.test.ts new file mode 100644 index 0000000..47b98c1 --- /dev/null +++ b/src/convert-anthropic-messages.test.ts @@ -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); + } + }); + }); +}); \ No newline at end of file diff --git a/src/convert-anthropic-messages.ts b/src/convert-anthropic-messages.ts index a5d3339..f0b69c8 100644 --- a/src/convert-anthropic-messages.ts +++ b/src/convert-anthropic-messages.ts @@ -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; } } diff --git a/src/debug.ts b/src/debug.ts new file mode 100644 index 0000000..1cce71f --- /dev/null +++ b/src/debug.ts @@ -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}`); + } + } +} \ No newline at end of file diff --git a/src/json-schema.ts b/src/json-schema.ts index db65d8d..0187a4c 100644 --- a/src/json-schema.ts +++ b/src/json-schema.ts @@ -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; diff --git a/src/main.ts b/src/main.ts index 5bf2456..8082947 100644 --- a/src/main.ts +++ b/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 / e.g. openai/o3"); + if (helpRequested) { + console.log("\nanyclaude flags:"); + console.log(" --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); }); } -