From 474169d1f5e380a920a617d1573efdfb3e1035c4 Mon Sep 17 00:00:00 2001 From: Krzysztof Wende Date: Wed, 18 Feb 2026 19:34:53 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20upstream=20PR=20triage=20=E2=80=94?= =?UTF-8?q?=20implement=206=20fixes,=20add=20e2e=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triaged all 14 open PRs from atalovesyou/claude-max-api-proxy, implemented the valuable fixes, and added end-to-end test coverage. Changes: - Fix normalizeModelName crash on undefined model (atalovesyou/claude-max-api-proxy#7 regression) - Pass prompt via stdin instead of CLI arg to avoid E2BIG (atalovesyou/claude-max-api-proxy#12) - Increase subprocess timeout from 5 to 15 minutes (atalovesyou/claude-max-api-proxy#20) - Add Claude 4.5/4.6 model IDs and claude-max/ prefix (atalovesyou/claude-max-api-proxy#10, atalovesyou/claude-max-api-proxy#20) - Include usage data in final streaming SSE chunk (atalovesyou/claude-max-api-proxy#16) - Wrap subprocess logging with DEBUG_SUBPROCESS env check (atalovesyou/claude-max-api-proxy#5, atalovesyou/claude-max-api-proxy#16) - Strip CLAUDECODE env var from subprocesses (own fix) - Add e2e test suite (7 tests covering health, models, completions) Co-Authored-By: kevinfealey <10552286+kevinfealey@users.noreply.github.com> Co-Authored-By: Max <257223904+Max-shipper@users.noreply.github.com> Co-Authored-By: James Hansen <1359077+jamshehan@users.noreply.github.com> Co-Authored-By: bitking <213560776+smartchainark@users.noreply.github.com> Co-Authored-By: Alex Rudloff's AI Agents <258647843+alexrudloffBot@users.noreply.github.com> --- src/adapter/cli-to-openai.ts | 3 +- src/adapter/openai-to-cli.ts | 24 +++- src/e2e.test.ts | 241 +++++++++++++++++++++++++++++++++++ src/server/routes.ts | 48 +++---- src/subprocess/manager.ts | 31 +++-- 5 files changed, 311 insertions(+), 36 deletions(-) create mode 100644 src/e2e.test.ts diff --git a/src/adapter/cli-to-openai.ts b/src/adapter/cli-to-openai.ts index 77815da..4875df1 100644 --- a/src/adapter/cli-to-openai.ts +++ b/src/adapter/cli-to-openai.ts @@ -109,7 +109,8 @@ export function cliResultToOpenai( * Normalize Claude model names to a consistent format * e.g., "claude-sonnet-4-5-20250929" -> "claude-sonnet-4" */ -function normalizeModelName(model: string): string { +function normalizeModelName(model: string | undefined): string { + if (!model) return "claude-sonnet-4"; if (model.includes("opus")) return "claude-opus-4"; if (model.includes("sonnet")) return "claude-sonnet-4"; if (model.includes("haiku")) return "claude-haiku-4"; diff --git a/src/adapter/openai-to-cli.ts b/src/adapter/openai-to-cli.ts index d36c6b4..7f72f5a 100644 --- a/src/adapter/openai-to-cli.ts +++ b/src/adapter/openai-to-cli.ts @@ -15,16 +15,34 @@ export interface CliInput { const MODEL_MAP: Record = { // Direct model names "claude-opus-4": "opus", + "claude-opus-4-6": "opus", "claude-sonnet-4": "sonnet", + "claude-sonnet-4-5": "sonnet", + "claude-sonnet-4-6": "sonnet", "claude-haiku-4": "haiku", - // With provider prefix + "claude-haiku-4-5": "haiku", + // With provider prefix (claude-code-cli/) "claude-code-cli/claude-opus-4": "opus", + "claude-code-cli/claude-opus-4-6": "opus", "claude-code-cli/claude-sonnet-4": "sonnet", + "claude-code-cli/claude-sonnet-4-5": "sonnet", + "claude-code-cli/claude-sonnet-4-6": "sonnet", "claude-code-cli/claude-haiku-4": "haiku", - // Aliases + "claude-code-cli/claude-haiku-4-5": "haiku", + // With provider prefix (claude-max/) + "claude-max/claude-opus-4": "opus", + "claude-max/claude-opus-4-6": "opus", + "claude-max/claude-sonnet-4": "sonnet", + "claude-max/claude-sonnet-4-5": "sonnet", + "claude-max/claude-sonnet-4-6": "sonnet", + "claude-max/claude-haiku-4": "haiku", + "claude-max/claude-haiku-4-5": "haiku", + // Bare aliases "opus": "opus", "sonnet": "sonnet", "haiku": "haiku", + "opus-max": "opus", + "sonnet-max": "sonnet", }; /** @@ -37,7 +55,7 @@ export function extractModel(model: string): ClaudeModel { } // Try stripping provider prefix - const stripped = model.replace(/^claude-code-cli\//, ""); + const stripped = model.replace(/^(?:claude-code-cli|claude-max)\//, ""); if (MODEL_MAP[stripped]) { return MODEL_MAP[stripped]; } diff --git a/src/e2e.test.ts b/src/e2e.test.ts new file mode 100644 index 0000000..e5eca78 --- /dev/null +++ b/src/e2e.test.ts @@ -0,0 +1,241 @@ +/** + * End-to-end test for the Claude Max API proxy. + * + * Starts the real server, sends HTTP requests, and verifies responses + * against the OpenAI API format. Requires Claude CLI to be installed + * and authenticated — uses haiku for speed and cost. + * + * Run: npm test + */ + +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { startServer, stopServer } from "./server/index.js"; +import type { Server } from "http"; +import type { AddressInfo } from "net"; + +console.warn("\n" + "=".repeat(70)); +console.warn(" WARNING: THIS TEST USES A REAL CLAUDE CODE CLI INSTANCE"); +console.warn(" IT WILL BURN TOKENS ON YOUR CLAUDE MAX SUBSCRIPTION"); +console.warn("=".repeat(70) + "\n"); + +let baseUrl: string; +let server: Server; + +// Longer timeout — Claude CLI can take a while +const TEST_TIMEOUT = 120_000; + +before(async () => { + server = await startServer({ port: 0 }); + const addr = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${addr.port}`; +}); + +after(async () => { + await stopServer(); +}); + +// ─── Health & Models ──────────────────────────────────────────────── + +describe("health and models", () => { + it("GET /health returns ok", async () => { + const res = await fetch(`${baseUrl}/health`); + assert.equal(res.status, 200); + const body = await res.json() as any; + assert.equal(body.status, "ok"); + assert.equal(body.provider, "claude-code-cli"); + assert.ok(body.timestamp); + }); + + it("GET /v1/models lists all model IDs", async () => { + const res = await fetch(`${baseUrl}/v1/models`); + assert.equal(res.status, 200); + const body = await res.json() as any; + assert.equal(body.object, "list"); + assert.ok(Array.isArray(body.data)); + + const ids = body.data.map((m: any) => m.id); + for (const expected of [ + "claude-opus-4", + "claude-opus-4-6", + "claude-sonnet-4", + "claude-sonnet-4-5", + "claude-sonnet-4-6", + "claude-haiku-4", + "claude-haiku-4-5", + ]) { + assert.ok(ids.includes(expected), `missing model ${expected}`); + } + + for (const model of body.data) { + assert.equal(model.object, "model"); + assert.equal(model.owned_by, "anthropic"); + assert.ok(typeof model.created === "number"); + } + }); + + it("returns 404 for unknown routes", async () => { + const res = await fetch(`${baseUrl}/v1/nonexistent`); + assert.equal(res.status, 404); + }); + + it("returns 400 for empty messages", async () => { + const res = await fetch(`${baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "haiku", messages: [] }), + }); + assert.equal(res.status, 400); + const body = await res.json() as any; + assert.ok(body.error); + assert.equal(body.error.code, "invalid_messages"); + }); +}); + +// ─── Non-streaming completion ─────────────────────────────────────── + +describe("non-streaming completion", { timeout: TEST_TIMEOUT }, () => { + it("returns a valid OpenAI response for a simple prompt", async () => { + const res = await fetch(`${baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "claude-haiku-4", + stream: false, + messages: [ + { + role: "user", + content: "Reply with exactly the word 'pong' and nothing else.", + }, + ], + }), + }); + + assert.equal(res.status, 200); + const body = await res.json() as any; + + // Shape checks + assert.ok(body.id, "missing id"); + assert.equal(body.object, "chat.completion"); + assert.ok(typeof body.created === "number"); + assert.ok(body.model, "missing model"); + + // Choices + assert.ok(Array.isArray(body.choices)); + assert.equal(body.choices.length, 1); + const choice = body.choices[0]; + assert.equal(choice.index, 0); + assert.equal(choice.finish_reason, "stop"); + assert.equal(choice.message.role, "assistant"); + assert.ok(typeof choice.message.content === "string"); + assert.ok(choice.message.content.length > 0, "empty content"); + + // Usage + assert.ok(body.usage, "missing usage"); + assert.ok(typeof body.usage.prompt_tokens === "number"); + assert.ok(typeof body.usage.completion_tokens === "number"); + assert.ok(typeof body.usage.total_tokens === "number"); + assert.ok(body.usage.prompt_tokens > 0, "prompt_tokens should be > 0"); + assert.ok(body.usage.total_tokens > 0, "total_tokens should be > 0"); + }); + + it("handles array-style content blocks", async () => { + const res = await fetch(`${baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "haiku", + stream: false, + messages: [ + { + role: "user", + content: [{ type: "text", text: "Reply with exactly 'ok'." }], + }, + ], + }), + }); + + assert.equal(res.status, 200); + const body = await res.json() as any; + assert.ok(body.choices[0].message.content.length > 0); + }); +}); + +// ─── Streaming completion ─────────────────────────────────────────── + +describe("streaming completion", { timeout: TEST_TIMEOUT }, () => { + it("returns valid SSE chunks with usage in final chunk", async () => { + const res = await fetch(`${baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "claude-haiku-4", + stream: true, + messages: [ + { + role: "user", + content: "Reply with exactly the word 'pong' and nothing else.", + }, + ], + }), + }); + + assert.equal(res.status, 200); + assert.ok( + res.headers.get("content-type")?.includes("text/event-stream"), + "expected text/event-stream content type" + ); + + // Read the full SSE stream + const text = await res.text(); + const lines = text.split("\n"); + + const chunks: any[] = []; + let gotDone = false; + + for (const line of lines) { + if (line === "data: [DONE]") { + gotDone = true; + continue; + } + if (!line.startsWith("data: ")) continue; + const json = JSON.parse(line.slice(6)); + chunks.push(json); + } + + assert.ok(gotDone, "stream should end with [DONE]"); + assert.ok(chunks.length >= 1, "should have at least one chunk"); + + // First data chunk should have role: "assistant" in delta + const firstContentChunk = chunks.find( + (c) => c.choices?.[0]?.delta?.role === "assistant" + ); + assert.ok(firstContentChunk, "first chunk should set role to assistant"); + + // All chunks should have correct shape + for (const chunk of chunks) { + assert.ok(chunk.id, "chunk missing id"); + assert.equal(chunk.object, "chat.completion.chunk"); + assert.ok(typeof chunk.created === "number"); + assert.ok(chunk.model, "chunk missing model"); + assert.ok(Array.isArray(chunk.choices)); + assert.equal(chunk.choices.length, 1); + } + + // Last chunk should have finish_reason: "stop" + const lastChunk = chunks[chunks.length - 1]; + assert.equal(lastChunk.choices[0].finish_reason, "stop"); + + // Last chunk should include usage (our new feature) + assert.ok(lastChunk.usage, "final chunk should include usage"); + assert.ok(typeof lastChunk.usage.prompt_tokens === "number"); + assert.ok(typeof lastChunk.usage.completion_tokens === "number"); + assert.ok(typeof lastChunk.usage.total_tokens === "number"); + + // Concatenated text from all deltas should be non-empty + const fullText = chunks + .map((c) => c.choices[0].delta.content || "") + .join(""); + assert.ok(fullText.length > 0, "streamed text should be non-empty"); + }); +}); diff --git a/src/server/routes.ts b/src/server/routes.ts index 2258796..8de6b8c 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -240,11 +240,19 @@ async function handleStreamingResponse( lastModel = message.message.model; }); - subprocess.on("result", (_result: ClaudeCliResult) => { + subprocess.on("result", (result: ClaudeCliResult) => { isComplete = true; if (!res.writableEnded) { - // Send final done chunk with finish_reason + // Send final done chunk with finish_reason and usage data const doneChunk = createDoneChunk(requestId, lastModel); + if (result.usage) { + (doneChunk as any).usage = { + prompt_tokens: result.usage.input_tokens || 0, + completion_tokens: result.usage.output_tokens || 0, + total_tokens: + (result.usage.input_tokens || 0) + (result.usage.output_tokens || 0), + }; + } res.write(`data: ${JSON.stringify(doneChunk)}\n\n`); res.write("data: [DONE]\n\n"); res.end(); @@ -376,28 +384,24 @@ async function handleNonStreamingResponse( * Returns available models */ export function handleModels(_req: Request, res: Response): void { + const now = Math.floor(Date.now() / 1000); + const modelIds = [ + "claude-opus-4", + "claude-opus-4-6", + "claude-sonnet-4", + "claude-sonnet-4-5", + "claude-sonnet-4-6", + "claude-haiku-4", + "claude-haiku-4-5", + ]; res.json({ object: "list", - data: [ - { - id: "claude-opus-4", - object: "model", - owned_by: "anthropic", - created: Math.floor(Date.now() / 1000), - }, - { - id: "claude-sonnet-4", - object: "model", - owned_by: "anthropic", - created: Math.floor(Date.now() / 1000), - }, - { - id: "claude-haiku-4", - object: "model", - owned_by: "anthropic", - created: Math.floor(Date.now() / 1000), - }, - ], + data: modelIds.map((id) => ({ + id, + object: "model", + owned_by: "anthropic", + created: now, + })), }); } diff --git a/src/subprocess/manager.ts b/src/subprocess/manager.ts index b619f78..120035c 100644 --- a/src/subprocess/manager.ts +++ b/src/subprocess/manager.ts @@ -42,7 +42,7 @@ export interface SubprocessEvents { raw: (line: string) => void; } -const DEFAULT_TIMEOUT = 300000; // 5 minutes +const DEFAULT_TIMEOUT = 900000; // 15 minutes /** * System prompt appended to Claude CLI to map OpenClaw tool names to Claude Code equivalents. @@ -98,7 +98,7 @@ export class ClaudeSubprocess extends EventEmitter { * Start the Claude CLI subprocess with the given prompt */ async start(prompt: string, options: SubprocessOptions): Promise { - const args = this.buildArgs(prompt, options); + const args = this.buildArgs(options); const timeout = options.timeout || DEFAULT_TIMEOUT; return new Promise((resolve, reject) => { @@ -106,7 +106,9 @@ export class ClaudeSubprocess extends EventEmitter { // Use spawn() for security - no shell interpretation this.process = spawn("claude", args, { cwd: options.cwd || process.cwd(), - env: { ...process.env }, + env: Object.fromEntries( + Object.entries(process.env).filter(([k]) => k !== "CLAUDECODE") + ), stdio: ["pipe", "pipe", "pipe"], }); @@ -133,15 +135,20 @@ export class ClaudeSubprocess extends EventEmitter { } }); - // Close stdin since we pass prompt as argument + // Pass prompt via stdin to avoid E2BIG on large inputs + this.process.stdin?.write(prompt); this.process.stdin?.end(); - console.error(`[Subprocess] Process spawned with PID: ${this.process.pid}`); + if (process.env.DEBUG_SUBPROCESS) { + console.error(`[Subprocess] Process spawned with PID: ${this.process.pid}`); + } // Parse JSON stream from stdout this.process.stdout?.on("data", (chunk: Buffer) => { const data = chunk.toString(); - console.error(`[Subprocess] Received ${data.length} bytes of stdout`); + if (process.env.DEBUG_SUBPROCESS) { + console.error(`[Subprocess] Received ${data.length} bytes of stdout`); + } this.buffer += data; this.processBuffer(); }); @@ -152,13 +159,17 @@ export class ClaudeSubprocess extends EventEmitter { if (errorText) { // Don't emit as error unless it's actually an error // Claude CLI may write debug info to stderr - console.error("[Subprocess stderr]:", errorText.slice(0, 200)); + if (process.env.DEBUG_SUBPROCESS) { + console.error("[Subprocess stderr]:", errorText.slice(0, 200)); + } } }); // Handle process close this.process.on("close", (code) => { - console.error(`[Subprocess] Process closed with code: ${code}`); + if (process.env.DEBUG_SUBPROCESS) { + console.error(`[Subprocess] Process closed with code: ${code}`); + } this.clearTimeout(); // Process any remaining buffer if (this.buffer.trim()) { @@ -179,7 +190,7 @@ export class ClaudeSubprocess extends EventEmitter { /** * Build CLI arguments array */ - private buildArgs(prompt: string, options: SubprocessOptions): string[] { + private buildArgs(options: SubprocessOptions): string[] { const args = [ "--print", // Non-interactive mode "--dangerously-skip-permissions", // Skip permission prompts @@ -192,7 +203,7 @@ export class ClaudeSubprocess extends EventEmitter { "--no-session-persistence", // Don't save sessions "--append-system-prompt", OPENCLAW_TOOL_MAPPING_PROMPT, - prompt, // Pass prompt as argument (more reliable than stdin) + // Prompt is passed via stdin (avoids E2BIG on large inputs) ]; if (options.sessionId) { From f50d3134ab411f7a1b8fb5a5c31ff1a4b245a66b Mon Sep 17 00:00:00 2001 From: Krzysztof Wende Date: Wed, 18 Feb 2026 19:42:47 +0100 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20address=20review=20=E2=80=94=20?= =?UTF-8?q?type-safe=20usage,=20deduplicate=20MODEL=5FMAP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add optional `usage` field to OpenAIChatChunk type, removing `as any` cast - Remove redundant provider-prefixed MODEL_MAP entries (extractModel already strips prefixes before lookup) --- src/adapter/openai-to-cli.ts | 19 ++----------------- src/server/routes.ts | 2 +- src/types/openai.ts | 5 +++++ 3 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/adapter/openai-to-cli.ts b/src/adapter/openai-to-cli.ts index 7f72f5a..5774e34 100644 --- a/src/adapter/openai-to-cli.ts +++ b/src/adapter/openai-to-cli.ts @@ -13,7 +13,8 @@ export interface CliInput { } const MODEL_MAP: Record = { - // Direct model names + // Direct model names (provider prefixes like `claude-code-cli/` and `claude-max/` + // are stripped by extractModel before consulting this map) "claude-opus-4": "opus", "claude-opus-4-6": "opus", "claude-sonnet-4": "sonnet", @@ -21,22 +22,6 @@ const MODEL_MAP: Record = { "claude-sonnet-4-6": "sonnet", "claude-haiku-4": "haiku", "claude-haiku-4-5": "haiku", - // With provider prefix (claude-code-cli/) - "claude-code-cli/claude-opus-4": "opus", - "claude-code-cli/claude-opus-4-6": "opus", - "claude-code-cli/claude-sonnet-4": "sonnet", - "claude-code-cli/claude-sonnet-4-5": "sonnet", - "claude-code-cli/claude-sonnet-4-6": "sonnet", - "claude-code-cli/claude-haiku-4": "haiku", - "claude-code-cli/claude-haiku-4-5": "haiku", - // With provider prefix (claude-max/) - "claude-max/claude-opus-4": "opus", - "claude-max/claude-opus-4-6": "opus", - "claude-max/claude-sonnet-4": "sonnet", - "claude-max/claude-sonnet-4-5": "sonnet", - "claude-max/claude-sonnet-4-6": "sonnet", - "claude-max/claude-haiku-4": "haiku", - "claude-max/claude-haiku-4-5": "haiku", // Bare aliases "opus": "opus", "sonnet": "sonnet", diff --git a/src/server/routes.ts b/src/server/routes.ts index 8de6b8c..8695879 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -246,7 +246,7 @@ async function handleStreamingResponse( // Send final done chunk with finish_reason and usage data const doneChunk = createDoneChunk(requestId, lastModel); if (result.usage) { - (doneChunk as any).usage = { + doneChunk.usage = { prompt_tokens: result.usage.input_tokens || 0, completion_tokens: result.usage.output_tokens || 0, total_tokens: diff --git a/src/types/openai.ts b/src/types/openai.ts index 947c933..e8cbe7d 100644 --- a/src/types/openai.ts +++ b/src/types/openai.ts @@ -85,6 +85,11 @@ export interface OpenAIChatChunk { created: number; model: string; choices: OpenAIChatChunkChoice[]; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; } export interface OpenAIModel {