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
+10
View File
@@ -0,0 +1,10 @@
# Батникам нужны CRLF: с LF cmd.exe способен повести себя странно —
# например, оборвать последнюю строку или неверно разобрать блок в скобках.
*.cmd text eol=crlf
*.bat text eol=crlf
# Остальному текстовому — LF в репозитории, нормализация при выгрузке.
*.mjs text
*.md text
*.json text
.gitignore text
+3
View File
@@ -0,0 +1,3 @@
node_modules/
*.log
config.local.json
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Alex
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+488
View File
@@ -0,0 +1,488 @@
# deepseek-api
**DeepSeek, притворяющийся Ollama.**
У веб-версии DeepSeek нет API: ни ключа, ни эндпойнта — только вкладка
в браузере, куда человек руками пишет вопросы. Эта программа открывает такую
вкладку, пишет в неё за вас и отдаёт наружу обычный HTTP — тот самый протокол,
на котором говорит [Ollama](https://ollama.com).
Смысл: **любая программа, которая уже умеет работать с Ollama, начинает
работать с DeepSeek после смены одного адреса.** Ни строчки кода в ней менять
не нужно.
```
ваша программа ──HTTP──> deepseek-api ──> браузер ──> chat.deepseek.com
(думает, что │
говорит с Ollama) <──────────── ответ ───────────────┘
```
Платить не нужно — используется ваш обычный аккаунт DeepSeek. Взамен всё
медленно (секунды, а не миллисекунды) и работает только пока открыт браузер.
---
## Оглавление
1. [Что понадобится](#что-понадобится)
2. [Установка по шагам](#установка-по-шагам)
3. [Проверка, что всё работает](#проверка-что-всё-работает)
4. [Как подключить свою программу](#как-подключить-свою-программу)
5. [Что умеет и чего не умеет](#что-умеет-и-чего-не-умеет)
6. [Настройка](#настройка)
7. [Если не работает](#если-не-работает)
8. [Безопасность](#безопасность)
9. [Как это устроено внутри](#как-это-устроено-внутри)
---
## Что понадобится
| Что | Зачем | Как проверить, что есть |
|---|---|---|
| **Windows** | скрипты запуска написаны под неё | — |
| **Node.js 22 или новее** | на нём написана программа | `node --version` → должно быть `v22.…` и выше |
| **Браузер на движке Chromium** | Яндекс.Браузер, Chrome, Edge — любой | — |
| **Аккаунт DeepSeek** | бесплатный, регистрация на [chat.deepseek.com](https://chat.deepseek.com) | — |
| **Проект DeepShim** | в нём живёт код, который умеет управлять вкладкой | ставится на шаге 1 |
Node.js, если его нет, ставится с [nodejs.org](https://nodejs.org) — берите
версию LTS, кнопка слева. После установки **закройте и откройте заново**
командную строку, иначе `node` в ней не найдётся.
---
## Установка по шагам
Всего шагов семь. Делайте по порядку, каждый заканчивается проверкой —
не переходите к следующему, пока предыдущий не дал ожидаемого результата.
### Шаг 1. Скачать обе программы
Нужны две: эта и DeepShim, в котором лежит код управления браузером.
Положите их **рядом, в одну папку**:
```
C:\#Projects\
deepseek-api\ <- эта программа
ollama_orchestro\ <- DeepShim
```
```cmd
cd C:\#Projects
git clone https://git.08h.ru/alex/deepseek-api.git
git clone https://git.08h.ru/alex/deepshim.git ollama_orchestro
```
Папка DeepShim должна называться именно `ollama_orchestro` — так она указана
в настройках по умолчанию. Хотите иначе — поправите путь на шаге 6.
Без git можно просто скачать ZIP с тех же страниц и распаковать.
> **Проверка:** в `C:\#Projects\deepseek-api\` лежит `server.mjs`,
> в `C:\#Projects\ollama_orchestro\` — `ask.mjs`.
### Шаг 2. Завести отдельный профиль браузера
Браузером будет управлять программа, поэтому работать в нём вам не нужно —
и не стоит: он будет самопроизвольно открываться и переключать вкладки.
Заведите под него **отдельный профиль** — это просто пустая папка,
браузер сам её заполнит:
```cmd
mkdir C:\Browsers\deepseek
```
Имя любое, запомните путь — понадобится на следующем шаге.
### Шаг 3. Настроить DeepShim
В папке `ollama_orchestro` создайте файл `config.json`:
```json
{
"profileDir": "C:\\Browsers\\deepseek",
"browserExe": "C:\\Program Files\\Yandex\\YandexBrowser\\Application\\browser.exe",
"port": 9333
}
```
Три значения:
- **`profileDir`** — папка из шага 2.
- **`browserExe`** — путь к браузеру. Типичные:
| Браузер | Путь |
|---|---|
| Яндекс.Браузер | `C:\Program Files\Yandex\YandexBrowser\Application\browser.exe` |
| Google Chrome | `C:\Program Files\Google\Chrome\Application\chrome.exe` |
| Microsoft Edge | `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe` |
- **`port`** — порт, по которому программа управляет браузером. `9333` подходит,
меняйте только если он у вас занят.
**Обратные слэши в JSON удваиваются**`C:\\Browsers\\deepseek`, а не
`C:\Browsers\deepseek`. С одинарными файл не прочитается.
> **Проверка:** откройте `config.json` в браузере (перетащите файл в окно).
> Если показалась структура — JSON верный. Если ошибка — ищите пропущенную
> запятую или одинарный слэш.
### Шаг 4. Запустить браузер и войти в DeepSeek
```cmd
cd C:\#Projects\ollama_orchestro
start-browser.cmd
```
Откроется браузер с чистым профилем на странице DeepSeek. **Войдите
в аккаунт.** Логин сохранится в профиле — больше делать это не придётся.
> **Проверка:** в окне командной строки написано
> `READY. … is listening on port 9333.`
> Если написано `Port 9333 did not answer` — см. [Если не работает](#если-не-работает).
**Окно браузера не сворачивайте и не закрывайте.** Свёрнутое окно Windows
не отрисовывает, и программа не сможет прочитать ответ. Достаточно перевести
его на другой рабочий стол или просто оставить позади других окон.
### Шаг 5. Создать три рабочих чата
DeepSeek выбирает режим (обычный / с поиском в интернете / со зрением)
**в момент создания чата**, и потом переключить его нельзя — тумблеры
исчезают после первого сообщения. Поэтому под каждый режим нужен свой чат.
Создадутся они сами:
```cmd
cd C:\#Projects\ollama_orchestro
node ask.mjs init
```
Команда откроет три новых чата, задаст в каждом по вопросу и запишет их
номера в `chats.json`. Займёт минуту-две, окно браузера в это время будет
само щёлкать — так и надо, не мешайте.
> **Проверка:** в конце напечатается что-то вроде
> ```
> Сохранено в chats.json:
> think expert af016e20-…
> search default+… 8021bbc1-…
> vision vision 14663b36-…
> ```
### Шаг 6. Настроить deepseek-api
Файл `config.json` в папке `deepseek-api` уже готов:
```json
{
"port": 11500,
"host": "0.0.0.0",
"bridge": "C:/#Projects/ollama_orchestro/ask.mjs",
"chatUrl": "https://chat.deepseek.com/",
"defaultRole": "think",
"timeout": 300
}
```
Если вы разложили папки как в шаге 1 — **менять здесь нечего, переходите
к шагу 7.** Если DeepShim лежит в другом месте, поправьте `bridge`. Здесь
слэши обычные, прямые (`C:/…`), а не обратные.
### Шаг 7. Запустить
```cmd
cd C:\#Projects\deepseek-api
start.cmd
```
> **Проверка:** в окне появилось
> ```
> DeepSeek прикидывается Ollama: http://localhost:11500
> Модели: deepseek:think, deepseek:search, deepseek:vision
> ```
Это окно должно оставаться открытым — пока оно живо, работает и сервис.
Останов — `Ctrl+C` или просто закрыть окно.
**Готово.** Дальше — проверка.
---
## Проверка, что всё работает
Откройте **вторую** командную строку (первая занята сервисом) и спросите
что-нибудь:
```cmd
curl http://localhost:11500/api/tags
```
Ожидается список из трёх «моделей»:
```json
{"models":[{"name":"deepseek:think",},{"name":"deepseek:search",},{"name":"deepseek:vision",}]}
```
Теперь настоящий вопрос:
```cmd
curl http://localhost:11500/api/chat -d "{\"model\":\"deepseek:think\",\"stream\":false,\"messages\":[{\"role\":\"user\",\"content\":\"Ответь одним словом: столица Японии?\"}]}"
```
Через несколько секунд:
```json
{"model":"deepseek:think","created_at":"…","message":{"role":"assistant","content":"Токио"},"done":true,"done_reason":"stop"}
```
Если пришло — всё работает. В окне сервиса при этом видно, сколько заняло:
```
[think] 37 симв.
[think] ответ 5 симв. за 8 с
```
---
## Как подключить свою программу
Везде, где программа спрашивает **адрес Ollama**, укажите:
```
http://localhost:11500
```
Моделью выберите `deepseek:think`.
Если программа работает на **другом компьютере** в вашей сети — вместо
`localhost` укажите адрес этого компьютера, например `http://192.168.0.10:11500`.
Сервис слушает все адреса, ничего дополнительно настраивать не нужно.
### Три «модели» — это три режима
| Модель | Что это | Когда брать |
|---|---|---|
| `deepseek:think` | обычный режим (expert) | почти всегда |
| `deepseek:search` | с поиском в интернете | нужны свежие данные |
| `deepseek:vision` | со зрением | разбор картинок |
Картинку можно просто приложить к запросу, как это делает Ollama —
в поле `images` массивом base64. Тогда режим `vision` включится сам,
независимо от того, какую модель вы указали.
**Незнакомое имя модели — не ошибка.** Если программа настойчиво шлёт свою
`llama3` или `qwen3:8b`, запрос уйдёт в обычный чат. Это сделано нарочно:
чужую программу можно переключить на DeepSeek, не разыскивая в её настройках
поле с моделью.
### Пример: помощник hlamingo
В [hlamingo](https://git.08h.ru/alex/hlamingo) на вкладке **⚙** есть выбор
«Кто думает» — переключите на «DeepSeek», укажите адрес, сохраните.
---
## Что умеет и чего не умеет
### Эндпойнты
| Адрес | Что делает |
|---|---|
| `GET /api/tags` | список «моделей». **Отвечает 503, пока браузер не поднят** — по этому клиенты понимают, что сервис не готов |
| `GET /api/version` | опознавательная строка |
| `POST /api/chat` | сообщения → ответ |
| `POST /api/generate` | то же, но вопрос одной строкой |
| `POST /api/embeddings` | **501** — веб-чат векторов не отдаёт |
### Чего нет и не будет
- **Настоящего потока.** Ollama печатает ответ по словам; здесь он приходит
целиком. Формат потока при этом соблюдается (клиент получит две строки JSON
вместо многих), поэтому программы, которые ждут поток, не ломаются —
просто ответ появляется разом. Хотите один объект вместо потока — присылайте
`"stream": false`.
- **`temperature`, `num_predict` и прочих настроек генерации.** Веб-чат их
не принимает. Присланные — молча игнорируются.
- **Эмбеддингов** (векторов для поиска по смыслу). Веб-чат их не считает.
Вместо пустого вектора возвращается честная ошибка 501: молчаливая подделка
испортила бы поиск так, что вы бы этого не заметили. Если вашей программе
нужен и чат, и поиск по смыслу — оставьте поиск на настоящей Ollama.
- **Параллельных запросов.** Вкладка одна, и два запроса разом затрут друг
другу поле ввода. Запросы встают в очередь и идут по одному.
### Сколько ждать
Порядок величин, замерено на разборе фразы и генерации тегов:
| | Ollama (qwen3:8b, локально) | DeepSeek (веб) |
|---|---|---|
| короткий вопрос | 2–3 с | 8–12 с |
| первый запрос после запуска браузера | — | +15 с на подъём окна |
Если ваша программа считает, что модель обязана ответить за пару секунд, —
увеличьте в ней таймаут.
---
## Настройка
### `config.json`
| Ключ | По умолчанию | Смысл |
|---|---|---|
| `port` | `11500` | порт, на котором сервис слушает |
| `host` | `0.0.0.0` | какие адреса слушать; `127.0.0.1` — только этот компьютер |
| `bridge` | `C:/#Projects/ollama_orchestro/ask.mjs` | путь к DeepShim |
| `chatUrl` | `https://chat.deepseek.com/` | что открывать во вкладке |
| `defaultRole` | `think` | режим для незнакомых имён моделей |
| `timeout` | `300` | сколько секунд ждать **бездействия** страницы |
Про `timeout`: это не «сколько ждать ответа». Пока DeepSeek печатает, ждём
сколько угодно; срок отсчитывается от последнего признака жизни страницы.
### Переменные окружения
Перебивают файл, удобны для разовых запусков:
```cmd
set DEEPSEEK_API_PORT=11501
set DEEPSEEK_CDP_PORT=9444
start.cmd
```
Профиль браузера, путь к нему и порт управления берутся из
`ollama_orchestro/config.json` — там уже выполнен вход в DeepSeek, и второй
экземпляр этих настроек однажды разошёлся бы с первым.
---
## Если не работает
Сначала посмотрите в окно сервиса — там пишется, что происходит.
| Что видно | В чём дело | Что делать |
|---|---|---|
| `Браузер с отладочным портом 9333 не отвечает` в ответе на `/api/tags` | браузер закрыт | Ничего: он поднимется сам при первом запросе к `/api/chat`. Если не поднялся — следующая строка |
| `Браузер так и не открыл отладочный порт` | этот профиль уже открыт **без** порта управления: новый процесс отдаёт ссылку старому окну и молча завершается | Закройте **все** окна браузера с этим профилем и повторите. Проверить, что не осталось: диспетчер задач, процессы браузера |
| `в config.json не задан profileDir или browserExe` | шаг 3 пропущен или файл лежит не в той папке | `ollama_orchestro\config.json`, оба ключа заполнены |
| `мост вышел с кодом 1. Вкладка chat.deepseek.com не открыта` | вкладку закрыли руками | Откройте `chat.deepseek.com` в том же окне браузера, или перезапустите его через `start-browser.cmd` |
| Ответ приходит **обрезанным**, без кода | окно браузера свёрнуто — Windows перестаёт его отрисовывать, а DeepSeek дорисовывает блоки кода лениво | Разверните окно. Не сворачивайте: уводите на другой рабочий стол или за другие окна |
| Ответ приходит **чужой**, не на ваш вопрос | вкладка не успела обновиться | Повторите запрос. Если повторяется — перезапустите браузер |
| В окне мелькнуло `правки в этом чате кончились, завожу новый` | исчерпан лимит в шесть правок одного сообщения | Ничего. Это штатная смена чата, см. [Чаты изнашиваются](#чаты-изнашиваются-и-меняются-сами) |
| `EADDRINUSE: address already in use` | сервис уже запущен в другом окне | Закройте то окно либо возьмите другой порт (`DEEPSEEK_API_PORT`) |
| `node не является внутренней или внешней командой` | Node не установлен или командная строка открыта до его установки | Поставьте Node, **закройте и откройте заново** командную строку |
| Просит войти в DeepSeek при каждом запуске | профиль браузера каждый раз новый | Проверьте, что `profileDir` указывает на одну и ту же существующую папку |
Отдельная диагностика самого моста — из папки DeepShim:
```cmd
node ask.mjs probe
```
Она скажет, видна ли вкладка, найдено ли поле ввода и не просит ли сайт
залогиниться.
Проверить перевод формата, не поднимая браузер:
```cmd
cd C:\#Projects\deepseek-api
node test.mjs
```
---
## Безопасность
Прочтите, это важнее, чем кажется.
- **Сервис слушает все сетевые адреса** (`0.0.0.0`), а не только этот
компьютер. Так задумано: подключаться должны и соседние машины, ровно как
к самой Ollama. **Аутентификации нет.**
- **Он ходит в DeepSeek под вашим логином.** Любой, кто достучится до порта
11500, задаёт вопросы от вашего имени и расходует ваши лимиты.
- Поэтому: только домашняя или доверенная сеть. **Не пробрасывайте порт
наружу** и не открывайте его на публичном Wi-Fi.
- Всё, что вы отправляете, уходит на серверы DeepSeek — как если бы вы
написали это в чат руками. Для приватных данных берите локальную Ollama.
Если сервис нужен только на этом компьютере, впишите в `config.json`:
```json
"host": "127.0.0.1"
```
Тогда снаружи к нему не подключиться вовсе.
---
## Как это устроено внутри
```
POST /api/chat
├─ склеить system + user в один текст (у веб-чата нет ролей)
├─ выбрать чат по имени модели (think / search / vision)
├─ встать в очередь (вкладка одна)
├─> написать вопрос во временный файл
├─> node ask.mjs ask --file … --out … (дочерний процесс)
│ └─> Chrome DevTools Protocol
│ └─> правка ПЕРВОГО сообщения чата
│ └─> дождаться, пока DeepSeek допечатает
├─< прочитать ответ из файла
└─ отдать в формате Ollama
```
Почему правка первого сообщения, а не новое: DeepSeek отбрасывает всё,
что было после отредактированного сообщения, и чат остаётся из одного обмена.
Контекст не растёт, мусор между запросами не копится, каждый вопрос —
с чистого листа.
Почему дочерний процесс, а не импорт: `ask.mjs` — программа командной строки,
она ничего не экспортирует и выполняется сразу при подключении. Переделывать
чужой проект ради этого не стали.
Почему браузер поднимается напрямую, а не через `browser-launch.ps1`
из DeepShim: тот скрипт рассчитан на человека за консолью — на занятом профиле
он задаёт вопрос и ждёт ответа, а у сервиса некому отвечать.
### Чаты изнашиваются и меняются сами
**Одно сообщение DeepSeek разрешает править ровно шесть раз.** После шестой
правки кнопка перестаёт открывать поле ввода — и чат становится непригоден.
Ничего делать не нужно, это уже предусмотрено. На седьмом вопросе DeepShim
заводит новый чат того же режима, задаёт вопрос сразу в нём, записывает новый
номер в `chats.json` и **удаляет отработавший чат**. Снаружи не видно ничего:
тот же запрос, тот же ответ, разве что на несколько секунд дольше.
Отсюда два следствия:
- **`chats.json` — не настройка, а переменная.** Номера в нём меняются сами
по мере работы. Не правьте его руками и не удивляйтесь, если git покажет
в нём изменения.
- **В сайдбаре DeepSeek не копится мусор.** Старые чаты удаляются, плюс перед
каждым вопросом сайдбар подчищается от посторонних служебных чатов.
Если вы удалите рабочий чат вручную — тоже ничего страшного: следующий запрос
заметит пропажу и создаст новый.
Файлы:
| Файл | Что в нём |
|---|---|
| `server.mjs` | всё: HTTP, очередь, запуск браузера, вызов моста |
| `config.json` | настройки этой машины |
| `start.cmd` | запуск |
| `test.mjs` | проверка перевода формата, браузер не нужен |
Зависимостей нет — только то, что есть в Node 22.
---
## Лицензия
MIT.
+8
View File
@@ -0,0 +1,8 @@
{
"port": 11500,
"host": "0.0.0.0",
"bridge": "C:/#Projects/ollama_orchestro/ask.mjs",
"chatUrl": "https://chat.deepseek.com/",
"defaultRole": "think",
"timeout": 300
}
+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');
});
}
+6
View File
@@ -0,0 +1,6 @@
@echo off
rem ASCII only - cmd.exe reads .cmd in the system codepage, not UTF-8.
rem DeepSeek web chat pretending to be Ollama. Stop with Ctrl+C.
title deepseek-api
node "%~dp0server.mjs"
if errorlevel 1 pause
+29
View File
@@ -0,0 +1,29 @@
// Проверка перевода формата Ollama в один вопрос для веб-чата.
// Браузер и сам DeepSeek тут не нужны: node test.mjs
import assert from 'node:assert/strict';
import { roleOf, flatten, imageOf } from './server.mjs';
// Имя модели → чат DeepSeek. Незнакомое имя не ошибка: чужую программу можно
// переключить на DeepSeek, не трогая в ней настройку модели.
assert.equal(roleOf('deepseek:search'), 'search');
assert.equal(roleOf('deepseek:VISION'), 'vision');
assert.equal(roleOf('qwen3:8b'), 'think');
assert.equal(roleOf(undefined), 'think');
// У Ollama роли — отдельные сообщения, у веб-чата их нет: склеиваем по порядку,
// пустые выбрасываем. Потеря system означала бы потерю всей инструкции.
assert.equal(
flatten([{ role: 'system', content: 'Отвечай JSON' },
{ role: 'user', content: ' где кусачки? ' },
{ role: 'user', content: '' }]),
'Отвечай JSON\n\nгде кусачки?');
assert.equal(flatten([]), '');
assert.equal(flatten(undefined), '');
// Картинка приезжает в base64 внутри сообщения — так её кладёт Ollama.
const bytes = Buffer.from([0xff, 0xd8, 0xff]);
assert.deepEqual(imageOf([{ role: 'user', content: 'что это?',
images: [bytes.toString('base64')] }]), bytes);
assert.equal(imageOf([{ role: 'user', content: 'без картинки' }]), null);
console.log('OK: перевод формата работает');