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:
co-authored by
Claude Opus 4.6
parent
f346992924
commit
f1db573f10
@@ -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`)
|
||||
|
||||
Reference in New Issue
Block a user