352 lines
15 KiB
JavaScript
352 lines
15 KiB
JavaScript
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.redirect(302, 'https://student.sechenov.ru/auth.php'));
|
||
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 = '833411630';
|
||
const TG_MESSAGE = 'начинай решать через 5 мин';
|
||
|
||
async function tgSend() {
|
||
// Fresh ProxyAgent per call to avoid stale connections
|
||
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 freshProxy = proxyUrl ? new ProxyAgent(proxyUrl) : undefined;
|
||
const r = await fetch(url, { ...init, dispatcher: freshProxy });
|
||
return { ok: r.ok, status: r.status, via: 'proxy-fresh' };
|
||
} catch (e) {
|
||
const details = e.cause ? `${e.message} | cause: ${e.cause.message || e.cause.code}` : e.message;
|
||
return { ok: false, error: details };
|
||
}
|
||
}
|
||
|
||
app.post('/api/notify', async (_req, res) => {
|
||
res.json(await tgSend());
|
||
});
|
||
|
||
app.listen(8108, () => console.log('sechenov :8108'));
|