revert(proxy): restore working CLI-based proxy from c82b4ef

Reverts all proxy changes back to the last known working version.
The proxy uses Claude CLI for all requests and only uses the Anthropic
API for web search tool calling when a credential is available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-04 07:41:47 +00:00
co-authored by Claude Opus 4.6
parent 9263d7bb26
commit b08fd86f41
+107 -173
View File
@@ -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<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)
/** 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<string | undefined> {
}
} 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<string> {
@@ -219,12 +101,10 @@ async function runSearchWeb(query: string): Promise<string> {
}
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) => ({
@@ -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<string, string> = {
'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<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, {
@@ -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`)
})