Add setCorsHeaders() and handleCorsOptions() helpers in server/dev-auth.ts. Replace wildcard CORS origin with http://localhost:5173 in all Vite plugins and claude-proxy.ts. Include Authorization in allowed CORS headers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
453 lines
14 KiB
TypeScript
453 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'
|
|
import { validateDevAuth, handleCorsOptions } from './dev-auth.js'
|
|
|
|
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': 'http://localhost:5173' })
|
|
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': 'http://localhost:5173',
|
|
'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') {
|
|
handleCorsOptions(res)
|
|
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
|
|
}
|
|
|
|
if (!validateDevAuth(req, res)) 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': 'http://localhost:5173',
|
|
'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' }
|
|
// Allow CLI to spawn even when dev server is started from inside Claude Code
|
|
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': 'http://localhost:5173',
|
|
'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': 'http://localhost:5173',
|
|
})
|
|
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`)
|
|
})
|