Files
deepseek-api/server.mjs
T
Alex CubeandClaude Opus 5.5 1cd1ec33a7 feat: OpenAI /v1 с эмуляцией вызова инструментов
Добавлены POST /v1/chat/completions и GET /v1/models. Описания инструментов
из поля tools вклеиваются в текст промпта, DeepSeek просят звать их блоком
<tool_call>{json}</tool_call>, а ответ разбирается обратно в OpenAI tool_calls.
Парсятся два формата: наш <tool_call> и родной DeepSeek invoke/parameter.

Позволяет подключать агентов на OpenAI-протоколе (например, Hermes) с рабочими
вызовами инструментов поверх веб-чата DeepSeek.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011W17JPwr6FwxMAKAHtgEwT
2026-09-25 23:35:43 +03:00

488 lines
25 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.
#!/usr/bin/env node
// DeepSeek, притворяющийся Ollama.
//
// Веб-чат DeepSeek — не API: у него нет ни ключа, ни эндпойнта, зато есть
// вкладка в браузере, куда можно писать. Этот сервис поднимает такую вкладку
// и отдаёт наружу протокол Ollama: /api/tags и /api/chat. Любая программа,
// которая уже умеет ходить в Ollama, подключается сюда сменой одного адреса.
//
// Здесь только HTTP и перевод формата. Всё, что касается браузера и разметки
// DeepSeek, живёт в browser.mjs.
//
// Зависимостей нет: Node 22+ со встроенными fetch и WebSocket.
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { configure, ask, probe, revive, browserAlive } from './browser.mjs';
// fileURLToPath, а не разбор pathname вручную: путь проекта может содержать «#»,
// и в URL он приезжает как %23 — самодельный разбор ломается именно на нём.
const HERE = dirname(fileURLToPath(import.meta.url));
const cfg = {
port: 11500,
host: '0.0.0.0',
cdpPort: 9333,
browserExe: '',
profileDir: '',
chatUrl: 'https://chat.deepseek.com/',
timeout: 300,
// Сторож: поднимать браузер и вкладку сам, не дожидаясь вопроса. Иначе
// восстановление происходит внутри запроса, и клиент платит за него
// 15-60 секундами своего времени ожидания.
watchdog: true,
watchdogSec: 30,
// Потолок отсрочки. Если браузер не поднимается (обычно профиль уже открыт
// другим окном без отладочного порта), проверки разрежаются вдвое каждый
// раз — иначе сторож плодил бы процессы каждые полминуты без конца.
watchdogMaxSec: 300,
...JSON.parse(await readFile(join(HERE, 'config.json'), 'utf8').catch(() => '{}')),
// Личные пути — сюда: этот файл в репозиторий не попадает, и обновление
// проекта не затрёт настройки конкретной машины.
...JSON.parse(await readFile(join(HERE, 'config.local.json'), 'utf8').catch(() => '{}')),
};
if (process.env.DEEPSEEK_API_PORT) cfg.port = Number(process.env.DEEPSEEK_API_PORT);
if (process.env.DEEPSEEK_CDP_PORT) cfg.cdpPort = Number(process.env.DEEPSEEK_CDP_PORT);
configure(cfg);
// Модель одна: выбор из трёх (Instant / Expert / Vision) DeepSeek из веб-морды
// убрал, оставшаяся универсальна — она же разбирает картинки. Имя модели,
// которое пришлёт клиент, мы не смотрим вовсе.
const MODEL = 'deepseek:chat';
// Состояние для индикаторов. Обновляется сторожем и запросами; отдаётся
// по /api/state, НЕ трогая вкладку — на него можно смотреть хоть раз в секунду.
const state = {
browser: false, // отвечает ли отладочный порт
ready: false, // браузер поднят и вкладка с чатом на месте
busy: false, // прямо сейчас идёт запрос
queue: 0, // сколько ждёт в очереди
since: null, // когда начался текущий запрос
error: null, // последняя беда, понятным текстом
checked: null, // когда сторож смотрел в последний раз
};
// Вкладка одна, и два запроса разом затрут друг другу поле ввода. Поэтому
// очередь: запросы идут строго по одному.
// ponytail: очередь без ограничения длины — если клиент завалит сервис
// запросами, они просто будут ждать. Появится нужда — резать по длине очереди.
let chain = Promise.resolve();
function serial(fn) {
state.queue++;
const next = chain.then(fn, fn);
chain = next.catch(() => {});
next.then(() => { state.queue--; }, () => { state.queue--; });
return next;
}
// ------------------------------------------------------------------ сторож
// Сторож ходит через ТУ ЖЕ очередь, что и запросы. Иначе он полез бы в браузер
// в тот момент, когда DeepSeek дописывает ответ, — а вкладка одна.
let watchTimer = null;
let watchDelay = 0;
function scheduleWatch(delaySec) {
if (!cfg.watchdog) return;
clearTimeout(watchTimer);
watchDelay = delaySec;
watchTimer = setTimeout(runWatch, delaySec * 1000);
watchTimer.unref?.(); // сторож не должен держать процесс живым
}
async function runWatch() {
// Идёт запрос — он сам всё поднимет и проверит. Не мешаем и не ждём очереди:
// на длинном ответе сторож простоял бы минуты без всякой пользы.
if (state.busy) return scheduleWatch(cfg.watchdogSec);
const r = await serial(() => revive(m => console.log(` сторож: ${m}`)));
state.checked = now();
state.browser = r.browser;
state.ready = r.ok;
state.error = r.ok ? null : r.error;
if (r.ok) return scheduleWatch(cfg.watchdogSec);
// Не поднялось — разрежаем проверки. Чаще всего причина не лечится сама:
// профиль открыт другим окном без отладочного порта, и человеку надо это окно
// закрыть. Долбиться каждые полминуты бессмысленно и плодит процессы.
const next = Math.min((watchDelay || cfg.watchdogSec) * 2, cfg.watchdogMaxSec);
console.error(`Сторож: ${r.error} Следующая попытка через ${next} с.`);
scheduleWatch(next);
}
// ------------------------------------------------------------------ Ollama
// Ollama принимает роли system/user/assistant отдельными сообщениями, у чата
// DeepSeek такого разделения нет — склеиваем в один текст. Роль assistant тоже
// сохраняем: в редких сценариях клиент подкладывает начало ответа.
export function flatten(messages) {
return (messages || [])
.map(m => String(m?.content || '').trim())
.filter(Boolean)
.join('\n\n');
}
// Картинка приезжает в base64 внутри сообщения — так её кладёт Ollama.
export function imageOf(messages) {
for (const m of messages || []) {
const img = (m?.images || [])[0];
if (img) return Buffer.from(String(img), 'base64');
}
return null;
}
// ──────────────────── OpenAI /v1 с эмуляцией вызова инструментов ────────────
// Агенты (Hermes и пр.) говорят по OpenAI /v1/chat/completions и передают
// инструменты в поле tools, ожидая обратно tool_calls. У веб-чата DeepSeek нет
// ни ролей, ни функций — поэтому инструменты вклеиваем в текст промпта, просим
// модель звать их блоком <tool_call>{...}</tool_call>, а ответ разбираем обратно
// в tool_calls. Ради этого вся затея: DeepSeek держит формат надёжно, а слабые
// локальные модели через настоящий tool-calling его ломали.
export const TOOL_OPEN = '<tool_call>';
export const TOOL_CLOSE = '</tool_call>';
function toolInstructions(tools) {
const list = tools.map(t => {
const f = t.function || t;
return `- ${f.name}: ${f.description || ''}\n parameters (JSON Schema): ${JSON.stringify(f.parameters || {})}`;
}).join('\n');
return [
'You have tools. Available tools:', list, '',
`To call tools, output ONLY tool-call blocks and nothing else, one per line:`,
`${TOOL_OPEN}{"name":"<tool>","arguments":{ ... }}${TOOL_CLOSE}`,
'Rules:',
'- A tool call MUST be exactly that block; arguments MUST be valid JSON.',
'- When calling tools, output no other text — no prose, no markdown fences.',
'- After tool results arrive, either call more tools or give the final answer.',
'- Give a plain-text answer (no blocks) only when the task is finished.',
].join('\n');
}
// content OpenAI бывает строкой или массивом частей (текст/картинка).
function textOf(content) {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content.map(p => (typeof p === 'string' ? p : (p?.text || ''))).filter(Boolean).join('\n');
}
return String(content ?? '');
}
function safeArgs(a) {
if (a && typeof a === 'object') return a;
try { return JSON.parse(a || '{}'); } catch { return {}; }
}
// Разворачиваем OpenAI-историю (роли, tool_calls ассистента, результаты role:tool)
// в один текст для веб-чата.
export function renderMessages(messages) {
const parts = [];
for (const m of messages || []) {
const role = m.role || 'user';
if (role === 'system') { parts.push(`# System\n${textOf(m.content)}`); continue; }
if (role === 'tool') {
const id = m.tool_call_id || m.name || '';
parts.push(`# Tool result${id ? ` (${id})` : ''}\n${textOf(m.content)}`); continue;
}
if (role === 'assistant') {
let s = '# Assistant\n';
if (m.content) s += `${textOf(m.content)}\n`;
for (const tc of m.tool_calls || []) {
const fn = tc.function || {};
s += `${TOOL_OPEN}${JSON.stringify({ name: fn.name, arguments: safeArgs(fn.arguments) })}${TOOL_CLOSE}\n`;
}
parts.push(s.trimEnd()); continue;
}
parts.push(`# User\n${textOf(m.content)}`);
}
return parts.join('\n\n');
}
const mkCall = (name, args) => ({
id: 'call_' + Math.random().toString(36).slice(2, 10),
type: 'function',
function: { name, arguments: JSON.stringify(args ?? {}) },
});
// DeepSeek не всегда слушается инструкции про <tool_call>{json}: иногда отдаёт
// свой invoke/parameter-формат (<||DSML|| invoke name="t"> с параметрами-тегами).
// Разбираем и его — иначе вызов ушёл бы наружу текстом. Толерантно к любому
// мусору вокруг слов invoke/parameter (спецсимволы-разделители игнорируем).
function parseInvokeStyle(text) {
if (!/invoke\s+name=/i.test(text)) return { calls: [], clean: text };
const calls = [];
// Каждый invoke-блок: имя + его параметры до закрытия invoke.
const blockRe = /invoke\s+name="([^"]+)"([\s\S]*?)(?=invoke\s+name="|$)/gi;
const paramRe = /parameter\s+name="([^"]+)"([^>]*)>([\s\S]*?)<\/[^>]*?parameter/gi;
let b;
while ((b = blockRe.exec(text))) {
const name = b[1];
const args = {};
let p;
while ((p = paramRe.exec(b[2]))) {
const key = p[1], meta = p[2] || '', raw = p[3].trim();
// string="true" — брать как строку; иначе пробуем распознать число/bool/JSON.
if (/string="true"/i.test(meta)) args[key] = raw;
else { try { args[key] = JSON.parse(raw); } catch { args[key] = raw; } }
}
calls.push(mkCall(name, args));
}
// Вырезаем всё, что похоже на разметку вызова, из «чистого» текста.
const clean = text.replace(/<[^>]*?(invoke|parameter|calls)[^>]*?>/gi, '')
.replace(/<\/[^>]*?(invoke|parameter|calls)[^>]*?>/gi, '').trim();
return { calls, clean };
}
// Достаём вызовы инструментов из ответа модели: сперва наш <tool_call>{json},
// затем, если пусто, — invoke/parameter-формат DeepSeek.
export function parseToolCalls(text) {
const re = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;
const calls = [];
let m;
while ((m = re.exec(text))) {
let obj;
try { obj = JSON.parse(m[1]); } catch { continue; }
if (!obj || !obj.name) continue;
calls.push(mkCall(obj.name, obj.arguments ?? obj.parameters ?? {}));
}
if (calls.length) return { calls, clean: text.replace(re, '').trim() };
return parseInvokeStyle(text);
}
function sse(res, chunks) {
res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache', Connection: 'keep-alive' });
for (const c of chunks) res.write(`data: ${JSON.stringify(c)}\n\n`);
res.write('data: [DONE]\n\n');
res.end();
}
const json = (res, code, body) => {
const text = JSON.stringify(body);
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(text) });
res.end(text);
};
async function readBody(req) {
const chunks = [];
for await (const c of req) chunks.push(c);
return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
}
const now = () => new Date().toISOString();
// Обёртка вокруг одного запроса: держит state в правде, что бы ни случилось.
async function track(fn) {
state.busy = true; state.since = Date.now(); state.error = null;
try {
const out = await fn();
state.ready = true; state.browser = true;
return out;
} catch (e) {
state.error = e.message;
state.ready = false;
throw e;
} finally {
state.busy = false; state.since = null;
}
}
// Клиенты Ollama по умолчанию просят поток и ждут не один объект, а несколько
// строк JSON подряд. Настоящего потока у нас нет — веб-чат отдаёт ответ целиком,
// — но формат соблюсти обязаны, иначе клиент не разберёт ответ вообще.
// Поэтому поток из двух строк: весь текст, затем признак конца.
function stream(res, first, last) {
res.writeHead(200, { 'Content-Type': 'application/x-ndjson; charset=utf-8' });
res.write(JSON.stringify(first) + '\n');
res.end(JSON.stringify(last) + '\n');
}
const server = createServer(async (req, res) => {
const url = new URL(req.url, 'http://x');
try {
// Проверка связи. Клиенты Ollama дёргают /api/tags, чтобы понять, жив ли
// сервер, — и здесь это должно означать «браузер на месте». Иначе клиент
// сочтёт нас живыми и повиснет на первом же запросе на все пять минут.
if (req.method === 'GET' && (url.pathname === '/api/tags' || url.pathname === '/')) {
if (!(await browserAlive())) {
return json(res, 503, {
models: [],
error: `Браузер с отладочным портом ${cfg.cdpPort} не отвечает. ` +
'Он поднимется сам при первом запросе к /api/chat; если этого ' +
'не произошло — профиль, скорее всего, уже открыт без ' +
'отладочного порта, закройте то окно.',
});
}
return json(res, 200, {
models: [{
name: MODEL, model: MODEL, modified_at: now(), size: 0,
details: { family: 'deepseek', parameter_size: 'web', quantization_level: '-' },
}],
});
}
if (req.method === 'GET' && url.pathname === '/api/version') {
return json(res, 200, { version: 'deepseek-api' });
}
// Дешёвый снимок состояния для индикаторов: ни браузер, ни вкладку
// не трогает, поэтому спрашивать можно хоть раз в секунду.
if (req.method === 'GET' && url.pathname === '/api/state') {
return json(res, 200, {
...state,
seconds: state.busy && state.since ? Math.round((Date.now() - state.since) / 1000) : 0,
host: cfg.host, port: cfg.port, cdpPort: cfg.cdpPort,
watchdog: !!cfg.watchdog, watchdogSec: cfg.watchdogSec,
});
}
// Своё, сверх протокола Ollama: что видно во вкладке прямо сейчас.
if (req.method === 'GET' && url.pathname === '/api/probe') {
return json(res, 200, await serial(() => probe()));
}
if (req.method === 'POST' && url.pathname === '/api/chat') {
const body = await readBody(req);
const prompt = flatten(body.messages);
if (!prompt) return json(res, 400, { error: 'пустой запрос' });
const imageBytes = imageOf(body.messages);
const started = Date.now();
console.log(`вопрос ${prompt.length} симв.${imageBytes ? ' + картинка' : ''}`);
const answer = await serial(() => track(() => ask({
prompt, imageBytes, timeout: cfg.timeout,
log: m => console.log(` ${m}`),
})));
console.log(`ответ ${answer.length} симв. за ${
Math.round((Date.now() - started) / 1000)} с`);
// Имя модели возвращаем то, которое прислал клиент: иначе он решит, что
// ответила не та модель, которую он просил.
const model = body.model || MODEL;
const head = { model, created_at: now() };
if (body.stream === false) {
return json(res, 200, { ...head, message: { role: 'assistant', content: answer },
done: true, done_reason: 'stop' });
}
return stream(res,
{ ...head, message: { role: 'assistant', content: answer }, done: false },
{ ...head, message: { role: 'assistant', content: '' },
done: true, done_reason: 'stop' });
}
// /api/generate — тот же чат, только промпт строкой.
if (req.method === 'POST' && url.pathname === '/api/generate') {
const body = await readBody(req);
const prompt = [body.system, body.prompt].filter(Boolean).join('\n\n').trim();
if (!prompt) return json(res, 400, { error: 'пустой запрос' });
const answer = await serial(() => track(() => ask({
prompt, timeout: cfg.timeout, log: m => console.log(` ${m}`),
})));
const head = { model: body.model || MODEL, created_at: now() };
if (body.stream === false) {
return json(res, 200, { ...head, response: answer, done: true, done_reason: 'stop' });
}
return stream(res, { ...head, response: answer, done: false },
{ ...head, response: '', done: true, done_reason: 'stop' });
}
// OpenAI-совместимый чат с инструментами. Именно сюда ходит Hermes.
if (req.method === 'POST' && url.pathname === '/v1/chat/completions') {
const body = await readBody(req);
const tools = body.tools || [];
let prompt = renderMessages(body.messages);
if (tools.length) prompt = `${toolInstructions(tools)}\n\n${prompt}`;
if (!prompt.trim()) return json(res, 400, { error: { message: 'пустой запрос' } });
const imageBytes = imageOf(body.messages);
const started = Date.now();
console.log(`v1 запрос ${prompt.length} симв., инструментов ${tools.length}`);
const answer = await serial(() => track(() => ask({
prompt, imageBytes, timeout: cfg.timeout, log: m => console.log(` ${m}`),
})));
const { calls, clean } = tools.length ? parseToolCalls(answer) : { calls: [], clean: answer };
console.log(`v1 ответ ${answer.length} симв., вызовов ${calls.length}, за ${
Math.round((Date.now() - started) / 1000)} с`);
const model = body.model || MODEL;
const created = Math.floor(Date.now() / 1000);
const id = `chatcmpl-${created}`;
const message = calls.length
? { role: 'assistant', content: clean || null, tool_calls: calls }
: { role: 'assistant', content: answer };
const finish = calls.length ? 'tool_calls' : 'stop';
if (body.stream) {
const base = { id, object: 'chat.completion.chunk', created, model };
const delta = calls.length
? { role: 'assistant', content: clean || '', tool_calls: calls.map((c, i) => ({ index: i, ...c })) }
: { role: 'assistant', content: answer };
return sse(res, [
{ ...base, choices: [{ index: 0, delta, finish_reason: null }] },
{ ...base, choices: [{ index: 0, delta: {}, finish_reason: finish }] },
]);
}
return json(res, 200, {
id, object: 'chat.completion', created, model,
choices: [{ index: 0, message, finish_reason: finish }],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
});
}
// Список моделей в формате OpenAI — некоторые клиенты дёргают его при старте.
if (req.method === 'GET' && url.pathname === '/v1/models') {
return json(res, 200, { object: 'list',
data: [{ id: MODEL, object: 'model', created: 0, owned_by: 'deepseek' }] });
}
// Эмбеддинги веб-чат не отдаёт в принципе. Честный отказ лучше пустого
// вектора: клиент увидит причину, а не молча испорченный поиск.
if (url.pathname === '/api/embeddings' || url.pathname === '/api/embed') {
return json(res, 501, {
error: 'DeepSeek через веб-чат не умеет эмбеддинги. ' +
'Оставьте поиск по смыслу на настоящей Ollama.',
});
}
json(res, 404, { error: 'нет такого эндпойнта' });
} catch (e) {
console.error('Ошибка:', e.message);
json(res, 500, { error: String(e && e.message || e) });
}
});
// По умолчанию слушаем все адреса: подключаются и с этой машины, и с соседней —
// ровно как к самой Ollama. Сервис ходит в браузер под вашим логином, поэтому
// в недоверенной сети host в config.json стоит сузить до 127.0.0.1.
//
// Запускаемся только когда файл вызвали напрямую: перевод формата проверяется
// из test.mjs импортом, и поднимать ради этого сервер незачем.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
server.listen(cfg.port, cfg.host, () => {
console.log(`DeepSeek прикидывается Ollama: http://localhost:${cfg.port}`);
if (cfg.host !== '0.0.0.0') console.log(`Слушаем только ${cfg.host}`);
console.log(`Модель: ${MODEL}`);
console.log(`Браузер: порт ${cfg.cdpPort}, профиль ${cfg.profileDir || '(не задан)'}`);
if (cfg.watchdog) {
console.log(`Сторож: проверка каждые ${cfg.watchdogSec} с`);
// Первая проверка сразу: если браузер уже открыт, вкладка подхватится,
// а если нет — поднимется, пока никто не ждёт ответа.
scheduleWatch(1);
} else {
console.log('Сторож выключен: браузер поднимется на первом запросе');
}
console.log(process.env.DEEPSEEK_TRAY
? 'Остановить — «Выход» в меню значка в трее'
: 'Остановить — Ctrl+C');
});
// Запущены из окна (tray.cs): оно держит нашу трубу stdin открытой. Труба
// закрылась — окна больше нет, и висеть дальше незачем: раньше закрытая
// консоль оставляла node в памяти. Браузер при этом переживает нас: он
// отдельный процесс и нужен человеку.
if (process.env.DEEPSEEK_TRAY) {
process.stdin.on('close', () => process.exit(0)).resume();
}
}