Files
deepseek-api/server.mjs
T
Alex CubeandClaude Opus 5 f5cf061fcc DeepSeek, притворяющийся Ollama
У веб-версии DeepSeek нет API: ни ключа, ни эндпойнта, только вкладка
в браузере. Этот сервис держит такую вкладку и отдаёт наружу протокол
Ollama, поэтому любая программа, которая уже умеет ходить в Ollama,
подключается к DeepSeek сменой одного адреса.

  GET  /api/tags        три "модели" по числу режимов чата;
                        503, пока браузер с чатом не поднят
  GET  /api/version
  POST /api/chat        images в сообщении уводят в чат со зрением
  POST /api/generate
  POST /api/embeddings  501: веб-чат векторов не отдаёт

Внутри — ollama_orchestro/ask.mjs дочерним процессом: он ничего не
экспортирует и выполняется при импорте, это программа командной строки.
Браузер поднимается напрямую из Node, а не через browser-launch.ps1 из
DeepShim: тот рассчитан на человека за консолью и на занятом профиле
спрашивает Read-Host, а у сервиса некому отвечать. Флаги окна оттуда
перенесены: у неактивного окна DeepSeek не дорисовывает блоки кода.

Чего нет: настоящего потока (формат соблюдается, но ответ приходит
целиком), temperature и num_predict, эмбеддингов, параллельных запросов —
вкладка одна, запросы идут очередью по одному.

Сервис слушает все адреса и ходит в DeepSeek под вашим логином. Только
доверенная сеть; сузить до этого компьютера — host в config.json.

Проверка перевода формата без браузера: node test.mjs

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:49:03 +03:00

