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`)