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>
This commit is contained in:
Alex Cube
2026-08-26 12:49:03 +03:00
co-authored by Claude Opus 5
commit f5cf061fcc
8 changed files with 874 additions and 0 deletions
+309
View File
@@ -0,0 +1,309 @@
#!/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');
});
}