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 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-04 07:19:55 +00:00
co-authored by Claude Opus 4.6
parent f346992924
commit f1db573f10
+111 -5
View File
@@ -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<void> {
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`)