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>
705 lines
23 KiB
TypeScript
705 lines
23 KiB
TypeScript
import { spawn } from 'child_process'
|
|
import { createServer } from 'http'
|
|
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
|
import { resolve, dirname } from 'path'
|
|
import { fileURLToPath } from 'url'
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
|
|
// Load .env.local from workspace root (monorepo) or cwd
|
|
function loadEnv() {
|
|
for (const base of [resolve(__dirname, '../../..'), process.cwd()]) {
|
|
const path = resolve(base, '.env.local')
|
|
if (existsSync(path)) {
|
|
try {
|
|
const buf = readFileSync(path, 'utf8')
|
|
for (const line of buf.split('\n')) {
|
|
const m = line.match(/^([^#=]+)=(.*)$/)
|
|
if (m) {
|
|
const key = m[1].trim()
|
|
const val = m[2].trim().replace(/^["']|["']$/g, '')
|
|
if (!process.env[key]) process.env[key] = val
|
|
}
|
|
}
|
|
break
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
}
|
|
loadEnv()
|
|
|
|
const PORT = 3141
|
|
const CLAUDE_BIN = resolve(process.env.HOME ?? '', '.local/bin/claude')
|
|
const APP_URL = process.env.APP_URL ?? 'http://localhost:5173'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OAuth credential management — reads from ~/.claude/.credentials.json
|
|
// (the same file Claude Code uses) with automatic token refresh
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const OAUTH_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'
|
|
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
|
|
const CREDENTIALS_PATH = resolve(process.env.HOME ?? '', '.claude/.credentials.json')
|
|
|
|
interface OAuthCredentials {
|
|
accessToken: string
|
|
refreshToken: string
|
|
expiresAt: number
|
|
scopes?: string[]
|
|
subscriptionType?: string
|
|
rateLimitTier?: string
|
|
}
|
|
|
|
/** Read Claude Code's OAuth credentials from disk (fresh every call) */
|
|
function readClaudeCredentials(): OAuthCredentials | undefined {
|
|
try {
|
|
if (!existsSync(CREDENTIALS_PATH)) return undefined
|
|
const json = JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf8'))
|
|
const oauth = json?.claudeAiOauth
|
|
if (oauth?.accessToken && oauth?.refreshToken) {
|
|
return oauth as OAuthCredentials
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/** Persist refreshed credentials back to ~/.claude/.credentials.json */
|
|
function writeClaudeCredentials(creds: OAuthCredentials): void {
|
|
try {
|
|
let json: Record<string, unknown> = {}
|
|
if (existsSync(CREDENTIALS_PATH)) {
|
|
json = JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf8'))
|
|
}
|
|
json.claudeAiOauth = creds
|
|
writeFileSync(CREDENTIALS_PATH, JSON.stringify(json, null, 2) + '\n', 'utf8')
|
|
console.log('[proxy] Refreshed OAuth token written to', CREDENTIALS_PATH)
|
|
} catch (err) {
|
|
console.error('[proxy] Failed to write refreshed credentials:', err)
|
|
}
|
|
}
|
|
|
|
/** Refresh an expired OAuth token using the refresh token */
|
|
async function refreshOAuthToken(refreshToken: string): Promise<OAuthCredentials | undefined> {
|
|
try {
|
|
console.log('[proxy] OAuth token expired, refreshing...')
|
|
const res = await fetch(OAUTH_TOKEN_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: refreshToken,
|
|
client_id: OAUTH_CLIENT_ID,
|
|
}),
|
|
signal: AbortSignal.timeout(15000),
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => '')
|
|
console.error(`[proxy] OAuth refresh failed ${res.status}: ${body.slice(0, 200)}`)
|
|
return undefined
|
|
}
|
|
|
|
const data = (await res.json()) as {
|
|
access_token?: string
|
|
refresh_token?: string
|
|
expires_in?: number
|
|
}
|
|
|
|
if (!data.access_token) {
|
|
console.error('[proxy] OAuth refresh response missing access_token')
|
|
return undefined
|
|
}
|
|
|
|
const creds: OAuthCredentials = {
|
|
accessToken: data.access_token,
|
|
refreshToken: data.refresh_token ?? refreshToken,
|
|
expiresAt: Date.now() + (data.expires_in ?? 3600) * 1000,
|
|
}
|
|
|
|
writeClaudeCredentials(creds)
|
|
console.log('[proxy] OAuth token refreshed successfully (expires in', data.expires_in ?? 3600, 's)')
|
|
return creds
|
|
} catch (err) {
|
|
console.error('[proxy] OAuth refresh error:', err instanceof Error ? err.message : err)
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a valid Anthropic credential, checking multiple sources:
|
|
* 1. Env vars (ANTHROPIC_API_KEY, ANTHROPIC_TOKEN, etc.)
|
|
* 2. ~/.claude/.credentials.json (Claude Code OAuth — auto-refreshed)
|
|
* 3. ~/.claude/settings.json env section
|
|
*
|
|
* Re-reads from disk on every call so we always pick up fresh tokens.
|
|
*/
|
|
async function getAnthropicCredential(): Promise<string | undefined> {
|
|
// 1. Env vars (highest priority — explicit override)
|
|
const fromEnv = process.env.ANTHROPIC_API_KEY
|
|
?? process.env.VITE_ANTHROPIC_API_KEY
|
|
?? process.env.ANTHROPIC_TOKEN
|
|
?? process.env.VITE_ANTHROPIC_TOKEN
|
|
if (fromEnv) return fromEnv
|
|
|
|
// 2. Claude Code credentials file (OAuth — primary path for Max users)
|
|
const creds = readClaudeCredentials()
|
|
if (creds) {
|
|
// Check if token is expired (with 60s buffer)
|
|
if (creds.expiresAt > Date.now() + 60_000) {
|
|
return creds.accessToken
|
|
}
|
|
// Token expired — try to refresh
|
|
const refreshed = await refreshOAuthToken(creds.refreshToken)
|
|
if (refreshed) return refreshed.accessToken
|
|
// Refresh failed but token might still work briefly — try it anyway
|
|
if (creds.expiresAt > Date.now()) return creds.accessToken
|
|
}
|
|
|
|
// 3. Claude settings.json env section (legacy)
|
|
const home = process.env.HOME ?? ''
|
|
const settingsPath = resolve(home, '.claude/settings.json')
|
|
if (home && existsSync(settingsPath)) {
|
|
try {
|
|
const json = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
|
const env = json?.env
|
|
if (env && typeof env === 'object') {
|
|
const t = env.ANTHROPIC_TOKEN ?? env.VITE_ANTHROPIC_TOKEN ?? env.ANTHROPIC_API_KEY ?? env.VITE_ANTHROPIC_API_KEY
|
|
if (typeof t === 'string') return t
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? process.env.VITE_OPENROUTER_API_KEY ?? ''
|
|
const isOAuthToken = (s: string) => /^sk-ant-oat/.test(s)
|
|
|
|
const SEARCH_WEB_TOOL = {
|
|
name: 'search_web',
|
|
description: 'Search the web for current information. Use this when the user asks for news, recent events, facts you are unsure about, or any information that may have changed. Perform one search per distinct topic. Returns titles, URLs, and snippets.',
|
|
input_schema: {
|
|
type: 'object',
|
|
properties: {
|
|
query: {
|
|
type: 'string',
|
|
description: 'Search query (e.g. "Bitcoin price March 2025", "latest news AI regulation")',
|
|
},
|
|
},
|
|
required: ['query'],
|
|
},
|
|
}
|
|
|
|
function mapModelToApi(model: string): string {
|
|
if (model?.includes('opus')) return 'claude-opus-4-5-20250918'
|
|
if (model?.includes('haiku')) return 'claude-haiku-4-5-20251001'
|
|
return 'claude-sonnet-4-5-20250514'
|
|
}
|
|
|
|
async function runSearchWeb(query: string): Promise<string> {
|
|
const url = `${APP_URL.replace(/\/$/, '')}/api/web-search?${new URLSearchParams({ q: query })}`
|
|
try {
|
|
const res = await fetch(url, {
|
|
headers: { Accept: 'application/json' },
|
|
signal: AbortSignal.timeout(10000),
|
|
})
|
|
if (!res.ok) return `Search failed: ${res.status}`
|
|
const data = (await res.json()) as { results?: { title?: string; url?: string; content?: string }[] }
|
|
const results = data.results ?? []
|
|
if (results.length === 0) return 'No results found.'
|
|
return results
|
|
.map((r, i) => `${i + 1}. [${r.title ?? 'Unknown'}](${r.url ?? ''})${r.content ? ` — ${r.content.slice(0, 150)}${r.content.length > 150 ? '…' : ''}` : ''}`)
|
|
.join('\n')
|
|
} catch (err) {
|
|
return `Search error: ${err instanceof Error ? err.message : String(err)}`
|
|
}
|
|
}
|
|
|
|
async function streamViaAnthropicApi(
|
|
credential: string,
|
|
model: string,
|
|
system: string | undefined,
|
|
messages: { role: string; content: string }[],
|
|
res: import('http').ServerResponse,
|
|
webSearch: boolean,
|
|
): Promise<void> {
|
|
const apiModel = mapModelToApi(model)
|
|
const apiMessages = messages.map((m) => ({
|
|
role: m.role === 'assistant' ? 'assistant' : 'user',
|
|
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
|
|
}))
|
|
|
|
let clientDisconnected = false
|
|
res.on('close', () => { clientDisconnected = true })
|
|
|
|
const sendDelta = (text: string) => {
|
|
if (!clientDisconnected) {
|
|
res.write(`data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text } })}\n\n`)
|
|
}
|
|
}
|
|
|
|
const sendError = (msg: string) => {
|
|
if (!clientDisconnected) {
|
|
res.write(`data: ${JSON.stringify({ type: 'error', error: { message: msg } })}\n\n`)
|
|
}
|
|
}
|
|
|
|
let turnMessages = [...apiMessages]
|
|
const maxToolRounds = webSearch ? 5 : 1
|
|
let rounds = 0
|
|
|
|
while (rounds < maxToolRounds) {
|
|
rounds++
|
|
const body: Record<string, unknown> = {
|
|
model: apiModel,
|
|
max_tokens: 4096,
|
|
system,
|
|
messages: turnMessages,
|
|
}
|
|
if (webSearch) {
|
|
body.tools = [SEARCH_WEB_TOOL]
|
|
}
|
|
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
'anthropic-version': '2023-06-01',
|
|
}
|
|
if (isOAuthToken(credential)) {
|
|
headers['Authorization'] = `Bearer ${credential}`
|
|
headers['anthropic-beta'] = 'oauth-2025-04-20'
|
|
} else {
|
|
headers['x-api-key'] = credential
|
|
}
|
|
|
|
const apiRes = await fetch('https://api.anthropic.com/v1/messages', {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(120000),
|
|
})
|
|
|
|
if (!apiRes.ok) {
|
|
const errBody = await apiRes.text()
|
|
sendError(`Anthropic API ${apiRes.status}: ${errBody.slice(0, 200)}`)
|
|
break
|
|
}
|
|
|
|
const data = (await apiRes.json()) as {
|
|
content?: { type: string; text?: string; id?: string; name?: string; input?: { query?: string } }[]
|
|
stop_reason?: string
|
|
}
|
|
|
|
const content = data.content ?? []
|
|
const toolUses = content.filter((b) => b.type === 'tool_use')
|
|
const textBlocks = content.filter((b) => b.type === 'text')
|
|
|
|
if (data.stop_reason === 'tool_use' && toolUses.length > 0) {
|
|
const toolResults: { type: string; tool_use_id: string; content: string }[] = []
|
|
for (const tu of toolUses) {
|
|
if (tu.name === 'search_web' && tu.id && tu.input?.query) {
|
|
console.log('[proxy] tool search_web:', tu.input.query)
|
|
const result = await runSearchWeb(tu.input.query)
|
|
toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: result })
|
|
}
|
|
}
|
|
turnMessages = [
|
|
...turnMessages,
|
|
{ role: 'assistant' as const, content },
|
|
{ role: 'user' as const, content: toolResults },
|
|
]
|
|
continue
|
|
}
|
|
|
|
for (const block of textBlocks) {
|
|
if (block.text) sendDelta(block.text)
|
|
}
|
|
break
|
|
}
|
|
|
|
if (!clientDisconnected) {
|
|
res.write('data: [DONE]\n\n')
|
|
res.end()
|
|
}
|
|
}
|
|
|
|
/** 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,
|
|
): Promise<void> {
|
|
if (!OPENROUTER_API_KEY) {
|
|
res.writeHead(500, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
|
res.end(JSON.stringify({ error: 'OPENROUTER_API_KEY not configured on server' }))
|
|
return
|
|
}
|
|
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
'Access-Control-Allow-Origin': '*',
|
|
'X-Accel-Buffering': 'no',
|
|
})
|
|
|
|
let clientDisconnected = false
|
|
res.on('close', () => { clientDisconnected = true })
|
|
|
|
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: reqBody,
|
|
signal: AbortSignal.timeout(120000),
|
|
})
|
|
|
|
if (!apiRes.ok) {
|
|
const errBody = await apiRes.text()
|
|
if (!clientDisconnected) {
|
|
res.write(`data: ${JSON.stringify({ error: `OpenRouter API ${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()
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done || clientDisconnected) break
|
|
const chunk = decoder.decode(value, { stream: true })
|
|
res.write(chunk)
|
|
}
|
|
|
|
if (!clientDisconnected) {
|
|
res.end()
|
|
}
|
|
} catch (err) {
|
|
console.error('[proxy] OpenRouter error:', err)
|
|
if (!clientDisconnected) {
|
|
res.write(`data: ${JSON.stringify({ error: `OpenRouter proxy error: ${err instanceof Error ? err.message : String(err)}` })}\n\n`)
|
|
res.write('data: [DONE]\n\n')
|
|
res.end()
|
|
}
|
|
}
|
|
}
|
|
|
|
const server = createServer((req, res) => {
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204, {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
})
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
if (req.method !== 'POST' || (req.url !== '/v1/messages' && req.url !== '/v1/openrouter')) {
|
|
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ error: 'Not found' }))
|
|
return
|
|
}
|
|
|
|
let body = ''
|
|
req.on('data', (chunk) => { body += chunk })
|
|
req.on('end', async () => {
|
|
if (req.url === '/v1/openrouter') {
|
|
console.log('[proxy] → OpenRouter proxy')
|
|
streamOpenRouterProxy(body, res)
|
|
return
|
|
}
|
|
try {
|
|
const payload = JSON.parse(body)
|
|
const { model, messages, system, webSearch } = payload
|
|
|
|
// Get a fresh credential on every request (handles token refresh)
|
|
const credential = await getAnthropicCredential()
|
|
|
|
if (credential) {
|
|
// Direct Anthropic API path — always preferred (no CLI needed)
|
|
console.log(`[proxy] → API ${mapModelToApi(model)}${webSearch ? ' [WebSearch]' : ''}`)
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
'Access-Control-Allow-Origin': '*',
|
|
'X-Accel-Buffering': 'no',
|
|
})
|
|
await streamViaAnthropicApi(credential, model, system, messages ?? [], res, webSearch === true)
|
|
return
|
|
}
|
|
|
|
// 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'
|
|
: 'sonnet'
|
|
|
|
const history = (messages ?? []) as { role: string; content: string }[]
|
|
const userMessages = history.filter((m) => m.role === 'user')
|
|
const lastUserMsg = userMessages[userMessages.length - 1]?.content ?? ''
|
|
|
|
const contextParts: string[] = []
|
|
if (system) contextParts.push(system)
|
|
const prior = history.slice(0, -1)
|
|
if (prior.length > 0) {
|
|
contextParts.push(
|
|
'Conversation so far:\n' +
|
|
prior.map((m) => `${m.role}: ${m.content}`).join('\n')
|
|
)
|
|
}
|
|
|
|
const systemPrompt = contextParts.length > 0 ? contextParts.join('\n\n') : undefined
|
|
|
|
const args = ['-p', '--model', modelFlag]
|
|
if (systemPrompt) args.push('--system-prompt', systemPrompt)
|
|
if (webSearch === true) {
|
|
args.push('--allowed-tools', 'WebSearch', 'WebFetch')
|
|
args.push('--permission-mode', 'dontAsk')
|
|
}
|
|
args.push('--', lastUserMsg)
|
|
|
|
console.log(`[proxy] → claude -p --model ${modelFlag}${webSearch ? ' [WebSearch]' : ''} "${lastUserMsg.slice(0, 60)}..."`)
|
|
|
|
const procEnv = { ...process.env, NO_COLOR: '1', TERM: 'dumb' }
|
|
delete procEnv.CLAUDECODE
|
|
delete procEnv.CLAUDE_CODE
|
|
delete procEnv.ANTHROPIC_CLAUDE_CODE
|
|
delete procEnv.CLAUDE_CODE_ENTRYPOINT
|
|
if (webSearch === true) {
|
|
delete procEnv.DISALLOWED_TOOLS
|
|
}
|
|
const proc = spawn(CLAUDE_BIN, args, {
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
env: procEnv,
|
|
detached: false,
|
|
})
|
|
|
|
proc.stdin.end('')
|
|
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
'Access-Control-Allow-Origin': '*',
|
|
'X-Accel-Buffering': 'no',
|
|
})
|
|
|
|
let fullOutput = ''
|
|
let clientDisconnected = false
|
|
|
|
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. Set ANTHROPIC_API_KEY or VITE_OPENROUTER_API_KEY in .env.local' } })}\n\n`)
|
|
res.write('data: [DONE]\n\n')
|
|
res.end()
|
|
}
|
|
}, 30000)
|
|
|
|
proc.stdout.on('data', (chunk: Buffer) => {
|
|
const text = chunk.toString()
|
|
fullOutput += text
|
|
console.log(`[proxy] stdout +${text.length}b total=${fullOutput.length}b`)
|
|
|
|
if (!clientDisconnected) {
|
|
const sseData = {
|
|
type: 'content_block_delta',
|
|
delta: { type: 'text_delta', text },
|
|
}
|
|
res.write(`data: ${JSON.stringify(sseData)}\n\n`)
|
|
}
|
|
})
|
|
|
|
proc.stderr.on('data', (chunk: Buffer) => {
|
|
const msg = chunk.toString().trim()
|
|
if (msg) console.error('[proxy] stderr:', msg)
|
|
})
|
|
|
|
proc.on('error', (err) => {
|
|
clearTimeout(cliTimeout)
|
|
console.error('[proxy] spawn error:', err)
|
|
if (!clientDisconnected) {
|
|
const errData = {
|
|
type: 'error',
|
|
error: { message: `Spawn error: ${err.message}` },
|
|
}
|
|
res.write(`data: ${JSON.stringify(errData)}\n\n`)
|
|
res.write('data: [DONE]\n\n')
|
|
res.end()
|
|
}
|
|
})
|
|
|
|
proc.on('close', (code, signal) => {
|
|
clearTimeout(cliTimeout)
|
|
console.log(`[proxy] ← exit code=${code} signal=${signal} output=${fullOutput.length}b`)
|
|
if (!clientDisconnected) {
|
|
res.write('data: [DONE]\n\n')
|
|
res.end()
|
|
}
|
|
})
|
|
|
|
res.on('close', () => {
|
|
clearTimeout(cliTimeout)
|
|
clientDisconnected = true
|
|
if (proc.exitCode === null && !proc.killed) {
|
|
console.log('[proxy] Client disconnected, killing process')
|
|
proc.kill('SIGTERM')
|
|
}
|
|
})
|
|
|
|
} catch (err) {
|
|
console.error('[proxy] Parse error:', err)
|
|
res.writeHead(400, {
|
|
'Content-Type': 'application/json',
|
|
'Access-Control-Allow-Origin': '*',
|
|
})
|
|
res.end(JSON.stringify({ error: String(err) }))
|
|
}
|
|
})
|
|
})
|
|
|
|
server.listen(PORT, async () => {
|
|
console.log(`\n Claude proxy → http://localhost:${PORT}`)
|
|
|
|
const credential = await getAnthropicCredential()
|
|
if (credential) {
|
|
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`)
|
|
console.log(` Fallback: Claude CLI (${CLAUDE_BIN})`)
|
|
}
|
|
console.log(` OpenRouter proxy: ${OPENROUTER_API_KEY ? 'enabled' : 'add OPENROUTER_API_KEY to .env.local'}`)
|
|
console.log()
|
|
})
|