From bb2c994a4aa6f9772d3cb8469f90513c03a815cc Mon Sep 17 00:00:00 2001 From: "oleg.simashkevich" Date: Sun, 11 Oct 2026 23:24:51 +0300 Subject: [PATCH 1/3] Refactor browser providers onto a shared tab bridge. Unify Grok, Mistral, GLM, ChatGPT, Kimi, Qwen and Gemini browser flows on the tab bridge, keep DeepSeek on HTTP, and sync the clean-install opt-in test for ChatGPT. Co-authored-by: Cursor --- .env.example | 196 ++++-- .gitignore | 4 +- README.en.md | 30 +- README.md | 30 +- browser/README.md | 111 +++- browser/opencode-controller.user.js | 906 ++++++++++++++++++++++++-- browser/opencode-observer.user.js | 885 +++++++++++++++++++++++-- docs/ACCOUNT_SAFETY.md | 42 +- docs/HARNESS.en.md | 4 +- docs/HARNESS.ru.md | 4 +- docs/SETUP.en.md | 9 +- docs/SETUP.ru.md | 9 +- integrations/harness.py | 15 +- providers/access.py | 219 ++++++- providers/browser_protocol.py | 855 +++++++++++++++++++++++- providers/common.py | 6 +- providers/conversations.py | 11 +- providers/registry.py | 4 + providers/tab_bridge.py | 202 +++++- qwen/client.py | 203 +++++- server/api.py | 233 ++++--- server/config.py | 175 ++++- server/conversation_cache.py | 4 +- server/openai_format.py | 70 +- tests/observer_binding_fixture.cjs | 121 +++- tests/test_access.py | 138 +++- tests/test_api.py | 4 +- tests/test_browser_transport_flags.py | 47 ++ tests/test_chatgpt_bridge.py | 149 +++++ tests/test_claude_bridge.py | 129 ++++ tests/test_conversation_cache.py | 66 +- tests/test_gemini_bridge.py | 250 +++++++ tests/test_harness.py | 2 +- tests/test_providers.py | 12 +- tests/test_qwen.py | 60 +- tests/test_qwen_api.py | 3 +- tests/test_qwen_bridge.py | 118 ++++ tests/test_tab_bridge.py | 60 +- tests/test_tools.py | 47 ++ tests/test_userscripts.py | 4 +- 40 files changed, 4979 insertions(+), 458 deletions(-) create mode 100644 tests/test_browser_transport_flags.py create mode 100644 tests/test_chatgpt_bridge.py create mode 100644 tests/test_claude_bridge.py create mode 100644 tests/test_gemini_bridge.py create mode 100644 tests/test_qwen_bridge.py diff --git a/.env.example b/.env.example index 1330074..98013ca 100644 --- a/.env.example +++ b/.env.example @@ -1,89 +1,161 @@ # Copy this file to .env. Sign-in is done by hand in a browser window # (run `python -m deepseek.auth` or `python -m qwen.auth`), so no credentials are stored here. # All providers are opt-in. DeepSeek web automation may restrict your account. -DEEPSEEK_ENABLED=0 + +# --- Common --- +# Default listen address is 127.0.0.1 (local services only). +# Use 0.0.0.0 for access from the network or Docker containers. +# HOST=0.0.0.0 +# PORT=8000 + +# Transports (how a provider answers requests): +# browser — signed-in site tab + userscripts; model id is -web; the +# model is chosen in the site UI. Needs BROWSER_BRIDGE_ENABLED=1, a +# paired tab, and browser/README.md. Used by Grok, Mistral, GLM, +# ChatGPT/Claude (browser only); Qwen/Gemini/Kimi when *_TRANSPORT=browser. +# http — direct website API after a manual auth CLI (no tab during requests). +# Qwen default; optional for Kimi. DeepSeek has no *_TRANSPORT flag +# and always uses this style after `python -m deepseek.auth`. +# cli — Gemini default: official Antigravity CLI (`agy`) + GEMINI_MODELS. +# Enable a provider with _ENABLED=1. Dual-transport providers set +# _TRANSPORT (qwen: http|browser, gemini: cli|browser, kimi: browser|http). + +# Shared signed-in browser tab bridge via a userscript browser extension: +# Tampermonkey for Chrome, Violentmonkey for Chrome, or Userscripts for Safari. +# Read browser/README.md before enabling. Required by browser transports +# (Qwen, Gemini, Grok, Mistral, GLM, ChatGPT, Claude, Kimi). +BROWSER_BRIDGE_ENABLED=0 + +# How long to wait for one provider answer, in seconds (1–18000). Default 600. +# Used by browser tabs, Kimi HTTP and Gemini CLI (after a browser stream starts, +# the same budget applies). +# PROVIDER_RESPONSE_TIMEOUT=18000 + # Minimum seconds between starts of attempts/continuations (durable across processes). -# DeepSeek also spaces HTTP dispatches by up to 3 seconds and enforces durable -# default rolling budgets: 12 generations/hour, 60/day; 3 new chats/hour, 6/day; -# 40 HTTP requests/hour, 200/day. These are local ceilings, not safe quotas. # This is pacing, not a guarantee of acceptable use or website quota. PROVIDER_MIN_INTERVAL=30 -# Configurable DeepSeek ceilings. Integers 1..2000; day >= hour for each kind. -# Restart to apply. Existing usage is preserved; website quotas are independent. -DEEPSEEK_MAX_GENERATIONS_PER_HOUR=12 -DEEPSEEK_MAX_GENERATIONS_PER_DAY=60 -DEEPSEEK_MAX_CHATS_PER_HOUR=3 -DEEPSEEK_MAX_CHATS_PER_DAY=6 -DEEPSEEK_MAX_HTTP_PER_HOUR=40 -DEEPSEEK_MAX_HTTP_PER_DAY=200 -# Accepted replies per saved stateless conversation; integer 1..1000. -# Raising preserves existing history and one-use continuation protection. -DEEPSEEK_MAX_CONVERSATION_REPLIES=20 -# Optional Qwen Chat models: run `python -m qwen.auth`, then set QWEN_ENABLED=1. -# Qwen uses a separate profile and session under session/qwen/. -QWEN_ENABLED=0 -# Optional: before a new request, renew a locally expired Qwen access token -# once using its existing website refresh cookie. No browser/background work, -# no replay after refusal; failures persist a pause. See docs/HARNESS.en.md. -QWEN_RENEW_SESSION=0 -# QWEN_PROFILE_DIR=/absolute/path/to/a/dedicated/qwen/profile -# Experimental signed-in browser tabs (Safari Userscripts or a compatible manager). -# Read browser/README.md before enabling. Each uses the web UI's selected model. -BROWSER_BRIDGE_ENABLED=0 -GROK_ENABLED=0 -MISTRAL_ENABLED=0 -KIMI_ENABLED=0 -# Optional direct Kimi Instant transport; browser remains necessary for manual -# initial session capture only. See docs/KIMI.md for qualification and setup. -# Session renewal is a separate opt-in and never follows a provider refusal. -KIMI_TRANSPORT=browser -KIMI_RENEW_SESSION=0 -GLM_ENABLED=0 -# WEB_PROVIDER_TIMEOUT=180 +# Accepted replies per saved stateless conversation (DeepSeek + web providers, +# including Qwen browser); integer 1..1000. Raising preserves existing history +# and one-use continuation protection. +CONVERSATION_MAX_REPLIES=20 -# Gemini uses the official Antigravity CLI, with tools disabled. -# Sign in with `agy`; obtain real account model slugs with `agy models`. -GEMINI_ENABLED=0 -GEMINI_MODELS={} -# ANTIGRAVITY_BIN=agy -# For a broken IPv6 socket only, providers.antigravity_ipv4 wraps the official -# CLI using IPv4 on your existing route, without changing system/VPN settings. - -# --- Optional --- -# DEEPSEEK_PROFILE_DIR=session/profile # reuse an existing signed-in Chrome profile -# HOST=127.0.0.1 -# PORT=8000 # RATE_LIMIT_PER_MINUTE=30 # per-client-IP limit on /v1 endpoints -# Sessions are loaded from cache by default. The server never logs in, refreshes a -# browser session in the background, or replays a rejected request. -# Missing/expired sessions return 401 login_required, or a terminal SSE error -# after streaming headers are sent. Run the auth CLI manually. -# Restrictions, quota and uncertain upstream failures pause that provider in -# session/provider-pauses.json (HTTP 403; SSE error if headers are already sent). -# Inspect: python -m providers.access status -# After confirming normal website access manually: -# python -m providers.access resume qwen -# Existing SERVER_INTERACTIVE_LOGIN / SESSION_REFRESH_ENABLED settings no longer -# enable automatic browser capture in the server. - -# --- Tool-call truncation handling --- +# --- TOOL / TOOLCALL --- +# How OpenAI `tools` are flattened into the provider prompt. +# full — full JSON Schema + tool_calls rules on every turn. +# light — recommended; full block on the first turn of a provider chat; short +# name/format reminder on continuations (smaller follow-ups). +# none — like light on the first turn; no tools block on continuations. +# HTTP transports (DeepSeek, Qwen API, Kimi direct, Gemini CLI): default full. +# Browser tab bridge (grok/mistral/glm/chatgpt/claude/kimi/qwen/gemini): default light. +TOOL_INSTRUCTIONS_HTTP=full +TOOL_INSTRUCTIONS_BROWSER=light + # DeepSeek's web output limit can cut a large tool call off mid-JSON. When that # happens it stops without returning partial calls. Continuations are off by # default. An explicit opt-in (>0) only continues a provider-confirmed output # limit, never an otherwise finished reply with a missing Markdown fence. TOOLCALL_MAX_CONTINUATIONS=0 + # Optional: one format correction after a normally completed invalid DeepSeek # tool reply, in the same conversation. Adds at most one paced request. Never # retries provider refusals, output limits, or uncertain dispatches. Default off. TOOLCALL_FORMAT_REPAIR=0 # Dump the raw model reply to the log whenever a tool call can't be parsed. -# DEBUG_TOOLCALLS=1 +# TOOLCALL_DEBUG=1 + # Save the last invalid tool reply privately under session/, without a history request. # May contain private source or secrets; disabled by default. Do not publish the file. # TOOLCALL_DIAGNOSTICS=1 + +# --- DeepSeek --- +DEEPSEEK_ENABLED=0 +# TRANSPORT +# http only after `python -m deepseek.auth` (no DEEPSEEK_TRANSPORT). +# DeepSeek also spaces HTTP dispatches by up to 3 seconds and enforces durable +# default rolling budgets: 12 generations/hour, 60/day; 3 new chats/hour, 6/day; +# 40 HTTP requests/hour, 200/day. These are local ceilings, not safe quotas. +# Configurable DeepSeek ceilings. Integers 1..2000; day >= hour for each kind. +# Restart to apply. Existing usage is preserved; website quotas are independent. +DEEPSEEK_MAX_GENERATIONS_PER_HOUR=12 +DEEPSEEK_MAX_GENERATIONS_PER_DAY=60 +DEEPSEEK_MAX_CHATS_PER_HOUR=3 +DEEPSEEK_MAX_CHATS_PER_DAY=6 +DEEPSEEK_MAX_HTTP_PER_HOUR=40 +DEEPSEEK_MAX_HTTP_PER_DAY=200 +# DEEPSEEK_PROFILE_DIR=session/profile # reuse an existing signed-in Chrome profile # Private failed SSE bytes (may contain private source/response text): # up to 4 files, 8 MiB decoded bytes each; no automatic deletion or replay. # DEEPSEEK_STREAM_DIAGNOSTICS=1 + +# --- Qwen --- +QWEN_ENABLED=0 +# TRANSPORT +# http — `python -m qwen.auth` + QWEN_MODELS (default). +# browser — model qwen-web; needs BROWSER_BRIDGE_ENABLED=1 and a paired tab. +QWEN_TRANSPORT=http +# Public OpenAI id → website wire model (HTTP transport). Default if unset: +# qwen3.8-omni-flash, qwen3.8-max, qwen3.7-plus. +QWEN_MODELS={"qwen3.8-omni-flash":"qwen3.8-omni-flash","qwen3.8-max":"qwen3.8-max","qwen3.7-plus":"qwen3.7-plus"} +# Optional HTTP-only: renew a locally expired access token once via the website +# refresh cookie. No browser/background work, no replay after refusal; failures +# persist a pause. Ignored for browser transport. See docs/HARNESS.en.md. +QWEN_RENEW_SESSION=0 +# QWEN_PROFILE_DIR=/absolute/path/to/a/dedicated/qwen/profile + +# --- Gemini --- +GEMINI_ENABLED=0 +# TRANSPORT +# cli — sign in with `agy`; put slugs from `agy models` into GEMINI_MODELS (default). +# browser — gemini-web on AI Mode (google.com/search?udm=50) or gemini.google.com/app. +GEMINI_TRANSPORT=cli +# Example catalog current as of 2026-10-11, from `agy models` (account/region may differ). +# GEMINI_MODELS={"gemini-3.8-flash-high":"gemini-3.8-flash-high","gemini-3.8-flash-medium":"gemini-3.8-flash-medium","gemini-3.8-flash-low":"gemini-3.8-flash-low","gemini-3.7-flash-high":"gemini-3.7-flash-high","gemini-3.7-flash-medium":"gemini-3.7-flash-medium","gemini-3.7-flash-low":"gemini-3.7-flash-low","gemini-3.6-flash-high":"gemini-3.6-flash-high","gemini-3.6-flash-medium":"gemini-3.6-flash-medium","gemini-3.6-flash-low":"gemini-3.6-flash-low","gemini-3.1-pro-high":"gemini-3.1-pro-high","gemini-3.1-pro-low":"gemini-3.1-pro-low","gemini-claude-sonnet-4-6":"claude-sonnet-4-6","gemini-claude-opus-4-6-thinking":"claude-opus-4-6-thinking","gemini-gpt-oss-120b-medium":"gpt-oss-120b-medium"} +# ANTIGRAVITY_BIN=agy +# For a broken IPv6 socket only, providers.antigravity_ipv4 wraps the official +# CLI using IPv4 on your existing route, without changing system/VPN settings. + +# --- Grok --- +GROK_ENABLED=0 +# TRANSPORT +# browser only (no GROK_TRANSPORT). Model: grok-web. +# Needs BROWSER_BRIDGE_ENABLED=1 and a paired tab on grok.com. + +# --- Mistral --- +MISTRAL_ENABLED=0 +# TRANSPORT +# browser only (no MISTRAL_TRANSPORT). Model: mistral-web. +# Needs BROWSER_BRIDGE_ENABLED=1 and a paired tab on chat.mistral.ai. + +# --- GLM --- +GLM_ENABLED=0 +# TRANSPORT +# browser only (no GLM_TRANSPORT). Model: glm-web. +# Needs BROWSER_BRIDGE_ENABLED=1 and a paired tab on chat.z.ai. + +# --- ChatGPT --- +CHATGPT_ENABLED=0 +# TRANSPORT +# browser only (no CHATGPT_TRANSPORT). Model: chatgpt-web. +# Needs BROWSER_BRIDGE_ENABLED=1 and a paired tab on chatgpt.com. + +# --- Claude --- +CLAUDE_ENABLED=0 +# TRANSPORT +# browser only (no CLAUDE_TRANSPORT). Model: claude-web. +# Needs BROWSER_BRIDGE_ENABLED=1 and a paired signed-in tab on https://claude.ai/; +# select the model in the site UI. + +# --- Kimi --- +KIMI_ENABLED=0 +# TRANSPORT +# browser — kimi-web (default); large pastes ~4KB+ may become a TXT attachment — +# keep prompts shorter, or use http. Needs BROWSER_BRIDGE_ENABLED=1. +# http — Instant after manual session capture; browser still needed once for +# capture. See docs/KIMI.md. Renewal never follows a refusal. +KIMI_TRANSPORT=browser +KIMI_RENEW_SESSION=0 + diff --git a/.gitignore b/.gitignore index 5ec6e45..832d7b8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,10 +3,12 @@ __pycache__/ *.pyc .env logs/ +.tmp/ # Session artifacts — contain session tokens + cookies. NEVER commit. session.json session *.env.* !.env.example -CLAUDE.md \ No newline at end of file +CLAUDE.md + diff --git a/README.en.md b/README.en.md index f9fa1e3..9c9563b 100644 --- a/README.en.md +++ b/README.en.md @@ -291,8 +291,9 @@ not resume a stopped goal, replay an old reply, or reset history or counters. | Missing group or model | Check `.env`, the running server and `/v1/models`, then reinstall the catalog | DeepSeek defaults are **12/60 generations, 3/6 new chats and 40/200 HTTP requests -per hour/day**, with 20 accepted replies per conversation and a 30-second -interval. `DEEPSEEK_MAX_*` settings belong to the bridge process environment. +per hour/day**, with a shared 20 accepted replies per conversation +(`CONVERSATION_MAX_REPLIES`) and a 30-second interval. `DEEPSEEK_MAX_*` budget +ceilings and `CONVERSATION_MAX_REPLIES` belong to the bridge process environment. Raising a local ceiling does not raise the website's allowance. [Configuration and error guide](docs/HARNESS.en.md#local-limits-and-errors). @@ -321,14 +322,17 @@ Provider documentation retains detailed qualification results. | Provider / model ID | Browser during requests | Status | | --- | --- | --- | -| DeepSeek · `deepseek-chat`, `deepseek-expert` | Not needed after manual sign-in | Small Harness tasks passed; repeated account restrictions occurred. No automatic renewal | -| Qwen · `qwen3.8-omni-flash`, `qwen3.8-max` | Not needed after sign-in | Tested in Harness; Max reached a daily limit. [Session](docs/HARNESS.en.md#qwen-session) | +| DeepSeek · `deepseek-chat`, `deepseek-expert` | Not needed after manual sign-in | Browserless HTTP; small Harness tasks passed; repeated account restrictions occurred. No automatic renewal | +| Qwen · HTTP via `QWEN_MODELS` / `qwen-web` | HTTP: not needed after sign-in; `qwen-web`: connected tab | HTTP Flash/Max and the browser adapter verified; Max hit a daily limit. `qwen3.7-plus` is catalogued without a separate live check. [Session](docs/HARNESS.en.md#qwen-session) | | Kimi · `kimi-instant` | Not needed after capture | Experimental HTTP: reply, renewal and Harness `read` passed | -| Kimi · `kimi-k3`, `kimi-k2.8` | Not needed after capture | Implemented, not qualified end to end: K3 refused free-user capacity; K2.8 was not attempted afterward. [Details](docs/KIMI.md) | -| GLM · `glm-web` | Connected tab required | Previously tested with GLM-5.3-Flash; browserless access remains incomplete. [Blockers](docs/GLM.md) | -| Kimi · `kimi-web` | Connected tab required | Older tab adapter; model selected on the website | -| Mistral / Grok | Tab required | Experimental: Mistral continuation returned `429`; Grok capture did not complete | -| Gemini / Antigravity | Official CLI | Request rejected by region in our test; working access not established | +| Kimi · `kimi-k3`, `kimi-k2.8` | Not needed after capture | HTTP implemented, not qualified end to end: K3 refused free-user capacity; K2.8 was not attempted afterward. [Details](docs/KIMI.md) | +| Kimi · `kimi-web` | Connected tab required | Browser adapter verified; large composer pastes (~4KB+, system+tools) become a TXT attachment on the site — confirmed. See `browser/README.md` and `.env.example` | +| GLM · `glm-web` | Connected tab required | Browser adapter verified; browserless access remains incomplete. [Blockers](docs/GLM.md) | +| Mistral · `mistral-web` | Connected tab required | Browser adapter verified — see `browser/README.md` | +| Grok · `grok-web` | Connected tab required | Browser adapter verified — see `browser/README.md` | +| ChatGPT · `chatgpt-web` | Connected tab required | Browser adapter verified — see `browser/README.md` | +| Claude · `claude-web` | Connected tab required | Browser adapter verified (ProseMirror + `/api/*` / DOM fallback) — see `browser/README.md` | +| Gemini · `gemini-web` / Antigravity CLI | `gemini-web`: connected tab; CLI: `agy` | Browser adapter verified. CLI was rejected by region in our test | All connections start disabled. Website versions, quotas and availability may change. Chinese providers also have limits; the exact Qwen Max daily allowance @@ -350,7 +354,8 @@ not clear account restrictions or quotas. Inspect pauses with `python -m providers.access status`. [Resume rules](docs/ACCOUNT_SAFETY.md). -The API binds to `127.0.0.1`; `session/` and `.env` are excluded from Git. +The API binds to `127.0.0.1` by default (local only). Set `HOST=0.0.0.0` for +network or Docker access. `session/` and `.env` are excluded from Git. Do not publish them, personalized Userscripts or budget credentials. Supported: text and emulated tool calls. Images, audio and video are not uploaded, even for models named Omni. @@ -382,3 +387,8 @@ Locally: `python -m unittest discover -s tests -t . -v`. [Tsuev/opencode-deepseek](https://github.com/Tsuev/opencode-deepseek) and [sums001/Deepseek-API](https://github.com/sums001/Deepseek-API). Use accounts under each service's terms. [License](LICENSE). + +Browser adapters (userscripts, tab protocol, ChatGPT / Claude / Gemini / Qwen / +Kimi / GLM / Mistral / Grok providers and related checks) are reworked in the +[SimolSem/opencode-deepseek](https://github.com/SimolSem/opencode-deepseek) fork +and contributed here via pull requests. diff --git a/README.md b/README.md index 8bd547c..362f380 100644 --- a/README.md +++ b/README.md @@ -293,8 +293,9 @@ Save Location приложения Userscripts и разрешите досту | Нет группы или модели | Проверьте `.env`, работающий мост и `/v1/models`, затем повторите установку каталога | По умолчанию DeepSeek: **12/60 генераций, 3/6 новых чатов, 40/200 HTTP-запросов -за час/сутки**, до 20 принятых ответов в одном диалоге. Интервал — 30 секунд. -Настройки `DEEPSEEK_MAX_*` задаются в окружении процесса; увеличение локального +за час/сутки**, общий лимит 20 принятых ответов в одном диалоге +(`CONVERSATION_MAX_REPLIES`). Интервал — 30 секунд. Бюджеты `DEEPSEEK_MAX_*` и +`CONVERSATION_MAX_REPLIES` задаются в окружении процесса; увеличение локального потолка не увеличивает квоту сайта. [Настройка и разбор ошибок](docs/HARNESS.ru.md#локальные-лимиты-и-ошибки). ## Подключение OpenCode @@ -322,14 +323,17 @@ opencode --agent plan --model local-qwen/qwen3.8-omni-flash | Провайдер / ID модели | Браузер во время запросов | Статус | | --- | --- | --- | -| DeepSeek · `deepseek-chat`, `deepseek-expert` | Не нужен; первоначальный вход ручной | Небольшие задачи Harness проходили; были повторные ограничения аккаунта. Автопродления нет | -| Qwen · `qwen3.8-omni-flash`, `qwen3.8-max` | Не нужен после входа | Проверены в Harness; у Max наблюдался дневной лимит. [Сессия](docs/HARNESS.ru.md#сессия-qwen) | +| DeepSeek · `deepseek-chat`, `deepseek-expert` | Не нужен; первоначальный вход ручной | Безбраузерный HTTP; небольшие задачи Harness проходили; были повторные ограничения аккаунта. Автопродления нет | +| Qwen · HTTP via `QWEN_MODELS` / `qwen-web` | HTTP: не нужен после входа; `qwen-web`: связанная вкладка | HTTP Flash/Max и браузерный адаптер проверены; у Max наблюдался дневной лимит. `qwen3.7-plus` в каталоге без отдельной живой проверки. [Сессия](docs/HARNESS.ru.md#сессия-qwen) | | Kimi · `kimi-instant` | Не нужен после сохранения сессии | Экспериментальный HTTP: ответ, обновление токенов и Harness `read` проверены | -| Kimi · `kimi-k3`, `kimi-k2.8` | Не нужен после сохранения сессии | Реализованы, сквозная проверка не завершена: K3 отказал по нагрузке бесплатного доступа, K2.8 после отказа не запускали. [Подробности](docs/KIMI.md) | -| GLM · `glm-web` | Нужна связанная вкладка | Ранее проверен с GLM-5.3-Flash; безбраузерный доступ к этой модели не завершён. [Ограничения](docs/GLM.md) | -| Kimi · `kimi-web` | Нужна связанная вкладка | Прежний вкладочный режим, модель выбирается на сайте | -| Mistral / Grok | Нужна вкладка | Экспериментальные: у Mistral продолжение получило `429`, у Grok захват ответа не завершился | -| Gemini / Antigravity | Официальный CLI | Запрос в нашей проверке отклонён по региону; рабочий доступ не подтверждён | +| Kimi · `kimi-k3`, `kimi-k2.8` | Не нужен после сохранения сессии | HTTP реализован, сквозная проверка не завершена: K3 отказал по нагрузке бесплатного доступа, K2.8 после отказа не запускали. [Подробности](docs/KIMI.md) | +| Kimi · `kimi-web` | Нужна связанная вкладка | Браузерный адаптер проверен; длинная вставка (~4KB+, system+tools) на сайте уходит в TXT-вложение — подтверждено. См. `browser/README.md` и `.env.example` | +| GLM · `glm-web` | Нужна связанная вкладка | Браузерный адаптер проверен; безбраузерный доступ к этой модели не завершён. [Ограничения](docs/GLM.md) | +| Mistral · `mistral-web` | Нужна связанная вкладка | Браузерный адаптер проверен — см. `browser/README.md` | +| Grok · `grok-web` | Нужна связанная вкладка | Браузерный адаптер проверен — см. `browser/README.md` | +| ChatGPT · `chatgpt-web` | Нужна связанная вкладка | Браузерный адаптер проверен — см. `browser/README.md` | +| Claude · `claude-web` | Нужна связанная вкладка | Браузерный адаптер проверен (ProseMirror + `/api/*` / DOM fallback) — см. `browser/README.md` | +| Gemini · `gemini-web` / Antigravity CLI | `gemini-web`: связанная вкладка; CLI: `agy` | Браузерный адаптер проверен. CLI в нашей проверке отклонялся по региону |) Все подключения выключены после чистой установки. Веб-версии, квоты и доступность могут меняться. Китайские модели тоже имеют ограничения; точный размер дневной @@ -350,7 +354,8 @@ opencode --agent plan --model local-qwen/qwen3.8-omni-flash генерации; ограничения аккаунта и квоты он не снимает. Статус пауз: `python -m providers.access status`. [Правила возобновления](docs/ACCOUNT_SAFETY.md). -API слушает `127.0.0.1`; сессии в `session/` и `.env` исключены из Git. +API по умолчанию слушает `127.0.0.1` (только локально). Для доступа из сети +или Docker задайте `HOST=0.0.0.0`. Сессии в `session/` и `.env` исключены из Git. Не публикуйте их, персональные Userscripts или токен управления бюджетом. Поддерживается текст и эмуляция вызовов инструментов; изображения, аудио и видео не загружаются, даже если модель называется Omni. @@ -382,3 +387,8 @@ CI проверяет Python 3.9/3.12, PoW WASM, shell/JavaScript и изоли [Tsuev/opencode-deepseek](https://github.com/Tsuev/opencode-deepseek) и [sums001/Deepseek-API](https://github.com/sums001/Deepseek-API). Используйте аккаунты в рамках условий сервисов. [Лицензия](LICENSE). + +Браузерные адаптеры (userscripts, протокол вкладки, провайдеры ChatGPT / Claude / +Gemini / Qwen / Kimi / GLM / Mistral / Grok и связанные проверки) переработаны в +форке [SimolSem/opencode-deepseek](https://github.com/SimolSem/opencode-deepseek) +и оттуда приходят сюда pull request'ами. diff --git a/browser/README.md b/browser/README.md index b204bd3..44c98a2 100644 --- a/browser/README.md +++ b/browser/README.md @@ -1,10 +1,15 @@ # Experimental providers using your signed-in browser -Grok, Mistral, Kimi and GLM use the normal site's send handler in an existing -signed-in tab. They do not copy cookies, passwords or Google OAuth tokens into -the bridge. Each provider has a separate queue and scoped conversation ids. -All four integrations are opt-in, use the selected web model, and remain -experimental until verified with the provider's current frontend and account. +Grok, Mistral, Kimi, GLM, ChatGPT, Qwen and Gemini use the normal site's send +handler in an existing signed-in tab. They do not copy cookies, passwords or +Google OAuth tokens into the bridge. Each provider has a separate queue and +scoped conversation ids. All integrations are opt-in and use the selected web model. Browser adapters for +Qwen, Kimi, GLM, Mistral, Grok, ChatGPT, Claude and Gemini have been live-checked; +DeepSeek stays HTTP-only. Website frontends and account limits can still break a +session at any time. For Qwen set `QWEN_ENABLED=1` and `QWEN_TRANSPORT=browser` +(the default `http` transport still uses `python -m qwen.auth` plus the direct +API client). For Gemini set `GEMINI_ENABLED=1` and `GEMINI_TRANSPORT=browser` +(the default `cli` transport still uses the Antigravity CLI and `GEMINI_MODELS`). Kimi's JSON Connect parser binds root answer blocks to the matching user and successfully completed assistant message; thinking blocks are excluded. Only this verified wire text can be parsed as requested OpenCode tool calls; @@ -22,15 +27,23 @@ content context and keeps its local pairing key out of page JavaScript. Never upload, commit or share that directory: the controller contains your local pairing key. The generic files in `browser/` contain no key. 2. Install Userscripts and add the two exported `.user.js` files to its scripts - directory. Grant access only to `chat.z.ai`, `grok.com`, `chat.mistral.ai`, and - `www.kimi.com` (or `www.kimi.ai` for the international site); the bridge does not need access to Google sign-in pages. + directory. Grant access only to `chat.z.ai`, `grok.com`, `chat.mistral.ai`, + `www.kimi.com` (or `www.kimi.ai`), `chat.qwen.ai`, `chatgpt.com` (and + `chat.openai.com` if still used), `claude.ai`, `gemini.google.com`, and + `www.google.com`; the bridge does not need access to Google Account settings + pages beyond the chat UI. After adding or replacing files, open the Userscripts toolbar popup and wait until both script names appear. The extension must rescan externally edited files before the next page load, as described in its linked README. After upgrading the bridge, export and replace the scripts again. The current - controller is 0.2.5; older loaded scripts do not include all lease checks. + controller is 0.3.33 and observer is 0.3.17 (Claude thinking streamDone). + On a failed job the badge shows `ERROR: …` with the reason (hover for full text). + Older loaded scripts do not include all lease checks. 3. Keep the API bound to `127.0.0.1:8000`. Set `BROWSER_BRIDGE_ENABLED=1` and enable - the provider you want, e.g. `GLM_ENABLED=1`, then restart the API. + the provider you want, e.g. `GROK_ENABLED=1`, `MISTRAL_ENABLED=1`, + `GLM_ENABLED=1`, `CHATGPT_ENABLED=1`, or `CLAUDE_ENABLED=1` (browser-only — + no transport flag). For Qwen use `QWEN_TRANSPORT=browser`; for Gemini + `GEMINI_TRANSPORT=browser`. Then restart the API. 4. Open the provider in your normal signed-in browser and reload once to load both scripts. Click **Подключить вкладку к OpenCode**. Keep that tab open. Userscripts may separately ask for `127.0.0.1` access for local job exchange; @@ -38,8 +51,14 @@ content context and keeps its local pairing key out of page JavaScript. that its observer is ready before sending a prompt. Do not use it for manual chat while a bridge request is running. 5. Point an OpenAI-compatible client at the same `/v1` URL. Model ids are - `grok-web`, `mistral-web`, `kimi-web`, and `glm-web`. They deliberately do not - claim a specific model version; select the desired model in the site's UI. + `grok-web`, `mistral-web`, `kimi-web`, `glm-web`, `chatgpt-web`, `claude-web`, + `qwen-web`, and `gemini-web`. They deliberately do not claim a specific model + version; select the desired model in the site's UI. Stateless clients + (OpenCode) that resend full `messages[]` resume the same browser chat via the + local conversation cache; only the new user/tool turn is typed into the tab + after the first reply. Tab-visible failures (captcha, observe/send issues) do + not durable-pause the provider — fix the site UI and retry. Quota/access HTTP + status still pauses until `python -m providers.access resume `. Requests to the local job endpoints require the private pairing key and a loopback client address. There is no CORS grant. A claimed job is tied to one @@ -86,7 +105,10 @@ model turn. A resumed conversation consumes allowance too. documents a conversation frequency limit and peak-load throttling. Selected membership features/models also consume a [shared credit pool](https://www.kimi.com/en/help/membership/membership-overview). Kimi Code limits apply to Kimi Code and must not be presented as this browser - chat adapter's quota. + chat adapter's quota. Large composer pastes (~4KB+, typical OpenCode + system+tools) are turned into a TXT attachment by the site instead of chat + text — keep bridge prompts shorter, or use the HTTP transport after session + capture. - **DeepSeek:** our account received a temporary restriction (`biz_code=5`, `user is muted`) on 2026-10-02. The exact trigger is unknown; no live retest was attempted after the safety changes. Automated website access can restrict @@ -102,8 +124,54 @@ switch accounts or models, create another conversation to evade the quota, purchase credits or retry the rejected turn. Limits can change; check the provider's current plan before relying on it for sustained agent work. +## ChatGPT + +ChatGPT is browser-only (`CHATGPT_ENABLED=1`, no transport flag). Pair a signed-in +tab on `https://chatgpt.com/` (or `https://chat.openai.com/`), use model id +`chatgpt-web`, and select the model in the site UI. The observer captures +`/backend-api/conversation` and `/backend-api/f/conversation` SSE only for the +active bridge job. Conversation tokens use `/c/`. Captchas, Sentinel +challenges and plan limits must be resolved in the normal tab; the bridge does +not automate them. Live browser-adapter checks for this provider have passed; +account quotas, captchas and Sentinel challenges still apply per session. + +## Claude + +Claude is browser-only (`CLAUDE_ENABLED=1`, no transport flag). Pair a signed-in +tab on `https://claude.ai/`, use model id `claude-web`, and select the model in +the site UI. The controller fills the ProseMirror composer +(`[data-testid="chat-input"]`) and clicks Send. The observer captures only +`/api/organizations/.../chat_conversations//completion` SSE for the active +job (line-oriented `content_block_delta` / `message_stop`; thinking frames with +`"stop_reason": null` are not treated as done). If the wire body is incomplete, +the controller can finish from a late DOM scrape. Conversation tokens use +`/chat/`. Do not point OpenCode `small_model` / session-title at `claude-web` +while a long turn is running — use another provider or disable title LLM +(Harness already disables `session-title-llm`). Captchas and plan limits must be +resolved in the normal tab. Live browser-adapter checks for this provider have +passed; account quotas and challenges still apply per session. + ## Gemini +### Browser tab (`GEMINI_TRANSPORT=browser`) + +Set `GEMINI_ENABLED=1`, `GEMINI_TRANSPORT=browser`, and `BROWSER_BRIDGE_ENABLED=1`. +Export and install the userscripts, open a signed-in Gemini tab — either +`https://www.google.com/search?udm=50` (AI Mode empty chat) or +`https://gemini.google.com/app` — connect it, and use model id `gemini-web`. +The site UI selects the model. Conversation tokens use `/app/` when the +SPA exposes one (not Antigravity UUIDs). + +**Answer capture (intentional fork).** Other tab providers finish from a +network completion body that already holds assistant text. Google AI Mode +often streams `/async/folif` HTML with early prompt-echo shells, so the +controller prefers a late DOM→Markdown scrape; the server still accepts +StreamGenerate text, folif HTML, or that plain Markdown. Prefer wire-first +again only when a late frame is stable plain text/Markdown without echo — +see the comment above `gemini_answer` in `providers/browser_protocol.py`. + +### Antigravity CLI (`GEMINI_TRANSPORT=cli`, default) + Install the [official Antigravity CLI](https://antigravity.google/docs/cli/) and complete its Google account sign-in yourself. Individual free access to the old Gemini CLI was retired; do not reuse its OAuth credentials in this bridge. @@ -149,10 +217,15 @@ qualifies that account/session and test, not all GLM models or arbitrary tasks. With both 0.2.4 scripts on 2026-10-02, Kimi passed an exact new completion and resumed SSE with the same conversation id, `stop` and `[DONE]`, followed by an OpenCode `plan` request with a completed native `read` and the exact owned fixture -contents. This qualifies that account/session and controlled test. Mistral passed a new completion, while continuation was -blocked by HTTP `429`; native agent qualification remains pending. Grok's normal -frontend sent the owned prompt and displayed the exact response, but the bridge -could not capture a completed network result, so its live API qualification -failed. Mistral and Grok's partial checks must not be advertised as working agent integrations. -Official Antigravity login and model listing succeeded, but completion was -rejected by Google's regional eligibility check; further attempts stopped. +contents. This qualifies that account/session and controlled test. Large Kimi +composer pastes (~4KB+, typical OpenCode system+tools) are still turned into a +TXT attachment by the site — confirmed on later live checks; keep bridge prompts +shorter or use HTTP after session capture. +As of 2026-10-11, live browser-adapter checks also passed for Mistral +(`mistral-web`), Grok (`grok-web`), ChatGPT (`chatgpt-web`), Claude +(`claude-web`), Qwen (`qwen-web`) and Gemini (`gemini-web`). Earlier partial +failures (Mistral `429` on continuation, Grok capture gaps) do not overturn those +later adapter checks; quotas and UI challenges still apply per account. +Official Antigravity CLI login and model listing succeeded, but completion was +rejected by Google's regional eligibility check; further CLI attempts stopped. +Prefer `GEMINI_TRANSPORT=browser` / `gemini-web` when the tab path works. diff --git a/browser/opencode-controller.user.js b/browser/opencode-controller.user.js index 87e3bca..8ca9bc6 100644 --- a/browser/opencode-controller.user.js +++ b/browser/opencode-controller.user.js @@ -1,13 +1,19 @@ // ==UserScript== // @name OpenCode signed-in tab controller // @namespace opencode-local-bridge -// @version 0.2.5 +// @version 0.3.33 // @description Opt-in local jobs in your existing signed-in browser tab. // @match https://chat.z.ai/* // @match https://grok.com/* // @match https://chat.mistral.ai/* // @match https://www.kimi.com/* // @match https://www.kimi.ai/* +// @match https://chat.qwen.ai/* +// @match https://chatgpt.com/* +// @match https://chat.openai.com/* +// @match https://claude.ai/* +// @match https://gemini.google.com/* +// @match https://www.google.com/* // @inject-into content // @run-at document-end // @noframes @@ -18,18 +24,110 @@ (async () => { "use strict"; - class BridgeFailure extends Error {} + class BridgeFailure extends Error { + constructor(message) { + super(message); + this.name = "BridgeFailure"; + } + } + const bridgeFailureReason = error => { + if (error instanceof BridgeFailure || error?.name === "BridgeFailure") { + return typeof error.message === "string" && error.message ? error.message : "Bridge failure"; + } + return null; + }; const TOKEN = "__BRIDGE_TOKEN__"; // Replaced only in ignored private exports. const BASE = "http://127.0.0.1:8000"; + const geminiInput = 'div.ql-editor[contenteditable="true"], rich-textarea [contenteditable="true"], div[contenteditable="true"][role="textbox"], textarea[aria-label], textarea'; + const chatgptInput = '[data-composer-input] div.ProseMirror[contenteditable="true"], div.ProseMirror[contenteditable="true"][data-composer-markdown], div.ProseMirror[contenteditable="true"][role="textbox"], #prompt-textarea, div#prompt-textarea[contenteditable="true"], textarea#prompt-textarea'; + // Claude.ai: ProseMirror composer with a stable data-testid; keep ChatGPT-like fill flags. + const claudeInput = '[data-testid="chat-input"][contenteditable="true"], div.ProseMirror[contenteditable="true"][role="textbox"], div.ProseMirror[contenteditable="true"], [contenteditable="true"][role="textbox"], textarea'; + // Click-or-Enter composers share one submit path. `send` is attached after + // the button helpers exist. DOM exceptions stay as flags, not copied branches. + const kimiSite = { + provider: "kimi", home: "/", input: ".chat-input-editor[contenteditable=true]", + path: /^\/chat\/[\w-]+$/, ignoreMarkup: true, stalls: true, + }; + const chatgptSite = { + provider: "chatgpt", home: "/", input: chatgptInput, path: /^\/c\/[\w-]+$/, + // Empty ProseMirror keeps ZWSP and a placeholder paragraph. React also + // replaces the node and rewrites innerHTML after the text is committed. + looseEmpty: true, rebindEditor: true, ignoreMarkup: true, + fillDelayMs: 40, fillFallback: "paragraph", clearForeignDraft: true, stalls: true, + }; + const claudeSite = { + provider: "claude", home: "/new", input: claudeInput, path: /^\/chat\/[\w-]+$/, + looseEmpty: true, rebindEditor: true, ignoreMarkup: true, + fillDelayMs: 40, fillFallback: "paragraph", clearForeignDraft: true, stalls: true, + }; const sites = { "chat.z.ai": { provider: "glm", home: "/", input: "#chat-input", path: /^\/c\/[\w-]+$/ }, - "grok.com": { provider: "grok", home: "/", input: 'form[data-composer=true] .query-bar-editor[contenteditable=true], form[data-composer=true] textarea[aria-label]', path: /^\/c\/[\w-]+$/ }, - "chat.mistral.ai": { provider: "mistral", home: "/work", input: ".ProseMirror[contenteditable=true]", path: /^\/(?:chat|work)\/[\w-]+$/ }, - "www.kimi.com": { provider: "kimi", home: "/", input: ".chat-input-editor[contenteditable=true]", path: /^\/chat\/[\w-]+$/ }, - "www.kimi.ai": { provider: "kimi", home: "/", input: ".chat-input-editor[contenteditable=true]", path: /^\/chat\/[\w-]+$/ }, + "grok.com": { + provider: "grok", home: "/", stalls: true, + input: 'form[data-composer=true] .query-bar-editor[contenteditable=true], form[data-composer=true] textarea[aria-label]', + path: /^\/c\/[\w-]+$/, + send: { mode: "form", form: "form[data-composer=true]" }, + }, + "chat.mistral.ai": { + provider: "mistral", home: "/work", stalls: true, + input: ".ProseMirror[contenteditable=true], [contenteditable=true][role='textbox'], textarea", + path: /^\/(?:chat|work|code)\/[\w-]+$/, + }, + "www.kimi.com": kimiSite, + "www.kimi.ai": kimiSite, + "chat.qwen.ai": { + provider: "qwen", home: "/", stalls: true, + input: "textarea.message-input-textarea, textarea", path: /^\/c\/[\w-]+$/, + }, + "chatgpt.com": chatgptSite, + "chat.openai.com": chatgptSite, + "claude.ai": claudeSite, + "gemini.google.com": { + provider: "gemini", home: "/app", input: geminiInput, stalls: true, + path: /^\/(?:u\/\d+\/)?app\/[\w-]+$/, + }, + // Google AI Mode empty chat is /search?udm=50 (extra Chrome params ignored). + // Follow-ups may carry /app/ and continue on gemini.google.com. + "www.google.com": { + provider: "gemini", home: "/search?udm=50", input: geminiInput, stalls: true, + path: /^\/search$|^\/(?:u\/\d+\/)?app\/[\w-]+$/, + }, }; const site = sites[location.hostname]; if (!site || window.top !== window) return; + const geminiAppRoute = value => { + const match = String(value).match(/^\/(?:u\/\d+\/)?(app(?:\/[\w-]+)?)$/); + return match ? "/" + match[1].replace(/\/$/, "") : value; + }; + // Synthetic token for AI Mode threads that never leave /search?udm=50. + // Must not navigate to gemini.google.com/app/aimode (different product UI). + const geminiAiModeToken = value => { + const path = String(value || ""); + return path === "/app/aimode" || path === "/search" || path.startsWith("/search?"); + }; + const googleAiModePage = () => location.hostname === "www.google.com" + && location.pathname === "/search" + && new URLSearchParams(location.search).get("udm") === "50"; + const sameRoute = (pathname, target) => { + if (location.hostname === "www.google.com") { + let targetPath = target; + try { targetPath = new URL(target, location.origin).pathname; } catch (_) {} + if (geminiAiModeToken(target) || geminiAiModeToken(targetPath)) { + return pathname === "/search" + && new URLSearchParams(location.search).get("udm") === "50"; + } + if (targetPath === "/search") { + return pathname === "/search" + && new URLSearchParams(location.search).get("udm") === "50"; + } + return geminiAppRoute(pathname) === geminiAppRoute(targetPath); + } + if (pathname === target) return true; + if (site.provider !== "gemini") return false; + // gemini.google.com cannot host the AI Mode placeholder token. + if (geminiAiModeToken(target)) return false; + return geminiAppRoute(pathname) === geminiAppRoute(target); + }; const key = "opencode-bridge-tab-v1"; let owner = sessionStorage.getItem(key); if (!owner) { owner = crypto.randomUUID(); sessionStorage.setItem(key, owner); } @@ -106,20 +204,421 @@ return text; } const draft = input => input?.isContentEditable ? editorText(input) : input?.value; + const normText = value => (value || "").replace(/\r\n/g, "\n").replace(/\u00a0/g, " "); + const samePrompt = (value, prompt) => normText(value) === normText(prompt); + // ChatGPT empty ProseMirror keeps ZWSP / trailing breaks that String.trim() + // does not remove. Do NOT use innerText here: CSS placeholder + // ("Спросить ChatGPT") can leak into innerText and false-fail as a draft. + const draftIsEmpty = input => { + if (!input) return true; + const cleaned = normText(draft(input) || "").replace(/[\u200b\ufeff]/g, "").trim(); + if (!cleaned) return true; + if (site.looseEmpty) { + const emptyPara = input.querySelector?.('[data-empty-paragraph="true"]'); + const placeholder = emptyPara?.getAttribute("data-placeholder") + || input.getAttribute("aria-label") + || ""; + if (placeholder && cleaned === normText(placeholder).trim()) return true; + // Structural empty composer: only the placeholder paragraph remains. + if (emptyPara && input.querySelectorAll("p").length <= 1 + && cleaned.length <= (placeholder ? placeholder.length : 0)) { + return true; + } + } + return false; + }; + // Contenteditable (Kimi/Vue/ChatGPT ProseMirror) often adds a trailing + // block newline, NBSP, or zero-width spaces around the inserted prompt. + const retainsPrompt = (input, prompt) => { + const wanted = normText(prompt).replace(/\n+$/, "").replace(/[\u200b\ufeff]/g, ""); + const variants = [ + draft(input), + input?.isContentEditable ? input.innerText : null, + input?.isContentEditable ? input.textContent : null, + ]; + return variants.some(value => { + if (value == null) return false; + const got = normText(value).replace(/\n+$/, "").replace(/[\u200b\ufeff]/g, ""); + return got === wanted; + }); + }; + async function clearLeftoverDraft(input, prompt) { + if (!input || draftIsEmpty(input)) return; + if (samePrompt(draft(input), prompt) || retainsPrompt(input, prompt)) { + await clearDraft(input); + return; + } + // Exclusive ChatGPT tab: clear foreign/stale composer text instead of + // failing closed. Manual chat on a connected tab is unsupported anyway. + if (site.clearForeignDraft) { + await clearDraft(input); + if (!draftIsEmpty(input)) { + const left = draft(input) || ""; + throw new BridgeFailure( + "The tab has an unsent draft: " + JSON.stringify(left.slice(0, 80)), + ); + } + return; + } + throw new BridgeFailure("The tab has an unsent draft"); + } + const geminiSendButton = () => Array.from(document.querySelectorAll( + 'button[aria-label="Send message"], button[aria-label="Send"], button.send-button', + )).find(visible) || null; + const chatgptSendButton = () => { + const direct = Array.from(document.querySelectorAll([ + 'button[data-testid="send-button"]', + "#composer-submit-button", + 'button[data-testid="composer-submit-button"]', + ].join(", "))).find(visible); + if (direct) return direct; + return Array.from(document.querySelectorAll( + '[data-composer-body] button[aria-label], form button[aria-label], button[aria-label]', + )).find(btn => { + if (!visible(btn)) return false; + const label = (btn.getAttribute("aria-label") || "").toLowerCase(); + if (!/(send|отправ)/.test(label)) return false; + return !/(voice|голос|дикт|microphone|разговор|фото|файл|attach|stop|остан)/.test(label); + }) || null; + }; + const claudeSendButton = () => { + const direct = Array.from(document.querySelectorAll([ + 'button[aria-label="Send Message"]', + 'button[aria-label="Send message"]', + 'button[data-testid="send-button"]', + 'button[aria-label="Send"]', + ].join(", "))).find(visible); + if (direct) return direct; + return Array.from(document.querySelectorAll("button[aria-label], button[type='submit']")).find(btn => { + if (!visible(btn)) return false; + const label = (btn.getAttribute("aria-label") || btn.textContent || "").toLowerCase(); + if (!/(send|отправ)/.test(label)) return false; + return !/(voice|голос|дикт|microphone|stop|остан|attach|файл)/.test(label); + }) || null; + }; + // Must return the