diff --git a/packages/app/server/claude-proxy.ts b/packages/app/server/claude-proxy.ts index 9c7df5ac..556b033e 100644 --- a/packages/app/server/claude-proxy.ts +++ b/packages/app/server/claude-proxy.ts @@ -1,6 +1,6 @@ import { spawn } from 'child_process' import { createServer } from 'http' -import { readFileSync, writeFileSync, existsSync } from 'fs' +import { readFileSync, existsSync } from 'fs' import { resolve, dirname } from 'path' import { fileURLToPath } from 'url' @@ -34,132 +34,13 @@ 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 = {} - 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 { - 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 { - // 1. Env vars (highest priority — explicit override) +/** API key (sk-ant-api03-...) or OAuth token (sk-ant-oat...) from Max subscription */ +function getAnthropicCredential(): string | undefined { 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)) { @@ -172,10 +53,11 @@ async function getAnthropicCredential(): Promise { } } catch { /* ignore */ } } - return undefined } +const ANTHROPIC_CREDENTIAL = getAnthropicCredential() +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 = { @@ -194,9 +76,9 @@ const SEARCH_WEB_TOOL = { } 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' + if (model?.includes('opus')) return 'claude-opus-4-20250514' + if (model?.includes('haiku')) return 'claude-3-5-haiku-20241022' + return 'claude-sonnet-4-20250514' } async function runSearchWeb(query: string): Promise { @@ -219,12 +101,10 @@ async function runSearchWeb(query: string): Promise { } async function streamViaAnthropicApi( - credential: string, model: string, system: string | undefined, messages: { role: string; content: string }[], res: import('http').ServerResponse, - webSearch: boolean, ): Promise { const apiModel = mapModelToApi(model) const apiMessages = messages.map((m) => ({ @@ -248,7 +128,7 @@ async function streamViaAnthropicApi( } let turnMessages = [...apiMessages] - const maxToolRounds = webSearch ? 5 : 1 + const maxToolRounds = 5 let rounds = 0 while (rounds < maxToolRounds) { @@ -258,20 +138,18 @@ async function streamViaAnthropicApi( max_tokens: 4096, system, messages: turnMessages, - } - if (webSearch) { - body.tools = [SEARCH_WEB_TOOL] + tools: [SEARCH_WEB_TOOL], } const headers: Record = { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01', } - if (isOAuthToken(credential)) { - headers['Authorization'] = `Bearer ${credential}` + if (isOAuthToken(ANTHROPIC_CREDENTIAL!)) { + headers['Authorization'] = `Bearer ${ANTHROPIC_CREDENTIAL}` headers['anthropic-beta'] = 'oauth-2025-04-20' } else { - headers['x-api-key'] = credential + headers['x-api-key'] = ANTHROPIC_CREDENTIAL! } const apiRes = await fetch('https://api.anthropic.com/v1/messages', { @@ -325,6 +203,80 @@ async function streamViaAnthropicApi( } } +async function streamOpenRouterProxy( + reqBody: string, + res: import('http').ServerResponse, +): Promise { + 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, { @@ -336,7 +288,7 @@ const server = createServer((req, res) => { return } - if (req.method !== 'POST' || req.url !== '/v1/messages') { + 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 @@ -344,17 +296,19 @@ const server = createServer((req, res) => { let body = '' req.on('data', (chunk) => { body += chunk }) - req.on('end', async () => { + req.on('end', () => { + 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() + const useTools = webSearch === true && !!ANTHROPIC_CREDENTIAL - if (credential) { - // Direct Anthropic API path — always preferred (no CLI needed) - console.log(`[proxy] → API ${mapModelToApi(model)}${webSearch ? ' [WebSearch]' : ''}`) + if (useTools) { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -362,12 +316,13 @@ const server = createServer((req, res) => { 'Access-Control-Allow-Origin': '*', 'X-Accel-Buffering': 'no', }) - await streamViaAnthropicApi(credential, model, system, messages ?? [], res, webSearch === true) + streamViaAnthropicApi(model, system, messages ?? [], res) return } - // Fallback: Claude CLI (works when logged in from terminal) - console.log('[proxy] No API credential — falling back to Claude CLI') + if (webSearch === true && !ANTHROPIC_CREDENTIAL) { + console.log('[proxy] webSearch: using CLI built-in WebSearch + pre-fetched context') + } const modelFlag = model?.includes('opus') ? 'opus' : model?.includes('haiku') ? 'haiku' @@ -400,10 +355,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' } - delete procEnv.CLAUDECODE - delete procEnv.CLAUDE_CODE - delete procEnv.ANTHROPIC_CLAUDE_CODE - delete procEnv.CLAUDE_CODE_ENTRYPOINT if (webSearch === true) { delete procEnv.DISALLOWED_TOOLS } @@ -426,16 +377,6 @@ const server = createServer((req, res) => { 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 in .env.local or log in with: claude login' } })}\n\n`) - res.write('data: [DONE]\n\n') - res.end() - } - }, 30000) - proc.stdout.on('data', (chunk: Buffer) => { const text = chunk.toString() fullOutput += text @@ -456,7 +397,6 @@ const server = createServer((req, res) => { }) proc.on('error', (err) => { - clearTimeout(cliTimeout) console.error('[proxy] spawn error:', err) if (!clientDisconnected) { const errData = { @@ -470,7 +410,6 @@ const server = createServer((req, res) => { }) 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') @@ -479,7 +418,6 @@ const server = createServer((req, res) => { }) res.on('close', () => { - clearTimeout(cliTimeout) clientDisconnected = true if (proc.exitCode === null && !proc.killed) { console.log('[proxy] Client disconnected, killing process') @@ -498,18 +436,14 @@ const server = createServer((req, res) => { }) }) -server.listen(PORT, async () => { +server.listen(PORT, () => { 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)`) + console.log(` Binary: ${CLAUDE_BIN}`) + if (ANTHROPIC_CREDENTIAL) { + const mode = isOAuthToken(ANTHROPIC_CREDENTIAL) ? 'OAuth (Max)' : 'API key' + console.log(` Tool use (search_web): enabled (${mode})`) } 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(` Tool use: add ANTHROPIC_TOKEN (Max) or ANTHROPIC_API_KEY to .env.local`) } - console.log() + console.log(` OpenRouter proxy: ${OPENROUTER_API_KEY ? 'enabled' : 'add OPENROUTER_API_KEY to .env.local'}\n`) })