Files
sechenov/server.js

355 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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', '/mod/quiz/attempt.php'], (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'q', 'index.html')));
app.get('/auth.php', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'index.html')));
app.use(express.static(PUBLIC_DIR));
const MODELS = {
opus: 'claude-opus-4-7',
sonnet: 'claude-sonnet-4-6',
haiku: 'claude-haiku-4-5-20251001',
};
const THINKING_MODELS = new Set(['claude-opus-4-7']);
const THINKING_BUDGET_TOKENS = 16000; // "high effort"
const CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const CREDS_PATH = path.join(CONFIG_DIR, '.credentials.json');
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
const REFRESH_SKEW_MS = 5 * 60 * 1000;
function readCreds() {
try { return JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8')); } catch { return null; }
}
function writeCreds(creds) {
const tmp = CREDS_PATH + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(creds, null, 2));
fs.renameSync(tmp, CREDS_PATH);
}
async function refreshTokens(refreshToken) {
const resp = await fetch('https://console.anthropic.com/v1/oauth/token', {
method: 'POST',
dispatcher: anthropicAgent,
headers: {
'Content-Type': 'application/json',
'User-Agent': 'claude-cli/2.1.114 (external, cli)',
'Accept': '*/*',
'anthropic-beta': 'oauth-2025-04-20',
},
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: OAUTH_CLIENT_ID,
}),
});
if (!resp.ok) throw new Error(`refresh ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
return resp.json();
}
let refreshInFlight = null;
async function ensureFreshToken() {
const creds = readCreds();
if (!creds?.claudeAiOauth) return null;
const t = creds.claudeAiOauth;
if (t.expiresAt && t.expiresAt > Date.now() + REFRESH_SKEW_MS) return t.accessToken;
// Serialize concurrent refreshes
if (!refreshInFlight) {
refreshInFlight = (async () => {
// Re-read: another fetch may have refreshed while we waited
const latest = readCreds();
if (latest.claudeAiOauth.expiresAt > Date.now() + REFRESH_SKEW_MS) return;
console.log('[oauth] refreshing token…');
const r = await refreshTokens(latest.claudeAiOauth.refreshToken);
latest.claudeAiOauth.accessToken = r.access_token;
latest.claudeAiOauth.refreshToken = r.refresh_token;
latest.claudeAiOauth.expiresAt = Date.now() + r.expires_in * 1000;
writeCreds(latest);
console.log(`[oauth] ✓ refreshed, expires ${new Date(latest.claudeAiOauth.expiresAt).toISOString()}`);
})().finally(() => { refreshInFlight = null; });
}
try { await refreshInFlight; } catch (e) { console.error('[oauth] refresh failed:', e.message); }
return readCreds()?.claudeAiOauth?.accessToken || null;
}
// keep old sync name for compatibility
function getAccessToken() {
return readCreds()?.claudeAiOauth?.accessToken || 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) {
const body = {
model: modelId,
max_tokens: THINKING_MODELS.has(modelId) ? THINKING_BUDGET_TOKENS + 4096 : 8192,
stream: true,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content }],
};
if (THINKING_MODELS.has(modelId)) {
body.thinking = { type: 'enabled', budget_tokens: THINKING_BUDGET_TOKENS };
}
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(body),
});
}
app.post('/api/chat', express.json({ limit: '50mb' }), async (req, res) => {
const { text, model = 'opus', 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 = await ensureFreshToken();
if (!accessToken) {
return res.status(500).json({ error: 'No credentials in ' + CONFIG_DIR });
}
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();
}
});
// ══════════════════════════════════════════════════════════════
// OPENAI-COMPATIBLE ENDPOINT (/v1/chat/completions)
// Drop-in replacement for OpenRouter. Map ext/ or any OpenAI
// client here and it'll route through our Claude Max account.
// ══════════════════════════════════════════════════════════════
const CC_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude.";
function mapModel(name) {
if (!name) return MODELS.opus;
const n = String(name).toLowerCase();
if (n.includes('opus')) return MODELS.opus;
if (n.includes('haiku')) return MODELS.haiku;
if (n.includes('sonnet')) return MODELS.sonnet;
if (MODELS[n]) return MODELS[n];
return MODELS.opus;
}
function translateContent(content) {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return String(content ?? '');
return content.map(b => {
if (!b || typeof b !== 'object') return { type: 'text', text: String(b ?? '') };
if (b.type === 'text') return { type: 'text', text: b.text || '' };
if (b.type === 'image_url') {
const url = b.image_url?.url || '';
const m = url.match(/^data:([^;]+);base64,(.+)$/);
if (m) return { type: 'image', source: { type: 'base64', media_type: m[1], data: m[2] } };
return { type: 'text', text: '[image: remote URL, not supported]' };
}
if (b.type === 'image' && b.source) return b;
return { type: 'text', text: JSON.stringify(b).slice(0, 200) };
});
}
app.post('/v1/chat/completions', express.json({ limit: '50mb' }), async (req, res) => {
const { model, messages = [], max_tokens = 4096 } = req.body || {};
const systemParts = [CC_SYSTEM_PREFIX];
const anthMessages = [];
for (const m of messages) {
if (!m || !m.role) continue;
if (m.role === 'system') {
const s = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
if (s && s.trim()) systemParts.push(s.trim());
} else if (m.role === 'user' || m.role === 'assistant') {
anthMessages.push({ role: m.role, content: translateContent(m.content) });
}
}
if (anthMessages.length === 0) return res.status(400).json({ error: { message: 'messages required' } });
const accessToken = await ensureFreshToken();
if (!accessToken) return res.status(500).json({ error: { message: 'no credentials' } });
const modelId = mapModel(model);
try {
const upstream = await 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: THINKING_MODELS.has(modelId) ? Math.max(max_tokens, THINKING_BUDGET_TOKENS + 2048) : max_tokens,
stream: false,
system: systemParts.join('\n\n'),
messages: anthMessages,
...(THINKING_MODELS.has(modelId) ? { thinking: { type: 'enabled', budget_tokens: THINKING_BUDGET_TOKENS } } : {}),
}),
});
if (!upstream.ok) {
const err = await upstream.text();
return res.status(upstream.status).json({
error: { message: err.slice(0, 800), type: 'upstream_error', code: upstream.status },
});
}
const data = await upstream.json();
const text = (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('');
res.json({
id: data.id || ('chatcmpl-' + Date.now()),
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: model || modelId,
choices: [{
index: 0,
message: { role: 'assistant', content: text },
finish_reason: data.stop_reason === 'end_turn' ? 'stop' : (data.stop_reason || 'stop'),
}],
usage: {
prompt_tokens: data.usage?.input_tokens || 0,
completion_tokens: data.usage?.output_tokens || 0,
total_tokens: (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0),
},
});
} catch (e) {
res.status(500).json({ error: { message: e.message, type: 'proxy_error' } });
}
});
// ══════════════════════════════════════════════════════════════
// TELEGRAM NOTIFY (/api/notify)
// ══════════════════════════════════════════════════════════════
const TG_BOT_TOKEN = '8489078585:AAH3PrYNBiX-YMZA9XgHT2rE6yaK8BqwCOI';
const TG_CHAT_ID = '6606444796';
const TG_MESSAGE = 'я сел за комп, 5 мин жди и начинай';
async function tgSend() {
// Try direct first, fall back to VPS proxy
const url = `https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage`;
const init = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: TG_CHAT_ID, text: TG_MESSAGE }),
};
try {
const r = await fetch(url, init);
return { ok: r.ok, status: r.status, via: 'direct' };
} catch (e1) {
try {
const r = await fetch(url, { ...init, dispatcher: anthropicAgent });
return { ok: r.ok, status: r.status, via: 'proxy' };
} catch (e2) {
return { ok: false, error: `direct: ${e1.message}; proxy: ${e2.message}` };
}
}
}
app.post('/api/notify', async (_req, res) => {
res.json(await tgSend());
});
app.listen(8108, () => console.log('sechenov :8108'));