310 lines
16 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, подключается сюда сменой одного адреса.
//
// Что внутри происходит: запрос → файл → мост ollama_orchestro/ask.mjs →
// правка первого сообщения в чате DeepSeek → ответ → файл → HTTP.
//
// Зависимостей нет: Node 22+ со встроенным fetch.
import { createServer } from 'node:http';
import { spawn } from 'node:child_process';
import { readFile, writeFile, rm, mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
// fileURLToPath, а не разбор pathname вручную: путь проекта содержит «#»,
// и в URL он приезжает как %23 — самодельный разбор ломается именно на нём.
const HERE = dirname(fileURLToPath(import.meta.url));
const cfg = {
port: 11500,
host: '0.0.0.0',
bridge: 'C:/#Projects/ollama_orchestro/ask.mjs',
chatUrl: 'https://chat.deepseek.com/',
defaultRole: 'think',
timeout: 300,
...JSON.parse(await readFile(join(HERE, 'config.json'), 'utf8').catch(() => '{}')),
};
if (process.env.DEEPSEEK_API_PORT) cfg.port = Number(process.env.DEEPSEEK_API_PORT);
// Отладочный порт браузера живёт в конфиге моста: он один на все проекты,
// и держать его копию здесь значит однажды разойтись.
const bridgeDir = dirname(cfg.bridge);
const bridgeCfg = JSON.parse(
await readFile(join(bridgeDir, 'config.json'), 'utf8').catch(() => '{}'));
const cdpPort = Number(process.env.DEEPSEEK_CDP_PORT || bridgeCfg.port || 9333);
// Роли — это отдельные чаты DeepSeek под разные режимы (обычный, с поиском,
// со зрением). Режим выбирается при создании чата и потом не меняется, поэтому
// чатов несколько. Снаружи они выглядят как разные «модели» Ollama.
const ROLES = ['think', 'search', 'vision'];
const MODELS = ROLES.map(r => 'deepseek:' + r);
// ------------------------------------------------------------------ браузер
const cdp = path => fetch(`http://127.0.0.1:${cdpPort}${path}`,
{ signal: AbortSignal.timeout(3000) });
async function browserAlive() {
try {
return (await cdp('/json/version')).ok;
} catch { return false; }
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
// Флаги не украшение. Замер в ollama_orchestro 11.08.2026: у неактивного окна
// Windows сообщает браузеру, что оно перекрыто, рисование прекращается, а
// DeepSeek рисует блоки кода лениво — ответ приезжает без кода (3136 символов
// вместо 30 817). Эти флаги запрещают браузеру засыпать.
const BROWSER_FLAGS = [
'--remote-allow-origins=*',
'--start-maximized',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--disable-background-timer-throttling',
'--disable-features=CalculateNativeWinOcclusion',
];
// Поднять браузер, если он не отвечает, и открыть вкладку с чатом, если её нет.
// Профиль и путь к браузеру берём из конфига моста — там уже выполнен вход
// в DeepSeek, а второй профиль означал бы второй логин.
//
// Браузер запускается напрямую, а не через browser-launch.ps1 из ollama_orchestro:
// тот скрипт рассчитан на человека за консолью — на занятом профиле он
// спрашивает Read-Host, а у сервиса некому отвечать.
async function ensureBrowser() {
if (!(await browserAlive())) {
if (!bridgeCfg.profileDir || !bridgeCfg.browserExe) {
throw new Error(
`Браузер не отвечает на порту ${cdpPort}, и запустить его сам не могу: ` +
${join(bridgeDir, 'config.json')} не задан profileDir или browserExe.`);
}
console.log('Браузер не отвечает, поднимаю...');
spawn(bridgeCfg.browserExe,
[`--user-data-dir=${bridgeCfg.profileDir}`,
`--remote-debugging-port=${cdpPort}`,
...BROWSER_FLAGS, cfg.chatUrl],
{ detached: true, stdio: 'ignore' }).unref();
// Холодный профиль поднимается неспешно; 60 секунд с запасом.
for (let i = 0; i < 30 && !(await browserAlive()); i++) await sleep(2000);
if (!(await browserAlive())) {
throw new Error(
`Браузер так и не открыл отладочный порт ${cdpPort}. Скорее всего этот ` +
`профиль уже открыт БЕЗ отладочного порта: новый процесс отдаёт ссылку ` +
`старому окну и завершается. Закройте окно с профилем ` +
`${bridgeCfg.profileDir} и повторите.`);
}
}
const list = await (await cdp('/json/list')).json().catch(() => []);
if (list.some(t => String(t.url || '').includes('deepseek'))) return;
// Новая вкладка в свежих сборках Chromium открывается ТОЛЬКО методом PUT:
// на GET протокол отвечает 405 и молча ничего не делает.
const r = await fetch(
`http://127.0.0.1:${cdpPort}/json/new?url=${encodeURIComponent(cfg.chatUrl)}`,
{ method: 'PUT' });
if (!r.ok) throw new Error(`Не удалось открыть вкладку DeepSeek: ${r.status}.`);
await sleep(4000); // дать чату прогрузиться до первого вопроса
}
// ------------------------------------------------------------------ мост
// Вопрос и ответ ездят файлами, а не через stdout: вопрос бывает на десяток
// килобайт, ответ — на сотню, и труба тут только мешает.
async function askBridge(prompt, role, imageBytes) {
await ensureBrowser();
const dir = await mkdtemp(join(tmpdir(), 'dsapi-'));
const qFile = join(dir, 'q.txt'), aFile = join(dir, 'a.txt');
const iFile = join(dir, 'image.jpg');
try {
await writeFile(qFile, prompt, 'utf8');
const args = ['ask', '--file', qFile, '--out', aFile, '--quiet', '--no-seed',
'--chat', role, '--timeout', String(cfg.timeout)];
if (imageBytes) {
await writeFile(iFile, imageBytes);
args.push('--image', iFile);
}
await new Promise((resolve, reject) => {
const ch = spawn(process.execPath, [cfg.bridge, ...args],
{ cwd: bridgeDir, stdio: ['ignore', 'ignore', 'pipe'] });
let err = '';
ch.stderr.on('data', d => { err += d; });
ch.on('error', reject);
ch.on('close', c => (c === 0 ? resolve() : reject(new Error(
`мост вышел с кодом ${c}. ${err.trim().slice(0, 300)}`))));
});
return await readFile(aFile, 'utf8');
} finally {
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
}
// Вкладка одна, и два запроса разом затрут друг другу поле ввода. Поэтому
// очередь: запросы идут строго по одному.
// ponytail: очередь без ограничения длины — если клиент завалит сервис
// запросами, они просто будут ждать. Появится нужда — резать по длине очереди.
let chain = Promise.resolve();
function serial(fn) {
const next = chain.then(fn, fn);
chain = next.catch(() => {});
return next;
}
// ------------------------------------------------------------------ Ollama
// Роль из имени модели: "deepseek:search" → "search". Незнакомое имя (клиент
// прислал свою старую модель вроде qwen3:8b) — не повод падать: берём обычный
// чат, иначе смена движка в чужой программе требовала бы ещё и правки модели.
export function roleOf(model) {
const tail = String(model || '').split(':').pop().toLowerCase();
return ROLES.includes(tail) ? tail : cfg.defaultRole;
}
// 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;
}
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();
// Клиенты 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: `Браузер с отладочным портом ${cdpPort} не отвечает. ` +
'Он поднимется сам при первом запросе к /api/chat; если этого ' +
'не произошло — профиль, скорее всего, уже открыт без ' +
'отладочного порта, закройте то окно.',
});
}
return json(res, 200, {
models: MODELS.map(m => ({
name: m, model: m, 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 === 'POST' && url.pathname === '/api/chat') {
const body = await readBody(req);
const prompt = flatten(body.messages);
if (!prompt) return json(res, 400, { error: 'пустой запрос' });
const image = imageOf(body.messages);
const role = image ? 'vision' : roleOf(body.model);
const started = Date.now();
console.log(`[${role}] ${prompt.length} симв.${image ? ' + картинка' : ''}`);
const answer = await serial(() => askBridge(prompt, role, image));
console.log(`[${role}] ответ ${answer.length} симв. за ${
Math.round((Date.now() - started) / 1000)} с`);
const model = body.model || ('deepseek:' + role);
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 role = roleOf(body.model);
const answer = await serial(() => askBridge(prompt, role, null));
const head = { model: body.model || ('deepseek:' + role), 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' });
}
// Эмбеддинги веб-чат не отдаёт в принципе. Честный отказ лучше пустого
// вектора: клиент увидит причину, а не молча испорченный поиск.
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(`Модели: ${MODELS.join(', ')}`);
console.log(`Мост: ${cfg.bridge} (браузер на порту ${cdpPort})`);
console.log('Остановить — Ctrl+C');
});
}