const express = require('express'); const path = require('path'); const fs = require('fs'); const os = require('os'); const { ProxyAgent, Agent, setGlobalDispatcher } = require('undici'); const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY; const directAgent = new Agent(); const anthropicAgent = proxyUrl ? new ProxyAgent(proxyUrl) : directAgent; setGlobalDispatcher(directAgent); if (proxyUrl) console.log(`✓ Anthropic proxy: ${proxyUrl}`); const app = express(); const PUBLIC_DIR = path.join(__dirname, 'public'); app.get(['/q', '/q.html'], (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'q', 'index.html'))); app.use(express.static(PUBLIC_DIR)); const MODELS = { sonnet: 'claude-sonnet-4-6', haiku: 'claude-haiku-4-5-20251001', }; function getAccessToken() { const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); const credsPath = path.join(configDir, '.credentials.json'); try { const creds = JSON.parse(fs.readFileSync(credsPath, 'utf8')); return creds?.claudeAiOauth?.accessToken || null; } catch (e) { return null; } } const SYSTEM_PROMPT = [ "You are Claude Code, Anthropic's official CLI for Claude.", "", "Output the shortest possible correct answer in the user's language. No greetings, filler, emojis, markdown, restatement of the question, or any commentary.", "", "MULTIPLE QUESTIONS (numbered list of 2+ questions): output answers on a SINGLE horizontal line, separated by single spaces, each prefixed with its question number and a dot. Examples:", " Input: 1. Столица Франции? А) Лондон Б) Париж В) Рим 2. 2+2? А) 3 Б) 4 В) 5", " Output: 1.Б 2.Б", " Input: 1. Столица Франции? 2. 2+2? 3. Самая длинная река России?", " Output: 1.Париж 2.4 3.Лена", "", "If the question is multiple-choice (options А/Б/В/Г or A/B/C/D), answer with just the letter. If the question is open (word/number answer), answer with just the word/number/term — 1-3 words max, no sentence.", "", "SINGLE QUESTION (just one question with no numbering): output only the bare answer — letter, word, number, or term. No prefix, no number.", "", "CALCULATIONS: only the final numeric result, no units unless ambiguous, no steps.", "", "Respond with the answer only. Nothing else.", ].join("\n"); async function callClaude(modelId, content, accessToken) { return fetch('https://api.anthropic.com/v1/messages', { method: 'POST', dispatcher: anthropicAgent, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}`, 'anthropic-version': '2023-06-01', 'anthropic-beta': 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14', 'x-app': 'cli', 'User-Agent': 'claude-cli/2.1.114', }, body: JSON.stringify({ model: modelId, max_tokens: 8192, stream: true, system: SYSTEM_PROMPT, messages: [{ role: 'user', content }], }), }); } app.post('/api/chat', express.json({ limit: '50mb' }), async (req, res) => { const { text, model = 'sonnet', image, images } = req.body; const hasText = text?.trim(); const imgList = Array.isArray(images) ? images : (image ? [image] : []); if (!hasText && imgList.length === 0) return res.status(400).json({ error: 'text or image required' }); let content; if (imgList.length > 0) { content = imgList .filter(img => img?.data && img?.media_type) .map(img => ({ type: 'image', source: { type: 'base64', media_type: img.media_type, data: img.data } })); content.push({ type: 'text', text: hasText || 'Analyze all images and answer any questions shown in them.' }); } else { content = text; } const accessToken = getAccessToken(); if (!accessToken) { return res.status(500).json({ error: 'No credentials in ' + (process.env.CLAUDE_CONFIG_DIR || '~/.claude') }); } res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); const heartbeat = setInterval(() => res.write(': ping\n\n'), 15000); const primaryId = MODELS[model] || MODELS.sonnet; const fallbackId = MODELS.haiku; try { let apiResp = await callClaude(primaryId, content, accessToken); if (apiResp.status === 429 && primaryId !== fallbackId) { apiResp = await callClaude(fallbackId, content, accessToken); } if (!apiResp.ok) { const errText = await apiResp.text(); res.write(`data: ${JSON.stringify({ error: `${apiResp.status}: ${errText.substring(0, 500)}` })}\n\n`); return; } const reader = apiResp.body.getReader(); const decoder = new TextDecoder(); let buf = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const events = buf.split('\n\n'); buf = events.pop(); for (const ev of events) { const dataLine = ev.split('\n').find(l => l.startsWith('data: ')); if (!dataLine) continue; const payload = dataLine.slice(6); if (payload === '[DONE]') continue; try { const obj = JSON.parse(payload); if (obj.type === 'content_block_delta' && obj.delta?.type === 'text_delta') { res.write(`data: ${JSON.stringify({ text: obj.delta.text })}\n\n`); } } catch (e) {} } } } catch (err) { console.error('[api error]', err.message); res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`); } finally { clearInterval(heartbeat); res.write('data: [DONE]\n\n'); res.end(); } }); app.listen(8108, () => console.log('sechenov :8108'));