From f1db573f10106659e21a1904ee31e650fb9e5b48 Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 4 Mar 2026 07:19:55 +0000 Subject: [PATCH] fix(proxy): fall back to OpenRouter with Anthropic SSE format conversion When no Anthropic API credential is available, the proxy now falls back to OpenRouter before trying the Claude CLI. The new streamViaOpenRouterFallback function converts OpenRouter's OpenAI-format SSE to Anthropic-format SSE (content_block_delta with text_delta) so the frontend's Claude provider can parse it correctly. Co-Authored-By: Claude Opus 4.6 --- packages/app/server/claude-proxy.ts | 116 ++++++++++++++++++++++++++-- 1 file changed, 111 insertions(+), 5 deletions(-) diff --git a/packages/app/server/claude-proxy.ts b/packages/app/server/claude-proxy.ts index f611b6fa..9766b4a5 100644 --- a/packages/app/server/claude-proxy.ts +++ b/packages/app/server/claude-proxy.ts @@ -326,6 +326,102 @@ async function streamViaAnthropicApi( } } +/** Call OpenRouter but emit Anthropic-format SSE (for Claude provider fallback) */ +async function streamViaOpenRouterFallback( + model: string, + system: string | undefined, + messages: { role: string; content: string }[], + res: import('http').ServerResponse, +): Promise { + const orModel = model?.includes('opus') ? 'anthropic/claude-opus-4' + : model?.includes('haiku') ? 'anthropic/claude-haiku-4' + : 'anthropic/claude-sonnet-4' + const orMessages: { role: string; content: string }[] = [] + if (system) orMessages.push({ role: 'system', content: system }) + for (const m of messages) { + orMessages.push({ role: m.role, content: m.content }) + } + + let clientDisconnected = false + res.on('close', () => { clientDisconnected = true }) + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'X-Accel-Buffering': 'no', + }) + + try { + const apiRes = await fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${OPENROUTER_API_KEY}`, + 'HTTP-Referer': APP_URL, + 'X-Title': 'AIUI', + }, + body: JSON.stringify({ model: orModel, messages: orMessages, stream: true }), + signal: AbortSignal.timeout(120000), + }) + + if (!apiRes.ok) { + const errBody = await apiRes.text() + if (!clientDisconnected) { + res.write(`data: ${JSON.stringify({ type: 'error', error: { message: `OpenRouter ${apiRes.status}: ${errBody.slice(0, 200)}` } })}\n\n`) + res.write('data: [DONE]\n\n') + res.end() + } + return + } + + const reader = apiRes.body?.getReader() + if (!reader) { + if (!clientDisconnected) { + res.write('data: [DONE]\n\n') + res.end() + } + return + } + + const decoder = new TextDecoder() + let buffer = '' + while (true) { + const { done, value } = await reader.read() + if (done || clientDisconnected) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + if (!line.startsWith('data: ')) continue + const payload = line.slice(6).trim() + if (payload === '[DONE]') continue + try { + const chunk = JSON.parse(payload) + const text = chunk.choices?.[0]?.delta?.content + if (text && !clientDisconnected) { + // Convert to Anthropic SSE format + res.write(`data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text } })}\n\n`) + } + } catch { /* skip malformed chunks */ } + } + } + + if (!clientDisconnected) { + res.write('data: [DONE]\n\n') + res.end() + } + } catch (err) { + console.error('[proxy] OpenRouter fallback error:', err) + if (!clientDisconnected) { + res.write(`data: ${JSON.stringify({ type: 'error', error: { message: `OpenRouter error: ${err instanceof Error ? err.message : String(err)}` } })}\n\n`) + res.write('data: [DONE]\n\n') + res.end() + } + } +} + async function streamOpenRouterProxy( reqBody: string, res: import('http').ServerResponse, @@ -446,8 +542,17 @@ const server = createServer((req, res) => { return } - // Fallback: Claude CLI (works when logged in from a normal terminal) - console.log('[proxy] No API credential — falling back to Claude CLI') + // Fallback: OpenRouter (when no Anthropic credential available) + if (OPENROUTER_API_KEY) { + console.log('[proxy] No API credential — falling back to OpenRouter') + const lastMsg = (messages ?? []).slice(-1)[0]?.content?.slice(0, 60) ?? '' + console.log(`[proxy] → OpenRouter fallback "${lastMsg}..."`) + await streamViaOpenRouterFallback(model, system, messages ?? [], res) + return + } + + // Last resort: Claude CLI (works when logged in from terminal) + console.log('[proxy] No API credential and no OpenRouter key — falling back to Claude CLI') const modelFlag = model?.includes('opus') ? 'opus' : model?.includes('haiku') ? 'haiku' @@ -480,7 +585,6 @@ const server = createServer((req, res) => { console.log(`[proxy] → claude -p --model ${modelFlag}${webSearch ? ' [WebSearch]' : ''} "${lastUserMsg.slice(0, 60)}..."`) const procEnv = { ...process.env, NO_COLOR: '1', TERM: 'dumb' } - // Unset Claude Code session vars so the CLI can spawn inside a CC session delete procEnv.CLAUDECODE delete procEnv.CLAUDE_CODE delete procEnv.ANTHROPIC_CLAUDE_CODE @@ -507,12 +611,11 @@ const server = createServer((req, res) => { let fullOutput = '' let clientDisconnected = false - // Hard timeout — if CLI produces no output in 30s, return an error const cliTimeout = setTimeout(() => { if (fullOutput.length === 0 && !clientDisconnected) { console.warn('[proxy] CLI timeout (30s no output) — killing process') proc.kill('SIGTERM') - res.write(`data: ${JSON.stringify({ type: 'error', error: { message: 'Claude CLI timed out. Check that the claude binary is working: ~/.local/bin/claude -p "hi"' } })}\n\n`) + res.write(`data: ${JSON.stringify({ type: 'error', error: { message: 'Claude CLI timed out. Set ANTHROPIC_API_KEY or VITE_OPENROUTER_API_KEY in .env.local' } })}\n\n`) res.write('data: [DONE]\n\n') res.end() } @@ -588,6 +691,9 @@ server.listen(PORT, async () => { const mode = isOAuthToken(credential) ? 'OAuth (Max)' : 'API key' console.log(` Auth: ${mode} (token ...${credential.slice(-8)})`) console.log(` Mode: Direct Anthropic API (no CLI needed)`) + } else if (OPENROUTER_API_KEY) { + console.log(` Auth: none found (Anthropic)`) + console.log(` Fallback: OpenRouter (key ...${OPENROUTER_API_KEY.slice(-8)})`) } else { console.log(` Auth: none found`) console.log(` Checked: env vars, ~/.claude/.credentials.json, ~/.claude/settings.json`)