Files
archy/packages/app/server/claude-proxy.ts
T
DorianandClaude Opus 4.6 c82b4ef54f feat(app): redesign magazine grid, improve article views and proxy
- Redesign MagazineGrid with editorial New Yorker-inspired layout
- Simplify ArticleDetail and ArticleOverlay components
- Enhance claude-proxy with improved content extraction
- Add HTML utility for content processing
- Update NewsCard styling and chat message handling
- Clean up worktree references

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:50:46 +00:00

450 lines
14 KiB
TypeScript

import { spawn } from 'child_process'
import { createServer } from 'http'
import { readFileSync, 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'
/** 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
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 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 = {
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-20250514'
if (model?.includes('haiku')) return 'claude-3-5-haiku-20241022'
return 'claude-sonnet-4-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(
model: string,
system: string | undefined,
messages: { role: string; content: string }[],
res: import('http').ServerResponse,
): 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 = 5
let rounds = 0
while (rounds < maxToolRounds) {
rounds++
const body: Record<string, unknown> = {
model: apiModel,
max_tokens: 4096,
system,
messages: turnMessages,
tools: [SEARCH_WEB_TOOL],
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
}
if (isOAuthToken(ANTHROPIC_CREDENTIAL!)) {
headers['Authorization'] = `Bearer ${ANTHROPIC_CREDENTIAL}`
headers['anthropic-beta'] = 'oauth-2025-04-20'
} else {
headers['x-api-key'] = ANTHROPIC_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()
}
}
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', () => {
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
const useTools = webSearch === true && !!ANTHROPIC_CREDENTIAL
if (useTools) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'X-Accel-Buffering': 'no',
})
streamViaAnthropicApi(model, system, messages ?? [], res)
return
}
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'
: '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' }
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
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) => {
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) => {
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', () => {
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, () => {
console.log(`\n Claude proxy → http://localhost:${PORT}`)
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(` Tool use: add ANTHROPIC_TOKEN (Max) or ANTHROPIC_API_KEY to .env.local`)
}
console.log(` OpenRouter proxy: ${OPENROUTER_API_KEY ? 'enabled' : 'add OPENROUTER_API_KEY to .env.local'}\n`)
})