From 5c100043ce725999fddad3e426ae69bd1292161c Mon Sep 17 00:00:00 2001 From: James Hansen Date: Sat, 7 Feb 2026 22:31:23 -0500 Subject: [PATCH 1/2] feat: add support for Claude 4.5/4.6 models and claude-max prefix --- package-lock.json | 7 +++++-- src/adapter/openai-to-cli.ts | 9 +++++++++ src/server/routes.ts | 12 ++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index cfdfc81..f6561bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,20 @@ { - "name": "claude-code-cli-provider", + "name": "claude-max-api-proxy", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "claude-code-cli-provider", + "name": "claude-max-api-proxy", "version": "1.0.0", "license": "MIT", "dependencies": { "express": "^4.21.2", "uuid": "^11.0.5" }, + "bin": { + "claude-max-api": "dist/server/standalone.js" + }, "devDependencies": { "@types/express": "^5.0.0", "@types/node": "^22.10.7", diff --git a/src/adapter/openai-to-cli.ts b/src/adapter/openai-to-cli.ts index c8ecaa1..cdd1e30 100644 --- a/src/adapter/openai-to-cli.ts +++ b/src/adapter/openai-to-cli.ts @@ -15,16 +15,25 @@ export interface CliInput { const MODEL_MAP: Record = { // Direct model names "claude-opus-4": "opus", + "claude-opus-4-6": "opus", // Note: CLI doesn't have a separate 4.6 model, uses regular opus (200k limit) "claude-sonnet-4": "sonnet", + "claude-sonnet-4-5": "sonnet", "claude-haiku-4": "haiku", // With provider prefix "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-haiku-4": "haiku", + // Claude-max prefix (from Clawdbot config) + "claude-max/claude-opus-4-6": "opus", + "claude-max/claude-sonnet-4-5": "sonnet", // Aliases "opus": "opus", "sonnet": "sonnet", "haiku": "haiku", + "opus-max": "opus", + "sonnet-max": "sonnet", }; /** diff --git a/src/server/routes.ts b/src/server/routes.ts index ffe2e5b..87a4ce9 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -257,12 +257,24 @@ export function handleModels(_req: Request, res: Response): void { res.json({ object: "list", data: [ + { + id: "claude-opus-4-6", + object: "model", + owned_by: "anthropic", + created: Math.floor(Date.now() / 1000), + }, { id: "claude-opus-4", object: "model", owned_by: "anthropic", created: Math.floor(Date.now() / 1000), }, + { + id: "claude-sonnet-4-5", + object: "model", + owned_by: "anthropic", + created: Math.floor(Date.now() / 1000), + }, { id: "claude-sonnet-4", object: "model", From 3bcce642324d8725834baf918c94f0a0b6256a0b Mon Sep 17 00:00:00 2001 From: James Hansen Date: Sun, 8 Feb 2026 14:43:22 -0500 Subject: [PATCH 2/2] fix: comprehensive improvements from upstream PRs Incorporates fixes from PR #5 (coraAIbot) and PR #7 (wende): - Add --dangerously-skip-permissions flag for service/proxy usage Enables full system access when running as API proxy (file I/O, network, PM2, etc.) - Add system prompt support via --append-system-prompt Handles both short system prompts (CLI flag) and long prompts (stdin) Prevents ENAMETOOLONG on Windows by using 8000 char threshold - Fix content array extraction (PR #5 + our implementation) Handle OpenAI content format: string | ContentPart[] Extract text from content blocks correctly - Fix rate limit crashes (PR #7) Guard against undefined model names when rate limits are hit - Add debug logging with DEBUG_SUBPROCESS env var Helps diagnose prompt issues and subprocess behavior - Add support for 'developer' role in OpenAI spec - Keep stdin approach for main prompts (avoid ENAMETOOLONG) All changes tested and verified with OpenClaw integration. Co-Authored-By: Claude Sonnet 4.5 --- src/adapter/cli-to-openai.ts | 5 ++- src/adapter/openai-to-cli.ts | 78 +++++++++++++++++++++++++++++++----- src/server/routes.ts | 12 +++++- src/subprocess/manager.ts | 69 +++++++++++++++++++++++++++---- src/types/openai.ts | 19 ++++++++- 5 files changed, 160 insertions(+), 23 deletions(-) diff --git a/src/adapter/cli-to-openai.ts b/src/adapter/cli-to-openai.ts index 1e43eab..ee5ba0f 100644 --- a/src/adapter/cli-to-openai.ts +++ b/src/adapter/cli-to-openai.ts @@ -101,8 +101,11 @@ export function cliResultToOpenai( /** * Normalize Claude model names to a consistent format * e.g., "claude-sonnet-4-5-20250929" -> "claude-sonnet-4" + * + * Handles undefined model (e.g., when rate limit is hit and modelUsage is empty) */ -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 cdd1e30..b8c6c46 100644 --- a/src/adapter/openai-to-cli.ts +++ b/src/adapter/openai-to-cli.ts @@ -10,6 +10,8 @@ export interface CliInput { prompt: string; model: ClaudeModel; sessionId?: string; + systemPrompt?: string; + tools?: string[]; } const MODEL_MAP: Record = { @@ -56,43 +58,97 @@ export function extractModel(model: string): ClaudeModel { } /** - * Convert OpenAI messages array to a single prompt string for Claude CLI + * Extract text content from a message content field + * Handles both string content and array of content parts (multimodal) + */ +function extractTextContent(content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>): string { + // If it's already a string, return it directly + if (typeof content === "string") { + return content; + } + + // If it's an array of content parts, extract text from each text part + if (Array.isArray(content)) { + return content + .filter((part): part is { type: "text"; text: string } => part.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join("\n"); + } + + // Fallback for unexpected types + return String(content); +} + +/** + * Extract system messages and conversation from OpenAI messages array * - * Claude Code CLI in --print mode expects a single prompt, not a conversation. - * We format the messages into a readable format that preserves context. + * System messages should be passed via --append-system-prompt flag, + * not embedded in the user prompt (more reliable for OpenClaw integration). */ -export function messagesToPrompt(messages: OpenAIChatRequest["messages"]): string { - const parts: string[] = []; +export function extractMessagesContent(messages: OpenAIChatRequest["messages"]): { + systemPrompt: string | undefined; + conversationPrompt: string; +} { + const systemParts: string[] = []; + const conversationParts: string[] = []; for (const msg of messages) { + const text = extractTextContent(msg.content); + switch (msg.role) { case "system": - // System messages become context instructions - parts.push(`\n${msg.content}\n\n`); + case "developer": + // System/developer messages go to --append-system-prompt flag + // "developer" is OpenAI's newer role for system-level instructions + systemParts.push(text); break; case "user": // User messages are the main prompt - parts.push(msg.content); + conversationParts.push(text); break; case "assistant": // Previous assistant responses for context - parts.push(`\n${msg.content}\n\n`); + conversationParts.push(`\n${text}\n\n`); break; } } - return parts.join("\n").trim(); + return { + systemPrompt: systemParts.length > 0 ? systemParts.join("\n\n").trim() : undefined, + conversationPrompt: conversationParts.join("\n").trim(), + }; +} + +/** + * Convert OpenAI messages array to a single prompt string for Claude CLI + * + * @deprecated Use extractMessagesContent instead for better system prompt handling + */ +export function messagesToPrompt(messages: OpenAIChatRequest["messages"]): string { + const { systemPrompt, conversationPrompt } = extractMessagesContent(messages); + + if (systemPrompt) { + return `\n${systemPrompt}\n\n\n${conversationPrompt}`; + } + + return conversationPrompt; } /** * Convert OpenAI chat request to CLI input format */ export function openaiToCli(request: OpenAIChatRequest): CliInput { + const { systemPrompt, conversationPrompt } = extractMessagesContent(request.messages); + return { - prompt: messagesToPrompt(request.messages), + prompt: conversationPrompt, model: extractModel(request.model), sessionId: request.user, // Use OpenAI's user field for session mapping + systemPrompt, + // TODO: Extract tool names from request.tools and map to Claude Code tool names + // For now, let Claude Code use all its builtin tools + tools: undefined, }; } diff --git a/src/server/routes.ts b/src/server/routes.ts index 87a4ce9..e6460ef 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -175,10 +175,14 @@ async function handleStreamingResponse( resolve(); }); - // Start the subprocess + // Start the subprocess with OpenClaw workspace as cwd + const workspacePath = process.env.OPENCLAW_WORKSPACE || process.env.CLAWDBOT_WORKSPACE; subprocess.start(cliInput.prompt, { model: cliInput.model, sessionId: cliInput.sessionId, + systemPrompt: cliInput.systemPrompt, + tools: cliInput.tools, + cwd: workspacePath, }).catch((err) => { console.error("[Streaming] Subprocess start error:", err); reject(err); @@ -229,11 +233,15 @@ async function handleNonStreamingResponse( resolve(); }); - // Start the subprocess + // Start the subprocess with OpenClaw workspace as cwd + const workspacePath = process.env.OPENCLAW_WORKSPACE || process.env.CLAWDBOT_WORKSPACE; subprocess .start(cliInput.prompt, { model: cliInput.model, sessionId: cliInput.sessionId, + systemPrompt: cliInput.systemPrompt, + tools: cliInput.tools, + cwd: workspacePath, }) .catch((error) => { res.status(500).json({ diff --git a/src/subprocess/manager.ts b/src/subprocess/manager.ts index 6551a81..1cbc979 100644 --- a/src/subprocess/manager.ts +++ b/src/subprocess/manager.ts @@ -21,6 +21,8 @@ import type { ClaudeModel } from "../adapter/openai-to-cli.js"; export interface SubprocessOptions { model: ClaudeModel; sessionId?: string; + systemPrompt?: string; + tools?: string[]; cwd?: string; timeout?: number; } @@ -36,12 +38,24 @@ export interface SubprocessEvents { const DEFAULT_TIMEOUT = 300000; // 5 minutes +// Debug logging controlled by environment variable +const DEBUG = process.env.DEBUG_SUBPROCESS === "true"; + export class ClaudeSubprocess extends EventEmitter { private process: ChildProcess | null = null; private buffer: string = ""; private timeoutId: NodeJS.Timeout | null = null; private isKilled: boolean = false; + /** + * Conditional debug logging + */ + private debug(...args: any[]): void { + if (DEBUG) { + console.error(...args); + } + } + /** * Start the Claude CLI subprocess with the given prompt */ @@ -81,15 +95,31 @@ export class ClaudeSubprocess extends EventEmitter { } }); - // Close stdin since we pass prompt as argument - this.process.stdin?.end(); + // Write prompt to stdin instead of passing as argument (avoids ENAMETOOLONG on Windows) + // If system prompt is large, prepend it to the prompt instead of using --append-system-prompt + if (this.process.stdin) { + let fullPrompt = prompt; + + // If we have a system prompt that wasn't added via CLI args (because it's too long), + // prepend it to the prompt with XML tags + if (options.systemPrompt && options.systemPrompt.length > 8000) { + fullPrompt = `\n${options.systemPrompt}\n\n\n${prompt}`; + this.debug(`[Subprocess] System prompt too long (${options.systemPrompt.length} chars), prepending to stdin instead of CLI arg`); + } + + this.debug(`[Subprocess] Writing ${fullPrompt.length} chars to stdin`); + this.debug(`[Subprocess] Prompt preview (first 500 chars):\n${fullPrompt.slice(0, 500)}`); + this.debug(`[Subprocess] Prompt preview (last 500 chars):\n${fullPrompt.slice(-500)}`); + this.process.stdin.write(fullPrompt + "\n"); + this.process.stdin.end(); + } - console.error(`[Subprocess] Process spawned with PID: ${this.process.pid}`); + this.debug(`[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`); + this.debug(`[Subprocess] Received ${data.length} bytes of stdout`); this.buffer += data; this.processBuffer(); }); @@ -100,13 +130,13 @@ 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)); + this.debug("[Subprocess stderr]:", errorText.slice(0, 200)); } }); // Handle process close this.process.on("close", (code) => { - console.error(`[Subprocess] Process closed with code: ${code}`); + this.debug(`[Subprocess] Process closed with code: ${code}`); this.clearTimeout(); // Process any remaining buffer if (this.buffer.trim()) { @@ -137,13 +167,36 @@ export class ClaudeSubprocess extends EventEmitter { "--model", options.model, // Model alias (opus/sonnet/haiku) "--no-session-persistence", // Don't save sessions - prompt, // Pass prompt as argument (more reliable than stdin) + "--dangerously-skip-permissions", // Allow file operations (running as service) ]; + // Add system prompt if provided (backstory/memories from OpenClaw) + // Only use --append-system-prompt for short system prompts to avoid ENAMETOOLONG on Windows + // Long system prompts (>8000 chars) are prepended to stdin instead + if (options.systemPrompt) { + this.debug(`[Subprocess] System prompt: ${options.systemPrompt.length} chars`); + if (options.systemPrompt.length <= 8000) { + this.debug(`[Subprocess] Adding system prompt via --append-system-prompt (short enough for CLI)`); + args.push("--append-system-prompt", options.systemPrompt); + } else { + this.debug(`[Subprocess] System prompt too long for CLI arg, will prepend to stdin`); + } + } else { + this.debug("[Subprocess] NO system prompt provided"); + } + + // Add tool restrictions if provided + if (options.tools && options.tools.length > 0) { + args.push("--tools", options.tools.join(",")); + } + if (options.sessionId) { args.push("--session-id", options.sessionId); } + // Prompt is passed via stdin to avoid Windows ENAMETOOLONG error + // (not as CLI argument like in the original PR) + return args; } @@ -166,8 +219,10 @@ export class ClaudeSubprocess extends EventEmitter { // Emit content delta for streaming this.emit("content_delta", message as ClaudeCliStreamEvent); } else if (isAssistantMessage(message)) { + this.debug(`[Response] Assistant message:`, JSON.stringify(message.message.content)); this.emit("assistant", message); } else if (isResultMessage(message)) { + this.debug(`[Response] Result:`, message.result); this.emit("result", message); } } catch { diff --git a/src/types/openai.ts b/src/types/openai.ts index c116658..9f9510c 100644 --- a/src/types/openai.ts +++ b/src/types/openai.ts @@ -3,9 +3,24 @@ * Used for Clawdbot integration */ +// Content can be a string or array of content parts (multimodal) +export interface OpenAITextContentPart { + type: "text"; + text: string; +} + +export interface OpenAIImageContentPart { + type: "image_url"; + image_url: { + url: string; + }; +} + +export type OpenAIContentPart = OpenAITextContentPart | OpenAIImageContentPart; + export interface OpenAIChatMessage { - role: "system" | "user" | "assistant"; - content: string; + role: "system" | "developer" | "user" | "assistant"; + content: string | OpenAIContentPart[]; } export interface OpenAIChatRequest {