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; // Force global default = DIRECT. Anthropic calls override with their own dispatcher. setGlobalDispatcher(directAgent); if (proxyUrl) console.log(`✓ Anthropic proxy: ${proxyUrl} (sechenov: direct)`); const app = express(); const PUBLIC_DIR = path.join(__dirname, 'public'); const UPSTREAM = 'https://student.sechenov.ru'; // ══════════════════════════════════════════════════════════════ // CLAUDE CHAT (/api/chat) // ══════════════════════════════════════════════════════════════ 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, text, 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: text }], }), }); } app.post('/api/chat', express.json({ limit: '10mb' }), async (req, res) => { const { text, model = 'sonnet' } = req.body; if (!text?.trim()) return res.status(400).json({ error: 'text required' }); 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, text, accessToken); if (apiResp.status === 429 && primaryId !== fallbackId) { apiResp = await callClaude(fallbackId, text, 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(); } }); // ══════════════════════════════════════════════════════════════ // LEGACY / LOCAL ROUTES // ══════════════════════════════════════════════════════════════ app.get(['/old', '/old.html'], (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'index.html'))); app.get(['/iframe', '/iframe.html'], (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'iframe.html'))); app.use('/assets', express.static(path.join(PUBLIC_DIR, 'assets'))); // Legacy HTML-only proxy used by /iframe.html app.get('/proxy/*', async (req, res) => { const upstreamPath = req.url.replace(/^\/proxy/, '') || '/'; try { const r = await fetch(UPSTREAM + upstreamPath, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept': req.headers.accept || '*/*', 'Accept-Language': req.headers['accept-language'] || 'ru,en;q=0.9', }, }); const ct = r.headers.get('content-type') || 'application/octet-stream'; res.status(r.status); res.setHeader('Content-Type', ct); res.removeHeader('X-Frame-Options'); res.removeHeader('Content-Security-Policy'); if (ct.includes('text/html')) { let html = await r.text(); html = html .replace(/\b(href|action)="\/(?!\/)/g, '$1="/proxy/') .replace(/\bsrc="\/(?!\/)/g, 'src="' + UPSTREAM + '/') .replace(/url\(["']?\/(?!\/)/g, 'url(' + UPSTREAM + '/') .replace(/\btarget=["'](_top|_parent|_blank)["']/gi, '') .replace(/]+http-equiv=["']?(Content-Security-Policy|X-Frame-Options)["']?[^>]*>/gi, '') .replace(/\b(document|window|top|self)\.location(\.href)?\s*=\s*(['"])\/(?!\/)/g, "document.location.href=$3/proxy/") .replace(/\blocation\.replace\(\s*(['"])\/(?!\/)/g, "location.replace($1/proxy/"); html = html.replace(/
]*>/i, m => m + '\n