137 lines
5.5 KiB
JavaScript
137 lines
5.5 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.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, 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();
|
||
}
|
||
});
|
||
|
||
app.listen(8108, () => console.log('sechenov :8108'));
|