3 Commits

Author SHA1 Message Date
2305e26e25 feat: pre-request token validation, mid-stream error handling, and usage stats improvements
- Qoder: handle mid-stream errors by returning proper error Response instead of embedding in stream
- Qoder: add refreshCredentials() to validate token via quota endpoint before requests
- chatCore: validate and refresh provider tokens before sending chat requests
- chatCore: bail early with 401 on unrecoverable token refresh errors
- Usage stats: track apiKey, comboName, fallbackHistory in request details
- Dashboard: improve Combos, Endpoint, Provider, Usage, and RequestDetails pages
- API keys route: upsert logic with provider_type support
- DB repos: usageRepo query improvements, requestDetailsRepo pagination, apiKeysRepo updates
2026-06-29 10:08:07 +07:00
1fe8115dad merge: resolve conflicts with origin/master - keep both local and remote features 2026-06-22 11:40:16 +07:00
98412aa0bb update: sync local changes with latest features 2026-06-22 11:26:25 +07:00
642 changed files with 9580 additions and 61544 deletions

View File

@@ -0,0 +1,4 @@
# Taste (Continuously Learned by [CommandCode][cmd])
[cmd]: https://commandcode.ai/

View File

@@ -14,10 +14,6 @@ NODE_ENV=production
API_KEY_SECRET=endpoint-proxy-api-key-secret
MACHINE_ID_SALT=endpoint-proxy-salt
ENABLE_REQUEST_LOGS=false
# Console verbosity: DEBUG | INFO | WARN | ERROR. Default INFO. In production set
# ERROR to only print important errors (hides ▶ POST / 📊 DONE / [COMBO] / [CHAT]).
# Can also be changed at runtime from dashboard Settings → Logging.
# LOG_LEVEL=ERROR
OBSERVABILITY_ENABLED=true
AUTH_COOKIE_SECURE=false
REQUIRE_API_KEY=false
@@ -38,8 +34,5 @@ NEXT_PUBLIC_CLOUD_URL=https://9router.com
# ALL_PROXY=socks5://127.0.0.1:7890
# NO_PROXY=localhost,127.0.0.1
# Optional SearXNG endpoint for the built-in unauthenticated web-search provider.
# SEARXNG_URL=http://searxng:8080/search
# Currently unused by application runtime (kept as reference)
# INSTANCE_NAME=9router

28
.gitignore vendored
View File

@@ -1,4 +1,5 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
@@ -8,8 +9,10 @@
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/.next-cli-build/
@@ -19,22 +22,28 @@ product
# production
/build
.idea/
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.bin/*
data/
logs/*
@@ -52,28 +61,17 @@ Thanks.md
PUBLIC.en.md
PR/*
package-lock.json
#Ignore vscode AI rules
.github/instructions/codacy.instructions.md
README1.md
deploy*.sh
ecosystem.config.*
scripts/agSniffer/*
gitbooks/*
gitbook/README.md
# Refactor backup reference (do not bundle/lint)
open-sse.old/
.graphifyignore
graphify-out/*
# Local-only working dirs (notes, vendored repos, scripts, skills)
.claude/
.docs/
.repo/
.script/
.codegraph/
.PR/
.next-analyze/*
# CommandCode CLI local state (auth/taste/projects)
.commandcode/
# Pi subagent run artifacts
.pi-subagents/

View File

@@ -1,383 +1,3 @@
# v0.5.55 (2026-08-14)
## Features
- **Auth**: native SAML 2.0 SSO alongside OIDC — AuthnRequest generation, ACS
assertion handling, SP metadata export, admin config test, replay-protected
via a `saml_state` cookie matched against `InResponseTo`
- **Providers**: add Alibaba Token Plan (`token-plan.ap-southeast-1`) — the
fourth Alibaba key type, Singapore-only and OpenAI-compatible transport only
- **Providers**: add `glm-5.3` to GLM Coding and GLM (China)
- **Providers**: Kimchi accepts API keys as well as OAuth (dual auth), with a
working Test Connection for both modes
- **Antigravity**: add Gemini 3.7 Flash and its tiered high/medium/low variants
(also in the Gemini registry) with pricing and quota tracking
- **TTS**: add Fish Audio — model id travels in an HTTP `model` header, voice
is a `reference_id` (preset or cloned voice model)
- **OpenCode-Go**: route by request format via declared transports instead of
forcing every client into `/messages` — Codex/OpenAI clients no longer pay a
lossy Responses→OpenAI→Claude double translation. Per-model `supportedFormats`
guard; the bespoke executor is gone (its shared `_lastModel` cache could cross
auth headers between concurrent requests)
- **Usage**: dedup + cache Claude quota calls (120s TTL keyed by access token,
in-flight promise dedup, last-good read on soft failure) to stop multiple
tabs tripping 429; manual refresh (↻) sends `force=1` to bypass the cache
## Fixes
- **Docker**: ship `sql.js` in the image so the pure-JS DB fallback can start —
file tracing carried the package's JS without `dist/sql-wasm.wasm`, so a
container with no native driver aborted with ENOENT and never got a database
(#3248)
- **Usage**: read Gemini `usageMetadata` out of the antigravity `{ response }`
envelope — every non-streaming antigravity request logged `IN 0 | OUT 0`
(#3260)
- **Claude**: re-anchor passthrough cache breakpoints — the client's own
`cache_control` markers point at pre-normalization offsets, so the tail was
re-cached every request. Last system block and last tool pinned at 1h TTL,
last assistant turn at 5m, mid-conversation system messages folded into the
neighbouring user turn instead of hoisted into `body.system`
- **Combos**: detect images from Hermes and attachment payloads (`images[]`,
`experimental_attachments`, message-level `image_url`/`audio_url`, inline
`data:` URIs) so the Vision Adapter auto-switch fires for Hermes/Ollama/
Vercel AI SDK shapes
- **Kiro**: intercept chat via `x-amz-target` — Kiro IDE 1.0.228+ moved
`GenerateAssistantResponse` to `POST /` + header, bypassing MITM. Also emit
the now-mandatory initial-response frame and map the `auto` model slot
- **Kiro**: report real output tokens and stop discarding usable turns
- **Qoder**: detect billing blocks at stream start and return a synthetic 403
so combo/account fallback triggers instead of leaking the error into chat
- **Antigravity**: strip competitive system prompts (Zed IDE's Claude-agent
prompt) that Antigravity flags with a 429 Quota Exhausted
- **OpenCode**: send the official client fingerprint on free-tier requests so
the Console stops classifying traffic as unidentified and rate-limiting it;
session id resolves conversation-stable to preserve prompt caching
- **Responses**: don't close the message on an empty `tool_calls` array — some
providers attach one to every chunk, and the truthy check ended the message
on the first content token (#3234)
- **Translator**: preserve `prompt_cache_key` when converting chat to responses
- **Models**: expose snake_case token limits on `/v1/models`
- **Combos**: strip `stream_options` from the Fusion panel fan-out to avoid a
DeepSeek 400 (#3024); raise the dashboard model-test probe budget to 1024 and
soft-pass reasoning-only responses (#3010)
- **Headroom**: the toggle reflects the `headroomEnabled` setting even when the
proxy is down — it previously showed OFF while the engine kept calling
`/v1/compress`; proxy status stays visible via the status chip
- **Hermes**: add the `api_key` parameter to the model block in YAML config
- **Providers**: add llm7 to provider test support
## Docs
- **i18n**: add Spanish, French, and Brazilian Portuguese README translations
## Security
- **Real IP**: `x-9r-real-ip` and the Host fallback were trusted from
client-controlled headers whenever `custom-server.js` was not in the request
path (`npm run start`, `start:bun`), letting a remote caller pose as local to
skip API key auth and reach `LOCAL_ONLY_PATHS` (`/api/mcp/*`,
`/api/tunnel/enable`, `/api/auth/reset-password`). The server now stamps a
per-process `x-9r-peer-token` on every request it sanitizes and only trusts
`x-9r-real-ip` behind it — falling back to Host in development and failing
closed in production (GHSA-pjm4-8fpg-f9p6). Also fixes IPv6 loopback
detection (`::1`, `::ffff:127.0.0.1`) and routes `npm run start` /
`start:bun` through `custom-server.js`
- **Search**: `resolveBaseUrl()` rejects client-supplied non-public baseUrls
(SSRF guard on `/v1/search`)
- **Login**: fresh-install remote login with the default password returns 403
without issuing a JWT
- **Usage**: `/api/usage/request-details` redacts request/response payloads
# v0.5.50 (2026-08-05)
## Features
- **Providers**: add TokenRouter (300+ models via OpenAI-compatible gateway) with
exact per-model pricing for 110 models and `reasoning_effort` thinking config
- **Providers**: add Self-hosted STT / TTS / Embedding — point 9Router at your own
OpenAI-compatible speech and embedding servers (whisper.cpp, faster-whisper,
Kokoro-FastAPI, llama-server, vLLM, Infinity). Unlike the named cloud providers
these read `baseUrl` per connection, so one provider can front several machines
- **Combos**: default-enable vision/audio capacity adapter (auto-routes to a
vision/audio-capable model when the target lacks that capability, falling back
to `oc/mimo-v2.5-free`), wired into chat handler routing
- **Endpoint**: auto-provision a "Default Key" for first-time users so `/v1`
works without a manual dashboard step
- **Codex**: support GPT-5.6 Max/Ultra reasoning-level overrides (cx/ routes only)
- **Qoder**: support PAT (Personal Access Token) connections end-to-end, alongside
OAuth device flow
- **CLI tools**: add OpenDesign (manalkaff/opendesign) support
- **Headroom**: report effective payload savings (tool schema/history bytes broken
out, byte-savings % reflects actual outbound reduction)
- **Ollama**: Cloud quota tracker (session + weekly) + proactive background OAuth
token refresh scheduler for all providers
## Fixes
- **Providers**: remove Qwen (OAuth flow stopped working reliably)
- **Passthrough**: detect codex-tui/Codex Desktop as native Codex client — they
were falling through to the translator and losing fields like `reasoning.summary`
- **OAuth**: scope antigravity header fixes to loadCodeAssist/onboardUser only
- **OAuth**: keep `open` external in the build so xAI/Grok token refresh works on
Windows
- **OAuth**: declare missing `searchParams` in register-session handler (was a
500 instead of JSON on error)
- **DB**: `ENABLE_REQUEST_LOGS` env var now overrides the UI setting correctly;
observability defaults to off (opt-in)
- **Translator**: preserve Codex Responses Lite tool use across chat-native
OpenAI-compatible providers
- **Translator**: don't drop image-only user messages in `prepareClaudeRequest`
- **Translator**: drop JSON Schema keywords Gemini rejects (`uniqueItems`,
`contains`, `multipleOf`, `unevaluatedProperties`, `unevaluatedItems`,
`contentSchema`)
- **Claude**: remove global header cache that leaked one client's identity
headers onto another client/account sharing the server; gate `anthropic-beta`
by model instead
- **Antigravity**: drop retired Gemini 3.0 quota tiers, show Gemini 3.6 Flash
usage bars
- **Cloudflare AI**: declare API key authentication (dashboard showed "No
connections" despite an active key)
- **GitHub Copilot**: hold monthly-exhausted accounts until UTC month reset
instead of only cooling down 120s
- **CodeBuddy**: dodge Tencent CN content filter, add usage tracking, normalize
codebuddy-intl messages
- **Usage**: stop losing cached prompt tokens in the forced-SSE→JSON path
- **Grok CLI**: display the public subscription tier from the OAuth token claim
- **Providers**: count apikey connections for Ollama free-tier card; free-tier/
apikey providers without `authModes` now default to apikey (were treated
oauth-only)
- **Build**: include static/public assets in standalone output (login page hung
on 404s when run via PM2)
- **Server**: support IntelliJ IDEA OpenAI-compatible clients over HTTP (h2c
upgrade handling)
- **Auth**: redirect already-logged-in sessions away from `/login`
- **CLI tools**: enable Apply button for dynamic OpenAI/Anthropic-compatible
provider connections
- **CLI**: include complete API artifacts in the CLI package
- **TTS**: a bare self-hosted model name is the MODEL, not the voice — `kokoro`
was parsed as a voice against a default model, 404ing or synthesising with the
wrong one
- **Embeddings**: self-hosted embeddings no longer fall back to `api.openai.com`
when a connection has no `baseUrl` — that silently sent the input text and API
key to OpenAI under a provider named "Self-hosted"
- **Embeddings**: an adapter that rejects a misconfigured connection now returns
400 with the reason instead of escaping the handler uncaught
- **Embeddings**: bound the upstream fetch with `FETCH_CONNECT_TIMEOUT_MS` — an
endpoint that drops packets never returns headers, so the request previously
hung indefinitely
## Docs
- **i18n**: fix port typo, add RTK Token Saver feature descriptions
# v0.5.45 (2026-07-30)
## Features
- **TTS**: add Xiaomi MiMo text-to-speech (preset voices 冰糖/茉莉/苏打/白桦/Mia/Chloe/Milo/Dean, style control, language hint dropdown with Auto-detect, i18n for Style label/placeholder)
- **Providers**: add Poolside (OpenAI-compatible)
- **Providers**: add api-airforce, baidu, bazaarlink, bluesminds, kilo-gateway, llm7, morph, sambanova, tencent
- **OAuth**: zed / trae / windsurf providers + harden callback proxies
- **CLI tools**: set Claude Code max context tokens
- **Qoder**: PAT auth + refresh model list
- **Gemini**: Gemini 3.6 Flash tier routing + Gemini 3.5 Flash Lite
- **Claude**: bump default Opus to `claude-opus-5`
- **Kiro**: add Claude Opus 5 models
- **Usage**: Kimi and DeepSeek usage handlers
- **Usage**: SuperGrok weekly pool via gRPC-web
## Fixes
- **Refresh**: rotate `refresh_token` between retry attempts
- **Kiro**: canonicalize tool history and route API keys correctly
- **Kiro**: normalize dashboard thinking intensity models
- **Cursor**: stop leaking agent tool errors as text
- **Gemini**: fill empty tool schemas after `$ref` strip
- **Antigravity**: strip `stream_options` from non-stream requests
- **Jina-reader**: recover after transient errors, use JSON POST API
- **Usage**: record exact embedding tokens
- **Tunnel**: preserve successor cloudflared PID
- **Console-log**: initialize capture at server boot + prevent SSE proxy buffering
- **Dashboard**: count dual-auth, free-tier OAuth and API-key connections correctly
- **Dashboard**: flex quota rows, thin global scrollbars, no hidden-row overflow
## Docs
- **i18n**: expand pt-BR translation to 986 terms
- README: Indonesian translation
# v0.5.40 (2026-07-20)
## Features
- **i18n**: add Khmer (km) translations
- **CLI tools**: configure Grok Build subagent models
- **Kimi**: merge OAuth into dual-auth provider, add K3 / K2.7 models
- **Dashboard**: ProviderTopology flow animation
## Fixes
- **DB**: resolve better-sqlite3 parameter binding crash
- **Translator**: pass `service_tier` through OpenAI → Responses conversion
- **Kiro**: map GPT-5.6 reasoning effort fields
- **Kiro**: validate terminal streams before emitting output
- **Kiro**: map GPT reasoning effort fields
- **Codex**: current `client_version` + refresh-aware model sync
- **Alicode-intl**: split into Coding Plan + Model Studio providers
- **Cursor**: HTTP/2 AgentService support + version bump 3.12.17
- **Dashboard**: cut duplicate API/icon spam, lazy-load provider assets
# v0.5.35 (2026-07-16)
## Features
- **xAI**: Grok Imagine video generation (`/v1/videos`) + CLI
- **CLI tools**: Grok Build setup — choose separate main/general-purpose/explore/plan models and preserve each model's context window
- **GitHub Copilot**: route Claude models through Copilot's native `/v1/messages`
- **Kiro**: add GPT-5.6 model family (#2596)
- **RTK**: `X-9Router-Token-Saver` header to bypass token savers per request
- **Providers**: quota visibility settings
- **Translator**: drop temperature for all Claude models
- **i18n**: Thai (th) + Persian (fa) translations / README
## Fixes
- **Providers**: bulk-add API keys no longer overwrite existing keys (gap-fill `Key N`)
- **Anthropic**: lowercase `anthropic-version` header to prevent duplication on `/v1/messages`
- **Alicode-intl**: use DashScope compatible-mode endpoint so standard keys work
- **Grok CLI**: align Grok Build with current subscription protocol (#2590)
- **Grok CLI**: surface `expiresAt` so proactive token refresh fires (#2546)
- **Kiro**: improve direct session cache reuse
- **Models**: populate capabilities for live-catalog LLM models
- **Models**: list compatible provider models in `/v1/models`
- **Thinking**: send explicit `thinking:{type:adaptive}` alongside `output_config.effort`
- **Translator**: strip `client_metadata` when converting openai-responses → openai
## Improvements
- **Perf**: skip inactive background services on startup
## Docs
- README: Persian YouTube tutorial
# v0.5.30 (2026-07-10)
## Features
- **Perplexity**: add Agent API provider (#2492)
- **Grok CLI**: add Grok CLI / Grok Build provider with OAuth device-code flow (#2502)
- **Featherless**: add OpenAI-compatible provider presets
- **SearXNG**: configure endpoint via SEARXNG_URL env (#2499)
- **Providers**: add max thinking level for gpt-5.6-sol (#2500)
- **Headroom**: add extras detection and install UI (#2403)
- **Headroom**: activate/uninstall extras + fix interpreter detection
- **PXPipe**: PXPIPE token saver — multimodal prompt compression (#2465)
- **Proxy-Pools**: auto-rotate strategy for no-auth providers (#2409)
## Fixes
- **Cloudflare-AI**: support accountId in bulk key import (#2449)
- **DB**: backup on schema change, MCP child cleanup, codex models, usage providers OOM
- **Codex**: avoid bare-email OAuth dedup (#2477)
- **CLI**: allow staged app bundle builds (#2479)
- **Headroom**: compress Kiro conversation state (#2488)
- **Gemini-CLI**: raise output floor for thinking and add validated toolConfig (#2486)
- **GitHub**: label Copilot profiles by account identity (#2498)
- **OpenAI-to-Claude**: unwrap bare {function:{…}} tools without parent type (#2473)
- **Translator**: clamp thinking effort max->xhigh for OpenAI format (#2466)
- **RTK/find**: detect and group Windows backslash-style find output (#2448)
- **Codex**: handle fast tier and capacity SSE (#2452)
- **Volcengine-ark**: clamp Kimi max_tokens to 32768 endpoint cap
- **Antigravity**: align provider fingerprint with IDE Desktop 2.1.1 (#2389)
- **Pricing**: update Claude/Codex model rates and add new models
## Improvements
- **i18n(zh-CN)**: complete Chinese translations for all UI strings (#2436)
- **API**: caching for tunnel and version status endpoints
- **Perf**: faster dev startup and lighter bundle
# v0.5.20 (2026-07-07)
## Features
- **Thinking**: per-model thinking level picker on provider page — appends `(level)` suffix to copied model names for forced reasoning effort across all formats (openai, claude, gemini, deepseek, kimi, qwen, zai, minimax, hunyuan, step)
- **RTK**: add JS-native git-log filter (#2423)
- **Caveman**: add targeted upstream-aligned style rules (#2424)
- **i18n**: add Farsi (fa) language support (#2385)
## Fixes
- **Thinking**: strip `(level)` suffix from upstream `body.model` so providers no longer reject requests
- **Translator**: preserve developer instructions in openai-responses conversion (#2434)
- **count_tokens**: count structured Anthropic blocks (#2419)
- **Volcengine-ark**: clamp GLM-5 max_tokens to model output ceiling (#2428)
- **Kimi**: normalize reasoning_effort to backend enum (#2427)
- **Claude**: reconcile max_tokens vs thinking budget and lift per-model ceiling (#2381)
- **Kiro**: deliver system prompt natively, add Opus 4.5/4.7/4.8, tolerate dash version ids (#2366)
- **Headroom**: proxy dashboard through app (#2372)
- **MITM**: recover from stale lock file on server start
# v0.5.18 (2026-07-03)
## Features
- **Usage**: track cached tokens + correct input/output/cache cost (#2209) — hodtien
- **Codex**: show reset credit expiry details (#2290) — Rafli Ahmad Zulfikar
- **NVIDIA**: add new models and capabilities — decolua
- **ClinePass**: add provider support — sternelee
## Fixes
- **Usage**: dedupe streaming request-details log entries — Qin Li
- **Claude**: drop foreign thinking signatures in passthrough — decolua
- Prevent non-SSE stream pipe crash and cross-IdP account overwrites (#2244) — KunN-21
- **Kiro**: route IdC auth to regional CodeWhisperer surface (#2297) — Volodymyr Saakian
- **Kiro**: add Claude Sonnet 5 model support (#2264) — Edison42
- **Xiaomi-tokenplan**: region selector, key validation, multi-connection (#2251) — MiQieR
- **Translator**: strict Anthropic content block compliance (#2225) — Sahrul Ramadhan Hardiansyah
- **Kimchi**: strip reasoning_content echo to bound multi-turn input tokens — KunN-21
- **Kimchi**: bump User-Agent to kimchi/0.1.40 (#2256) — Ansh7473
- **Codebuddy-cn**: strip empty tool_calls arrays to preserve reasoning — zmf
- **Antigravity**: preserve Claude tool delta index (#2223) — Sutarto Jordan Chrisfivo
- **MITM**: generate root CA on server startup (#2228) — Sutarto Jordan Chrisfivo
# v0.5.15 (2026-06-29)
## Features
- Add Kimchi OAuth provider — Nant361
- Refine Qwen vision/video + thinking model patterns — decolua
- Opt-in Codex auto-ping quota keep-alive — Emirhan
## Fixes
- **Responses**: handle response.done terminal events (#2142) — rifuki
- **Headroom**: skip unsafe responses tool history (#2132) — Sutarto Jordan Chrisfivo
- **Translator**: map mid-conversation system message to user (claude→openai) — decolua
- **Gemini**: normalize contents to prevent 400 invalid_argument (#2192) — warelik
- **Gemini**: backfill thoughtSignature + suppress stream done sentinel — WARELIK
- **Alicode**: preserve cache_control for DashScope providers (#2069) — Rex
- **Antigravity**: strip deprecated/readOnly/writeOnly from tool schemas — iletai, Yudhistira-Official
- **CodeBuddy CN**: show bonus packs as one-time, not monthly-replenishing — whale9820
- **Kiro**: strip leaked <thinking> tags from content stream (#2158) — hamsa0x7
- **Tray**: make Windows context menu DPI-aware — Emirhan
- **Kilocode**: expose full gateway catalog in combo model picker — jellylarper
- **OpenCode**: fix Go GLM — decolua
# v0.5.12 (2026-06-26)
## Features
- Add token-saver dashboard page — decolua
- Add bulk delete for provider connections — teddytkz
- Resolve GitHub Copilot model catalog from upstream — caiqinzhou
- Add Venice AI provider — Brokenc0de
- Add Kiro external_idp import for Microsoft SSO (CLIProxyAPI) — Stevanus Pangau
- Overhaul Blackbox provider catalog + WebUI test support — suryacagur
## Fixes
- Provider thinking compatibility (DeepSeek/Gemini) — Mink Nguyen
- Stop double-counting streaming usage at source — decolua
- Usage logging dedupe to reduce stats churn — Mink Nguyen
- Prevent non-JSON SSE lines / duplicate [DONE] from breaking clients (PR #2046) — qianze
- Resolve Gemini TTS models from catalog — nguyenha935
- Support Kiro IDC (organization) token import — quanturbo
- Preserve forced streaming for JSON clients (#2031) — Joseph Yaksich
- Preserve Responses text format (Codex) — tenglong
- Support Gemini native TTS generateContent endpoint — nguyenha935
- Add missing zh-CN endpoint key label (i18n) — weimaozhen
- CodeBuddy: only send reasoning params when client requests reasoning (#2071) — Rex
- CodeBuddy CN: show one-shot bonus packs as expiring, not monthly-replenishing
- Show custom provider models in combo picker — Sapto
- Docker: add docker-compose.yml with headroom enabled by default — nitsuahlabs
- Clarify token diagnostics vs provider billing (headroom, #1998) — Sutarto Jordan Chrisfivo
- Translate openai-responses input through OpenAI for compression (#1998) — Ankit
- Kiro: report 1M context window for claude-opus-4.8 — EdisonPVE
- Avoid stale redirects after auth changes (#2100) — Emirhan
- Mark Claude Opus 4.7 (dashed id) as 1M context — Brokenc0de
- Preserve reasoning effort through Codex translations — ntdung6868
- Token-saver: full width card layout — decolua
- Antigravity: retry transient upstream failures — Sutarto Jordan Chrisfivo
- Param-support: handle strip rules without match/drop (#1960) — Joseph Yaksich
- Translator: resolve custom provider prefix in debug endpoint (#1083) — hamsa0x7
# v0.5.8 (2026-06-21)
## Features
@@ -625,4 +245,75 @@
# v0.4.46 (2026-05-15)
## Breaking Changes
- Tunnel public URL changed — old tunnel links no longer work, please reconnect to get the new URL
- Tunnel public URL changed — old tunnel links no longer work, please reconnect to get the new URL
# v0.4.44 (2026-05-15)
## Features
- Add Blackbox provider with `bb` alias (#1143)
- Add Xiaomi token plan provider
- Enhance model select modal UX + modal traffic lights (#1111)
- Default Usage dashboard period to Today (#1141)
## Fixes
- Fix Cowork model selection and Windows CLI packaging (#1129)
- Update provider name retrieval for compatibility provider (#1135)
- Update JWT_SECRET handling
# v0.4.41 (2026-05-14)
## Features
- Add jcode CLI tool integration with auto-configuration (#1047)
- Redesign CLI Tools dashboard: grid layout (1/2/3 cols) + dedicated detail page per tool
- Add drag-and-drop reordering for combo models (#1108)
- Add Today period option to Usage & Analytics (#1063)
- Add DeepSeek V4 Pro effort aliases (#950)
## Fixes
- fix(autostart): work on nvm + npm 9/10, actually register with launchctl (#1104, fixes #1082)
- Fix Ollama usage not tracked/shown in UI (#1102)
- fix(opencode): preserve DeepSeek reasoning content (#1099, fixes #1093)
- Fix TUI input lag (replace enquirer with native readline, persistent raw mode)
- fix(ui): show API key row actions on mobile (#1112)
## Improvements
- Sync DeepSeek TUI card style with other CLI tools (badges, layout, manual config modal)
- Add official logos for Amp CLI, jcode, Qwen Code (replace generic icons)
- Resize deepseek-tui icon 1024→128 with padding for visual consistency
# v0.4.39 (2026-05-14)
## Fixes
- fix(docker): restore `/app/server.js` (v0.4.38 regression)
# v0.4.38 (2026-05-13)
## Features
- Add DeepSeek TUI as CLI tool in dashboard (#1088)
## Fixes
- Fix broken Docker image in v0.4.36/v0.4.37 (#1096, #1097)
## Improvements
- Clean Docker tags + clearer pulls badge
# v0.4.37 (2026-05-13)
## Improvements
- Security hardening — upgrade recommended
# v0.4.36 (2026-05-13)
## Features
- Add MiniMax TTS provider support (#1043)
- Docker images now published on both Docker Hub (`decolua/9router`) and GHCR — pull from your preferred registry
## Improvements
- Replace browser confirm dialogs with custom ConfirmModal (#1060)
## Fixes
- Fix Docker `Cannot find module 'next'` error in standalone build
- Restore /app/server.js in Docker standalone build (#1064, #1067)
- Fix CLI TUI menu arrow-key escape sequences leaking (^[[A^[[B)
- Switch macOS/Linux tray to systray2 fork (fixes Kaspersky AV false-positive) (#1080)
- Fix zoom controls contrast in topology view (#1066)

View File

@@ -1,91 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
9Router (`9router-app`) — a local AI routing gateway + Next.js dashboard. It exposes one OpenAI-compatible endpoint (`/v1/*`) and routes traffic across 40+ upstream providers with format translation, model-combo fallback, multi-account fallback, OAuth/API-key credential management, token refresh, quota/usage tracking, and optional cloud sync.
Two published artifacts live in this one repo:
- The **dashboard + gateway** (root `package.json`, `9router-app`) — the Next.js server that does the actual routing.
- The **CLI launcher** (`cli/`, published to npm as `9router`) — a separate package that installs/starts the server and manages the tray. It has its own `package.json`, version, and build.
The code lives in `src/` (Next.js app + dashboard/compat APIs), `open-sse/` (the provider-agnostic routing/translation engine), `cli/` (the launcher package), and `tests/`.
## Commands
Dashboard/gateway (run from repo root):
```bash
cp .env.example .env
npm install
PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev # dev (webpack, port 20127 by default via next dev)
npm run build && PORT=20128 HOSTNAME=0.0.0.0 npm run start # production
```
- Bun variants: `npm run dev:bun` / `build:bun` / `start:bun`.
- Default runtime port is **20128** (dashboard at `/dashboard`, API at `/v1`).
- Lint: `npx eslint .` (config `eslint.config.mjs`, extends `eslint-config-next`).
CLI package (`cli/`):
```bash
npm run cli:pack # build + npm pack from root
cd cli && npm run dev # nodemon watch
```
Tests (vitest, in `tests/`, an **independent** ESM package — not wired into root `npm test`):
```bash
npm install # ROOT deps first — tests import from src/ which needs `open`, `undici`, etc.
cd tests && npm install # then tests' own deps (vitest) → tests/node_modules (allowed by tests/.gitignore)
npx vitest run # all tests; auto-discovers tests/vitest.config.js
npx vitest run unit/capabilities.test.js # single file (path relative to tests/)
```
> The committed `tests/package.json` `test` script hardcodes Unix paths (`NODE_PATH=/tmp/node_modules …`) — a shared-install workaround from upstream. On Windows (or anywhere), ignore it and use the `npx vitest` form above; `vitest.config.js` resolves the `open-sse`/`@/` aliases from the repo root regardless of where vitest lives.
>
> **The suite is NOT expected to be all-green on a plain checkout.** ~938 pass, ~64 fail. Judge regressions with `tests/__baseline__/verify-no-regression.mjs`, not a raw run. Expected red:
> - 26 catalogued in `tests/__baseline__/known-fails.txt` (rtk, oauth-cursor-auto-import, translator-request-normalization, …).
> - `unit/embeddings.cloud.test.js` imports `cloud/src/handlers/embeddings.js` — the `cloud/` worker dir is **not in this repo**, so it always fails here.
> - `unit/xai-oauth-service.test.js` times out (5s) when the xAI endpoint-discovery fetch isn't reachable/mocked.
> - `real/*.real.test.js` make live provider calls — need credentials, skip otherwise.
- `*.real.test.js` under `tests/translator/real/` make live provider calls — skip unless credentials are set.
- Regression baselines: `tests/__baseline__/verify-*.mjs` compare against committed snapshots (providers, aliases, OAuth URLs). Run these after touching provider registry / alias logic.
## Architecture
Two authoritative docs already exist — read them before working in these areas rather than re-deriving:
- `docs/ARCHITECTURE.md` — full system: request lifecycle, combo/account fallback, OAuth + token refresh, cloud sync, data model.
- `open-sse/AGENTS.md` — the routing/translation engine's own conventions and "how to add a provider/executor/translator". **Read this before editing anything under `open-sse/`.**
### Request flow (the thing to understand first)
`src/app/api/v1/*` route (Next rewrite maps `/v1/*``/api/v1/*` in `next.config.mjs`)
`src/sse/handlers/chat.js` (parse, combo expansion, account-selection loop)
`open-sse/handlers/chatCore.js` (detect source format, translate request, dispatch to executor, retry/refresh, stream setup)
`open-sse/executors/*` (per-provider upstream call; `default.js` handles any OpenAI-compatible provider)
`open-sse/translator/*` (client format ↔ provider format)
→ SSE back to client.
`src/sse/` is the app-side entry glue; `open-sse/` is the provider-agnostic engine (also usable standalone). Cross that boundary consciously.
### Translator engine (`open-sse/translator/`)
- Pivots through **OpenAI as the intermediate format**. A translator registered on an exact `source:target` pair (e.g. `claude:kiro`) runs as a **direct route**, skipping the lossy double-hop. Prefer a direct route for fragile pairs (thinking blocks, tool ids, non-base64 images, `is_error`).
- Translators **self-register** via `register(from, to, reqFn, resFn)` as an import side effect — a new translator file MUST be imported in `open-sse/translator/index.js` or it never runs.
- Never hardcode role/block/model strings — use `open-sse/translator/schema/` and `open-sse/config/` constants. Config-driven and DRY is enforced by convention here.
### Provider registry (`open-sse/providers/registry/*`)
- One file per provider. `providers/registry/index.js` is an **auto-generated** static import list — regenerate it with `scripts/migrate-registry.mjs` / `injectDisplayToRegistry.mjs`, don't hand-edit.
- Add a provider: copy `providers/REGISTRY_TEMPLATE.js`, add models to `config/providerModels.js`. Only add an executor for non-OpenAI-compatible upstreams.
### Persistence — IMPORTANT (ARCHITECTURE.md is stale here)
State is **no longer `db.json`**. It's a SQLite layer under `src/lib/db/` with an adapter fallback chain (`driver.js`): `bun:sqlite``better-sqlite3` (optional native dep) → `node:sqlite` (Node ≥22.5) → `sql.js` (pure-JS fallback, always works). `better-sqlite3` is deliberately in `optionalDependencies` so install never fails without build tools.
- `src/lib/localDb.js` is a **backward-compat shim** re-exporting `src/lib/db/index.js`. New code should import from `@/lib/db/index.js`; per-entity logic lives in `src/lib/db/repos/*`. Schema/migrations in `src/lib/db/migrations/`.
- DB file location resolves via `src/lib/db/paths.js` (`DATA_DIR`, else `~/.9router/`).
- Usage/logs (`src/lib/usageDb.js`, `usage.json` + `log.txt`) still live under `~/.9router` and do **not** follow `DATA_DIR`.
### RTK token saver (`open-sse/rtk/`)
Pre-translate hooks that compress `tool_result` content in-place to cut tokens. **Fail-open**: any error returns null and leaves the body untouched — never throw out of them. Skips `is_error`/`status:"error"` results to preserve traces.
## Conventions & gotchas
- Plain JavaScript (ESM), no TypeScript. `@/*` path alias → `src/*` (`jsconfig.json`).
- `custom-server.js` wraps the Next standalone server to derive client IP from the TCP socket and strip attacker-controlled `X-Forwarded-For` — trusting forwarding headers only from a loopback reverse proxy. Preserve this when touching request/IP/rate-limit code.
- Security-sensitive env: `JWT_SECRET` (session cookie), `INITIAL_PASSWORD` (default `123456` — must override), `API_KEY_SECRET`, `MACHINE_ID_SALT`. Full env contract in `.env.example` and ARCHITECTURE.md's env matrix.
- Binary/protobuf upstreams (kiro EventStream, cursor protobuf, commandcode NDJSON) don't round-trip through OpenAI — they're handled inside their own executor, not the translator.
- Versioning: root and `cli/` are versioned independently; changes are logged in `CHANGELOG.md`. Commit style is Conventional Commits (`fix(translator): …`, `feat(...)`).

View File

@@ -37,9 +37,6 @@ COPY --from=builder /app/src/mitm ./src/mitm
COPY --from=builder /app/node_modules/node-forge ./node_modules/node-forge
# Ensure `next` is available at runtime in case tracing did not include it.
COPY --from=builder /app/node_modules/next ./node_modules/next
# sql.js loads dist/sql-wasm.wasm by path at runtime; tracing only follows JS imports,
# so the last-resort DB driver would abort with ENOENT on the missing binary.
COPY --from=builder /app/node_modules/sql.js ./node_modules/sql.js
RUN mkdir -p /app/data && chown -R node:node /app && \
mkdir -p /app/data-home && chown node:node /app/data-home && \

327
README.md
View File

@@ -13,12 +13,11 @@
[![GHCR](https://img.shields.io/badge/GHCR-decolua%2F9router-blue?logo=github)](https://github.com/decolua/9router/pkgs/container/9router)
[![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE)
<a href="https://trendshift.io/repositories/22628" target="_blank"><img src="https://trendshift.io/api/badge/repositories/22628" alt="decolua%2F9router | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com)
[🇧🇷 Português (Brasil)](./i18n/README.pt-BR.md) • [🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) • [🇮🇩 Indonesia](./i18n/README.id-ID.md) • [🇪🇸 Español](./i18n/README.es.md) • [🇫🇷 Français](./i18n/README.fr.md)
<a href="https://trendshift.io/repositories/22628" target="_blank"><img src="https://trendshift.io/api/badge/repositories/22628" alt="decolua%2F9router | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com)
[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md)
</div>
---
@@ -84,7 +83,7 @@ npm install -g 9router
**2. Connect a FREE provider (no signup needed):**
Dashboard → Providers → Connect **Kiro AI** (~50 credits/month free: Claude 4.5 + GLM-5 + MiniMax) or **OpenCode Free** (no auth) → Done!
Dashboard → Providers → Connect **Kiro AI** (free Claude unlimited) or **OpenCode Free** (no auth) → Done!
**3. Use in your CLI tool:**
@@ -115,7 +114,6 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run
```
Default URLs:
- Dashboard: `http://localhost:20128/dashboard`
- OpenAI-compatible API: `http://localhost:20128/v1`
@@ -127,20 +125,6 @@ Default URLs:
<table>
<tr>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=X69n5Lm06Yw">
<img src="https://img.youtube.com/vi/X69n5Lm06Yw/maxresdefault.jpg" alt="Tiết kiệm chi phí LLM với 9Router" width="300"/>
</a><br/>
<b>🇻🇳 Tiếng Việt</b><br/>
<sub>Tiết kiệm chi phí LLM cho OpenClaw với 9Router<br/>by <a href="https://www.youtube.com/c/M%C3%ACAIblog">Mì AI</a></sub>
</td>
<td align="center" width="320">
<a href="https://youtu.be/VQAw612S27Y">
<img src="https://img.youtube.com/vi/VQAw612S27Y/maxresdefault.jpg" alt="9Router + Claude Code FREE Unlimited Setup" width="300"/>
</a><br/>
<b>🇵🇰 اردو / हिन्दी</b><br/>
<sub>9Router + Claude Code FREE Unlimited Setup<br/>by <a href="https://www.youtube.com/@BuildAIWithHamid">Build AI With Hamid</a></sub>
</td>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=raEyZPg5xE0">
<img src="https://img.youtube.com/vi/raEyZPg5xE0/maxresdefault.jpg" alt="9Router Setup Tutorial" width="300"/>
@@ -149,11 +133,11 @@ Default URLs:
<sub>9Router + Claude Code FREE Setup<br/>by <a href="https://www.youtube.com/@BuildAIWithHamid">Build AI With Hamid</a></sub>
</td>
<td align="center" width="320">
<a href="https://youtu.be/3dF5GIYMrcQ?si=bAyfyiHbARJQAHj_">
<img src="https://img.youtube.com/vi/3dF5GIYMrcQ/hqdefault.jpg" alt="9Router Setup Tutorial" width="300"/>
<a href="https://www.youtube.com/watch?v=X69n5Lm06Yw">
<img src="https://img.youtube.com/vi/X69n5Lm06Yw/maxresdefault.jpg" alt="Tiết kiệm chi phí LLM với 9Router" width="300"/>
</a><br/>
<b>🇺🇸 English</b><br/>
<sub>9Router + Claude Code FREE Setup<br/>by <a href="https://www.youtube.com/@BuildAIWithHamid">Build AI With Hamid</a></sub>
<b>🇻🇳 Tiếng Việt</b><br/>
<sub>Tiết kiệm chi phí LLM cho OpenClaw với 9Router<br/>by <a href="https://www.youtube.com/c/M%C3%ACAIblog">Mì AI</a></sub>
</td>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=o3qYCyjrFYg">
@@ -185,6 +169,8 @@ Default URLs:
<b>🇺🇸 English</b><br/>
<sub>FREE OpenClaw + Claude Opus 4.6<br/>by <a href="https://www.youtube.com/@BuildAIWithHamid">Build AI With Hamid</a></sub>
</td>
</tr>
<tr>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=CkVZZUSTXAI">
<img src="https://img.youtube.com/vi/CkVZZUSTXAI/mqdefault.jpg" alt="Claude CLI Free Setup" width="300"/>
@@ -200,25 +186,6 @@ Default URLs:
<sub>Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB<br/>by <a href="https://www.youtube.com/@krisswuh">Krisswuh</a></sub>
</td>
</tr>
<tr>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=GyX-DLvePW8">
<img src="https://img.youtube.com/vi/GyX-DLvePW8/hqdefault.jpg" alt="این شکلی از هر API ای استفاده کن برای هوش مصنوعی" width="300"/>
</a><br/>
<b>🇮🇷 Persian-فارسی</b><br/>
<sub dir="rtl">این شکلی از هر API ای استفاده کن برای هوش مصنوعی<br/>by <a href="https://www.youtube.com/@Matin_SenPai">Matin SenPai</a></sub>
</td>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=hPusYX-5Pmw">
<img src="https://img.youtube.com/vi/hPusYX-5Pmw/maxresdefault.jpg" alt="Hướng Dẫn Setup OpenClaw + 9Router: Tạo Bot Zalo AI Tự Động Từ A-Z" width="300"/>
</a><br/>
<b>🇻🇳 Tiếng Việt</b><br/>
<sub>Hướng Dẫn Setup OpenClaw + 9Router: Tạo Bot Zalo AI Tự Động Từ A-Z<br/>by <a href="https://github.com/tuanminhhole">tuanminhhole</a></sub>
</td>
<td align="center" width="320"></td>
<td align="center" width="320"></td>
<td align="center" width="320"></td>
</tr>
</table>
</div>
@@ -285,32 +252,6 @@ Default URLs:
<b>Kilo Code</b>
</td>
</tr>
<tr>
<td align="center" width="120">
<img src="./public/providers/opendesign.png" width="60" alt="OpenDesign"/><br/>
<b>OpenDesign</b>
</td>
<td align="center" width="120">
<img src="./public/providers/jcode.png" width="60" alt="jcode"/><br/>
<b>jcode</b>
</td>
<td align="center" width="120">
<img src="./public/providers/grok-cli.png" width="60" alt="Grok Build"/><br/>
<b>Grok Build</b>
</td>
<td align="center" width="120">
<img src="./public/providers/devin-cli.png" width="60" alt="Devin CLI"/><br/>
<b>Devin CLI</b>
</td>
<td align="center" width="120">
<img src="./public/providers/deepseek-tui.png" width="60" alt="DeepSeek TUI"/><br/>
<b>DeepSeek TUI</b>
</td>
<td align="center" width="120">
<img src="./public/providers/qwen.png" width="60" alt="Qwen Code"/><br/>
<b>Qwen Code</b>
</td>
</tr>
</table>
</div>
@@ -343,10 +284,6 @@ Default URLs:
<img src="./public/providers/cursor.png" width="60" alt="Cursor"/><br/>
<b>Cursor</b>
</td>
<td align="center" width="120">
<img src="./public/providers/kimchi.png" width="60" alt="Kimchi"/><br/>
<b>Kimchi</b>
</td>
</tr>
</table>
</div>
@@ -359,12 +296,12 @@ Default URLs:
<td align="center" width="150">
<img src="./public/providers/kiro.png" width="70" alt="Kiro"/><br/>
<b>Kiro AI</b><br/>
<sub>Claude 4.5 + GLM-5 + MiniMax<br/>50 credits/month free</sub>
<sub>Claude 4.5 + GLM-5 + MiniMax<br/>Unlimited FREE</sub>
</td>
<td align="center" width="150">
<img src="./public/providers/opencode.png" width="70" alt="OpenCode Free"/><br/>
<b>OpenCode Free</b><br/>
<sub>No auth • Auto-fetch models<br/>Free (model list varies)</sub>
<sub>No auth • Auto-fetch models<br/>Unlimited FREE</sub>
</td>
<td align="center" width="150">
<img src="./public/providers/gemini.png" width="70" alt="Vertex AI"/><br/>
@@ -375,11 +312,7 @@ Default URLs:
</table>
</div>
> **Note:** iFlow, Qwen Code and Gemini CLI free tiers were discontinued in 2026. Use Kiro / OpenCode Free / Vertex instead.
>
> **Kiro AI** moved to a paid model in Sep 2025 — the free tier is now capped at **50 credits/month** (plus 500 trial credits for new accounts in the first 30 days). Paid tiers: Pro $20/mo (1,000 credits), Pro+ $40/mo (2,000), Pro Max $100/mo (5,000), Power $200/mo (10,000).
> **OpenCode Free** model list fluctuates over time (some models free only for limited promos) — subject to change without notice.
> **Vertex AI**: the $300 free credit for new GCP accounts is still valid, but since Mar 2026 the **Gemini API endpoint no longer consumes these credits** — call the **Vertex AI Studio** endpoint instead.
> **Note:** iFlow, Qwen and Gemini CLI free tiers were discontinued in 2026. Use Kiro / OpenCode Free / Vertex instead.
### 🔑 API Key Providers (40+)
@@ -467,68 +400,26 @@ Default URLs:
<p><i>...and 20+ more providers including Nebius, Chutes, Hyperbolic, and custom OpenAI/Anthropic compatible endpoints</i></p>
</div>
### 🏠 Self-hosted Providers
For speech and embeddings served from **your own** machine — whisper.cpp,
faster-whisper, Speaches, Kokoro-FastAPI, openedai-speech, llama.cpp/llama-server,
vLLM, Infinity, text-embeddings-inference, or anything else that speaks the OpenAI
shape.
| Provider | Endpoint used | Typical server |
| --- | --- | --- |
| **Self-hosted STT** | `/v1/audio/transcriptions` | whisper.cpp, faster-whisper |
| **Self-hosted TTS** | `/v1/audio/speech` | Kokoro-FastAPI, openedai-speech |
| **Self-hosted Embedding** | `/v1/embeddings` | llama-server, vLLM, Infinity |
Every other speech provider is a named cloud service with a fixed endpoint. These
three read their address from **each connection**, so one provider can front
several machines and load-balance across them like any other.
Set it on the connection as `providerSpecificData.baseUrl`:
| Provider | Give it | Result |
| --- | --- | --- |
| Self-hosted STT | the full URL — `http://host:8080/v1/audio/transcriptions` | used as-is |
| Self-hosted TTS | the server root — `http://host:8880` | `+ /v1/audio/speech` |
| Self-hosted Embedding | the **OpenAI base**, `/v1` included — `http://host:8080/v1` | `+ /embeddings` |
> **Mind the `/v1` on embeddings.** The adapter appends `/embeddings`, so
> `http://host:8080` resolves to `http://host:8080/embeddings` and misses the
> OpenAI route — llama-server answers **501**. Give it the same base URL an OpenAI
> client would use. A full `.../v1/embeddings` is also accepted, so a value pasted
> from a `curl` example works too.
The API key is not checked by most local servers, but the field must be non-empty:
it is what gives the connection a credentials record, and `baseUrl` lives there.
Any placeholder works.
Self-hosted Embedding has **no cloud fallback by design** — a connection saved
without a `baseUrl` is reported as a configuration error rather than quietly
falling back to `api.openai.com`, which would send your input text and API key to
a third party under a provider named "Self-hosted".
---
## 💡 Key Features
| Feature | What It Does | Why It Matters |
| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------- |
| 🚀 **RTK Token Saver** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | Compress tool outputs (`git diff`, `grep`, `ls`, `tree`...) before sending to LLM | Save **20-40% input tokens** per request |
| 🧠 **Headroom Token Saver** ([Headroom](https://github.com/chopratejas/headroom)) | Optional external `/v1/compress` proxy before provider routing | Save more context tokens without changing clients |
| 🪨 **Caveman Mode** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | Inject caveman-speak prompt → LLM replies terse, technical substance preserved | Save **up to 65% output tokens** |
| 🐴 **Ponytail** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | Inject "lazy senior dev" prompt → LLM writes minimal, YAGNI-first code (Lite/Full/Ultra) | **Fewer output tokens, less refactoring** |
| 🎯 **Smart 3-Tier Fallback** | Auto-route: Subscription → Cheap → Free | Never stop coding, zero downtime |
| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown | Maximize subscription value |
| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | Works with any CLI tool |
| 👥 **Multi-Account Support** | Multiple accounts per provider | Load balancing + redundancy |
| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically | No manual re-login needed |
| 🎨 **Custom Combos** | Create unlimited model combinations | Tailor fallback to your needs |
| 📝 **Request Logging** | Debug mode with full request/response logs | Troubleshoot issues easily |
| 💾 **Cloud Sync** | Sync config across devices | Same setup everywhere |
| 📊 **Usage Analytics** | Track tokens, cost, trends over time | Optimize spending |
| 🌐 **Deploy Anywhere** | Localhost, VPS, Docker, Cloudflare Workers | Flexible deployment options |
Set `X-9Router-Token-Saver: off` to bypass all token savers for one chat request.
| Feature | What It Does | Why It Matters |
|---------|--------------|----------------|
| 🚀 **RTK Token Saver** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | Compress tool outputs (`git diff`, `grep`, `ls`, `tree`...) before sending to LLM | Save **20-40% input tokens** per request |
| 🧠 **Headroom Token Saver** ([Headroom](https://github.com/chopratejas/headroom)) | Optional external `/v1/compress` proxy before provider routing | Save more context tokens without changing clients |
| 🪨 **Caveman Mode** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | Inject caveman-speak prompt → LLM replies terse, technical substance preserved | Save **up to 65% output tokens** |
| 🐴 **Ponytail** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | Inject "lazy senior dev" prompt → LLM writes minimal, YAGNI-first code (Lite/Full/Ultra) | **Fewer output tokens, less refactoring** |
| 🎯 **Smart 3-Tier Fallback** | Auto-route: Subscription → Cheap → Free | Never stop coding, zero downtime |
| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown | Maximize subscription value |
| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | Works with any CLI tool |
| 👥 **Multi-Account Support** | Multiple accounts per provider | Load balancing + redundancy |
| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically | No manual re-login needed |
| 🎨 **Custom Combos** | Create unlimited model combinations | Tailor fallback to your needs |
| 📝 **Request Logging** | Debug mode with full request/response logs | Troubleshoot issues easily |
| 💾 **Cloud Sync** | Sync config across devices | Same setup everywhere |
| 📊 **Usage Analytics** | Track tokens, cost, trends over time | Optimize spending |
| 🌐 **Deploy Anywhere** | Localhost, VPS, Docker, Cloudflare Workers | Flexible deployment options |
<details>
<summary><b>📖 Feature Details</b></summary>
@@ -579,7 +470,7 @@ If Headroom is down or returns an error, 9Router fails open and sends the origin
### 🐴 Ponytail (Lazy Senior Dev)
Ponytail injects a _"lazy senior dev"_ system prompt into every request, biasing the LLM toward minimal, YAGNI-first code — deletion over addition, stdlib over new deps, one-liners over abstractions. Adapted from [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail).
Ponytail injects a *"lazy senior dev"* system prompt into every request, biasing the LLM toward minimal, YAGNI-first code — deletion over addition, stdlib over new deps, one-liners over abstractions. Adapted from [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail).
- **Lite** — Build what's asked, name the lazier alternative.
- **Full** — YAGNI ladder enforced: stdlib → native → existing deps → one-liner → minimal code.
@@ -615,7 +506,6 @@ Combo: "my-coding-stack"
### 🔄 Format Translation
Seamless translation between formats:
- **OpenAI** ↔ **Claude****Gemini****Cursor****Kiro****Vertex****Antigravity****Ollama****OpenAI Responses**
- Your CLI tool sends OpenAI format → 9Router translates → Provider receives native format
- Works with any tool that supports custom OpenAI endpoints
@@ -669,14 +559,14 @@ Seamless translation between formats:
- Optimize your AI spending
> **💡 IMPORTANT - Understanding Dashboard Costs:**
>
> The "cost" displayed in Usage Analytics is **for tracking and comparison purposes only**.
>
> The "cost" displayed in Usage Analytics is **for tracking and comparison purposes only**.
> 9Router itself **never charges** you anything. You only pay providers directly (if using paid services).
>
> **Example:** If your dashboard shows "$290 total cost" while using Kiro free models, this represents
> what you would have paid using paid APIs directly. Your actual cost = **$0** (Kiro free tier: ~50 credits/mo).
>
> Think of it as a "savings tracker" showing how much you're saving by using free models or
>
> **Example:** If your dashboard shows "$290 total cost" while using iFlow models, this represents
> what you would have paid using paid APIs directly. Your actual cost = **$0** (iFlow is free unlimited).
>
> Think of it as a "savings tracker" showing how much you're saving by using free models or
> routing through 9Router!
### 🌐 Deploy Anywhere
@@ -692,19 +582,19 @@ Seamless translation between formats:
## 💰 Pricing at a Glance
| Tier | Provider | Cost | Quota Reset | Best For |
| ------------------- | --------------------- | ------------ | ---------------- | --------------------------------------- |
| **🚀 TOKEN SAVER** | **RTK (built-in)** | **FREE** | Always on | **Save 20-40% tokens on EVERY request** |
| **💳 SUBSCRIPTION** | Claude Code (Pro/Max) | $20-200/mo | 5h + weekly | Already subscribed |
| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users |
| | GitHub Copilot | $10-19/mo | Monthly | GitHub users |
| | Cursor IDE | $20/mo | Monthly | Cursor users |
| **💰 CHEAP** | GLM-5.1 / GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup |
| | MiniMax M2.7 | $0.2/1M | 5-hour rolling | Cheapest option |
| | Kimi K2.5 | $9/mo flat | 10M tokens/mo | Predictable cost |
| **🆓 FREE** | Kiro AI | $0 | 50 credits/mo | Claude 4.5 + GLM-5 + MiniMax free (paid tiers above) |
| | OpenCode Free | $0 | Varies* | No auth, auto-fetch models (list changes over time) |
| | Vertex AI | $300 credits | New GCP accounts | Gemini 3 Pro + DeepSeek + GLM-5 (use Vertex AI Studio endpoint for free credits) |
| Tier | Provider | Cost | Quota Reset | Best For |
|------|----------|------|-------------|----------|
| **🚀 TOKEN SAVER** | **RTK (built-in)** | **FREE** | Always on | **Save 20-40% tokens on EVERY request** |
| **💳 SUBSCRIPTION** | Claude Code (Pro/Max) | $20-200/mo | 5h + weekly | Already subscribed |
| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users |
| | GitHub Copilot | $10-19/mo | Monthly | GitHub users |
| | Cursor IDE | $20/mo | Monthly | Cursor users |
| **💰 CHEAP** | GLM-5.1 / GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup |
| | MiniMax M2.7 | $0.2/1M | 5-hour rolling | Cheapest option |
| | Kimi K2.5 | $9/mo flat | 10M tokens/mo | Predictable cost |
| **🆓 FREE** | Kiro AI | $0 | Unlimited | Claude 4.5 + GLM-5 + MiniMax free |
| | OpenCode Free | $0 | Unlimited | No auth, auto-fetch models |
| | Vertex AI | $300 credits | New GCP accounts | Gemini 3 Pro + DeepSeek + GLM-5 |
**💡 Pro Tip:** RTK + Kiro AI + OpenCode Free combo = **$0 cost + 20-40% token savings**!
@@ -717,7 +607,7 @@ Seamless translation between formats:
**9Router software = FREE forever** (open source, never charges)
**Dashboard "costs" = Display/tracking only** (not actual bills)
**You pay providers directly** (subscriptions or API fees)
**FREE providers stay FREE** (Kiro ~50 credits/mo, OpenCode Free, Vertex $300 credits = $0 within free-tier limits) — note iFlow/Qwen/Gemini CLI free tiers were discontinued in 2026
**FREE providers stay FREE** (iFlow, Kiro, Qwen = $0 unlimited)
**9Router never sends invoices** or charges your card
**How Cost Display Works:**
@@ -725,7 +615,6 @@ Seamless translation between formats:
The dashboard shows **estimated costs** as if you were using paid APIs directly. This is **not billing** - it's a comparison tool to show your savings.
**Example Scenario:**
```
Dashboard Display:
• Total Requests: 1,662
@@ -733,13 +622,12 @@ Dashboard Display:
• Display Cost: $290
Reality Check:
• Provider: Kiro (free tier: ~50 credits/mo)
• Provider: iFlow (FREE unlimited)
• Actual Payment: $0.00
• What $290 Means: Amount you SAVED by using free models!
```
**Payment Rules:**
- **Subscription providers** (Claude Code, Codex): Pay them directly via their websites
- **Cheap providers** (GLM, MiniMax): Pay them directly, 9Router just routes
- **FREE providers** (iFlow, Kiro, Qwen): Genuinely free forever, no hidden charges
@@ -754,7 +642,6 @@ Reality Check:
**Problem:** Quota expires unused, rate limits during heavy coding
**Solution:**
```
Combo: "maximize-claude"
1. cc/claude-opus-4-7 (use subscription fully)
@@ -770,10 +657,9 @@ vs. $20 + hitting limits = frustration
**Problem:** Can't afford subscriptions, need reliable AI coding
**Solution:**
```
Combo: "free-forever"
1. kr/claude-sonnet-4.5 (Claude 4.5 free via Kiro, ~50 credits/mo)
1. kr/claude-sonnet-4.5 (Claude 4.5 free unlimited)
2. kr/glm-5 (GLM-5 free via Kiro)
3. oc/<auto> (OpenCode Free, no auth)
@@ -786,14 +672,13 @@ Quality: Production-ready models + RTK saves 20-40% tokens
**Problem:** Deadlines, can't afford downtime
**Solution:**
```
Combo: "always-on"
1. cc/claude-opus-4-7 (best quality)
2. cx/gpt-5.5 (second subscription)
3. glm/glm-5.1 (cheap, resets daily)
4. minimax/MiniMax-M2.7 (cheapest, 5h reset)
5. kr/claude-sonnet-4.5 (free via Kiro, ~50 credits/mo)
5. kr/claude-sonnet-4.5 (free unlimited)
Result: 5 layers of fallback = zero downtime
Monthly cost: $20-200 (subscriptions) + $10-20 (backup)
@@ -804,7 +689,6 @@ Monthly cost: $20-200 (subscriptions) + $10-20 (backup)
**Problem:** Need AI assistant in messaging apps (WhatsApp, Telegram, Slack...), completely free
**Solution:**
```
Combo: "openclaw-free"
1. kr/claude-sonnet-4.5 (Claude 4.5 free)
@@ -825,9 +709,8 @@ Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
The dashboard tracks your token usage and displays **estimated costs** as if you were using paid APIs directly. This is **not actual billing** - it's a reference to show how much you're saving by using free models or existing subscriptions through 9Router.
**Example:**
- **Dashboard shows:** "$290 total cost"
- **Reality:** You're using Kiro free models (~50 credits/mo)
- **Reality:** You're using iFlow (FREE unlimited)
- **Your actual cost:** **$0.00**
- **What $290 means:** Amount you **saved** by using free models instead of paid APIs!
@@ -841,7 +724,6 @@ The cost display is a "savings tracker" to help you understand your usage patter
**No.** 9Router is free, open-source software that runs on your own computer. It never charges you anything.
**You only pay:**
-**Subscription providers** (Claude Code $20/mo, Codex $20-200/mo) → Pay them directly on their websites
-**Cheap providers** (GLM, MiniMax) → Pay them directly, 9Router just routes your requests
-**9Router itself****Never charges anything, ever**
@@ -853,21 +735,19 @@ The cost display is a "savings tracker" to help you understand your usage patter
<details>
<summary><b>🆓 Are FREE providers really unlimited?</b></summary>
**Mostly!** The current FREE providers (Kiro, OpenCode Free, Vertex) are genuinely free, but free tiers have limits:
**Yes!** The current FREE providers (Kiro, OpenCode Free, Vertex) are genuinely free with **no hidden charges**.
These are free services offered by those respective companies:
- **Kiro AI**: Free unlimited Claude 4.5 + GLM-5 + MiniMax via AWS Builder ID / Google / GitHub OAuth
- **OpenCode Free**: No-auth passthrough proxy, models auto-fetched from `opencode.ai/zen/v1/models`
- **Vertex AI**: $300 free credits for new Google Cloud accounts (90 days)
- **Kiro AI**: ~50 credits/month free (plus 500 trial credits for new accounts in the first 30 days) via AWS Builder ID / Google / GitHub OAuth. Paid tiers available above that.
- **OpenCode Free**: No-auth passthrough proxy, models auto-fetched from `opencode.ai/zen/v1/models`. The free model list fluctuates over time (some models free only for limited promos) — subject to change without notice.
- **Vertex AI**: $300 free credits for new Google Cloud accounts (90 days). Since Mar 2026 the Gemini API endpoint no longer consumes these credits — use the **Vertex AI Studio** endpoint instead.
9Router just routes your requests to them - there's no "catch" or future billing from 9Router itself. They're truly free services, and 9Router makes them easy to use with fallback support.
9Router just routes your requests to them - there's no "catch" or future billing. They're truly free services, and 9Router makes them easy to use with fallback support.
**Discontinued free tiers (no longer recommended):**
-**iFlow**: Was free unlimited, now changed to paid (2026)
-**Qwen Code**: Free OAuth tier fully discontinued by Alibaba on 2026-04-15
-**Gemini CLI**: Service fully shut down by Google on 2026-06-18 (replaced by the closed-source Antigravity CLI). Discontinued — do not use.
-**Qwen Code**: Free OAuth tier discontinued by Alibaba on 2026-04-15
-**Gemini CLI**: Still works, but using it with non-CLI tools (Claude, Codex, Cursor...) may result in account bans — only use if you stick to Gemini CLI itself
</details>
@@ -877,21 +757,17 @@ These are free services offered by those respective companies:
**Free-First Strategy:**
1. **Start with 100% free combo:**
```
1. kr/glm-5 (GLM-5 free via Kiro, ~50 credits/mo)
2. OpenCode Free models (no auth, auto-fetched)
3. Vertex AI Gemini 3 Pro (using the Vertex AI Studio endpoint with $300 credits)
1. gc/gemini-3-flash (180K/month free from Google)
2. if/kimi-k2-thinking (unlimited free from iFlow)
3. qw/qwen3-coder-plus (unlimited free from Qwen)
```
**Cost: $0/month** (within Kiro's free credit cap; OpenCode/Vertex subject to their free-tier limits)
**Cost: $0/month**
2. **Add cheap backup** only if you need it:
```
4. glm/glm-4.7 ($0.6/1M tokens)
```
**Additional cost: Only pay for what you actually use**
3. **Use subscription providers last:**
@@ -910,12 +786,10 @@ These are free services offered by those respective companies:
**Scenario:** You're on a coding sprint and blow through your quotas
**Without 9Router:**
- ❌ Hit rate limit → Work stops → Frustration
- ❌ Or: Accidentally rack up huge API bills
**With 9Router:**
- ✅ Subscription hits limit → Auto-fallback to cheap tier
- ✅ Cheap tier gets expensive → Auto-fallback to free tier
- ✅ Never stop coding → Predictable costs
@@ -1109,7 +983,7 @@ Monthly cost example (100M tokens):
```
Name: free-combo
Models:
1. kr/claude-sonnet-4.5 (Claude 4.5 free via Kiro, ~50 credits/mo)
1. kr/claude-sonnet-4.5 (Claude 4.5 free unlimited)
2. kr/glm-5 (GLM-5 free via Kiro)
3. vertex/gemini-3.1-pro-preview ($300 free credits)
@@ -1239,7 +1113,6 @@ pm2 startup
### Docker
Published images (multi-platform `linux/amd64` + `linux/arm64`):
- Docker Hub: [`decolua/9router`](https://hub.docker.com/r/decolua/9router)
- GHCR: [`ghcr.io/decolua/9router`](https://github.com/decolua/9router/pkgs/container/9router)
@@ -1267,7 +1140,6 @@ docker run -d --name 9router -p 20128:20128 \
```
**Container defaults:**
- `PORT=20128`
- `HOSTNAME=0.0.0.0`
@@ -1284,28 +1156,26 @@ docker pull decolua/9router:latest # update to latest
### Environment Variables
| Variable | Default | Description |
| ---------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- |
| `JWT_SECRET` | Auto-generated (`~/.9router/jwt-secret`) | JWT signing secret for dashboard auth cookie (override to share across instances) |
| `INITIAL_PASSWORD` | `123456` | First login password when no saved hash exists |
| `DATA_DIR` | `~/.9router` | Main app data location (SQLite at `$DATA_DIR/db/data.sqlite`) |
| `PORT` | framework default | Service port (`20128` in examples) |
| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) |
| `NODE_ENV` | runtime default | Set `production` for deploy |
| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL used by cloud sync jobs |
| `CLOUD_URL` | `https://9router.com` | Server-side cloud sync endpoint base URL |
| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | Backward-compatible/public base URL (prefer `BASE_URL` for server runtime) |
| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | Backward-compatible/public cloud URL (prefer `CLOUD_URL` for server runtime) |
| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys |
| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | Salt for stable machine ID hashing |
| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs under `logs/` |
| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (set `true` behind HTTPS reverse proxy) |
| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` routes (recommended for internet-exposed deploys) |
| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | empty | Optional outbound proxy for upstream provider calls |
| `SEARXNG_URL` | `http://localhost:8888/search` | Endpoint for the built-in unauthenticated SearXNG web-search provider |
| Variable | Default | Description |
|----------|---------|-------------|
| `JWT_SECRET` | Auto-generated (`~/.9router/jwt-secret`) | JWT signing secret for dashboard auth cookie (override to share across instances) |
| `INITIAL_PASSWORD` | `123456` | First login password when no saved hash exists |
| `DATA_DIR` | `~/.9router` | Main app data location (SQLite at `$DATA_DIR/db/data.sqlite`) |
| `PORT` | framework default | Service port (`20128` in examples) |
| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) |
| `NODE_ENV` | runtime default | Set `production` for deploy |
| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL used by cloud sync jobs |
| `CLOUD_URL` | `https://9router.com` | Server-side cloud sync endpoint base URL |
| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | Backward-compatible/public base URL (prefer `BASE_URL` for server runtime) |
| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | Backward-compatible/public cloud URL (prefer `CLOUD_URL` for server runtime) |
| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys |
| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | Salt for stable machine ID hashing |
| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs under `logs/` |
| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (set `true` behind HTTPS reverse proxy) |
| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` routes (recommended for internet-exposed deploys) |
| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | empty | Optional outbound proxy for upstream provider calls |
Notes:
- Lowercase proxy variables are also supported: `http_proxy`, `https_proxy`, `all_proxy`, `no_proxy`.
- `.env` is not baked into Docker image (`.dockerignore`); inject runtime config with `--env-file` or `-e`.
- On Windows, `APPDATA` can be used for local storage path resolution.
@@ -1328,7 +1198,6 @@ Notes:
<summary><b>View all available models</b></summary>
**Claude Code (`cc/`)** - Pro/Max:
- `cc/claude-opus-4-7`
- `cc/claude-opus-4-6`
- `cc/claude-sonnet-4-6`
@@ -1336,7 +1205,6 @@ Notes:
- `cc/claude-haiku-4-5-20251001`
**Codex (`cx/`)** - Plus/Pro:
- `cx/gpt-5.5`
- `cx/gpt-5.4`
- `cx/gpt-5.3-codex`
@@ -1344,7 +1212,6 @@ Notes:
- `cx/gpt-5.1-codex-max`
**GitHub Copilot (`gh/`)**:
- `gh/gpt-5.4`
- `gh/claude-opus-4.7`
- `gh/claude-sonnet-4.6`
@@ -1352,30 +1219,25 @@ Notes:
- `gh/grok-code-fast-1`
**Cursor (`cu/`)** - Subscription:
- `cu/claude-4.6-opus-max`
- `cu/claude-4.5-sonnet-thinking`
- `cu/gpt-5.3-codex`
- `cu/kimi-k2.5`
**GLM (`glm/`)** - $0.6/1M:
- `glm/glm-5.1`
- `glm/glm-5`
- `glm/glm-4.7`
**MiniMax (`minimax/`)** - $0.2/1M:
- `minimax/MiniMax-M2.7`
- `minimax/MiniMax-M2.5`
**Kimi (`kimi/`)** - $9/mo flat:
- `kimi/kimi-k2.5`
- `kimi/kimi-k2.5-thinking`
**Kiro (`kr/`)** - Free (~50 credits/month, paid tiers above):
**Kiro (`kr/`)** - FREE unlimited:
- `kr/claude-sonnet-4.5`
- `kr/claude-haiku-4.5`
- `kr/glm-5`
@@ -1384,11 +1246,9 @@ Notes:
- `kr/deepseek-3.2`
**OpenCode Free (`oc/`)** - FREE no-auth:
- Auto-fetched from `opencode.ai/zen/v1/models`
**Vertex AI (`vertex/`)** - $300 free credits:
- `vertex/gemini-3.1-pro-preview`
- `vertex/gemini-3-flash-preview`
- `vertex/gemini-2.5-flash`
@@ -1402,38 +1262,31 @@ Notes:
## 🐛 Troubleshooting
**"Language model did not provide messages"**
- Provider quota exhausted → Check dashboard quota tracker
- Solution: Use combo fallback or switch to cheaper tier
**Rate limiting**
- Subscription quota out → Fallback to GLM/MiniMax
- Add combo: `cc/claude-opus-4-7 → glm/glm-5.1 → kr/claude-sonnet-4.5`
**OAuth token expired**
- Auto-refreshed by 9Router
- If issues persist: Dashboard → Provider → Reconnect
**High costs**
- Enable RTK in Dashboard → Endpoint settings (default ON, saves 20-40% tokens)
- Check usage stats in Dashboard
- Switch primary model to GLM/MiniMax
- Use free tier (Kiro, OpenCode Free, Vertex) for non-critical tasks
**Dashboard opens on wrong port**
- Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128`
**First login not working**
- Check `INITIAL_PASSWORD` in `.env`
- If unset, fallback password is `123456`
**No request logs under `logs/`**
- Set `ENABLE_REQUEST_LOGS=true`
---
@@ -1496,6 +1349,8 @@ Thanks to all contributors who helped make 9Router better!
[![Star Chart](https://starchart.cc/decolua/9router.svg?variant=adaptive)](https://starchart.cc/decolua/9router)
## 🔀 Forks
**[OmniRoute](https://github.com/diegosouzapw/OmniRoute)** — A full-featured TypeScript fork of 9Router. Adds 36+ providers, 4-tier auto-fallback, multi-modal APIs (images, embeddings, audio, TTS), circuit breaker, semantic cache, LLM evaluations, and a polished dashboard. 368+ unit tests. Available via npm and Docker.
@@ -1508,8 +1363,8 @@ Built on the shoulders of giants:
- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — original Go implementation that inspired this JavaScript port.
- **[RTK](https://github.com/rtk-ai/rtk)** ![Stars](https://img.shields.io/github/stars/rtk-ai/rtk?style=flat&color=yellow) — Rust token-saver. 9Router ports its compression pipeline to JS → **20-40% input tokens** on every request.
- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) by **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral _"why use many token when few token do trick"_. 9Router adapts its prompt → **65% output tokens**.
- **[Ponytail](https://github.com/DietrichGebert/ponytail)** ![Stars](https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat&color=yellow) by **[@DietrichGebert](https://github.com/DietrichGebert)** — _"lazy senior dev"_ skill. 9Router injects its YAGNI-first ladder → **fewer tokens, less code, shorter diffs**.
- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) by **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral *"why use many token when few token do trick"*. 9Router adapts its prompt → **65% output tokens**.
- **[Ponytail](https://github.com/DietrichGebert/ponytail)** ![Stars](https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat&color=yellow) by **[@DietrichGebert](https://github.com/DietrichGebert)** — *"lazy senior dev"* skill. 9Router injects its YAGNI-first ladder → **fewer tokens, less code, shorter diffs**.
Huge thanks to these authors — without their work, 9Router's token-saving features wouldn't exist. ⭐ them on GitHub!

View File

@@ -82,7 +82,7 @@ npm install -g 9router
**2. 连接免费提供商(无需注册):**
控制面板 → 提供商 → 连接 **Kiro AI**约 50 积分/月免费Claude 4.5 + GLM-5 + MiniMax)或 **OpenCode Free**(无需认证)→ 完成!
控制面板 → 提供商 → 连接 **Kiro AI**(免费 Claude 无限量)或 **OpenCode Free**(无需认证)→ 完成!
**3. 在 CLI 工具中使用:**
@@ -279,12 +279,12 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run
<td align="center" width="150">
<img src="./public/providers/kiro.png" width="70" alt="Kiro"/><br/>
<b>Kiro AI</b><br/>
<sub>Claude 4.5 + GLM-5 + MiniMax<br/>每月 50 积分免费</sub>
<sub>Claude 4.5 + GLM-5 + MiniMax<br/>无限免费</sub>
</td>
<td align="center" width="150">
<img src="./public/providers/opencode.png" width="70" alt="OpenCode Free"/><br/>
<b>OpenCode Free</b><br/>
<sub>无需认证 • 自动获取模型<br/>免费(模型列表会变)</sub>
<sub>无需认证 • 自动获取模型<br/>无限免费</sub>
</td>
<td align="center" width="150">
<img src="./public/providers/gemini.png" width="70" alt="Vertex AI"/><br/>
@@ -295,11 +295,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run
</table>
</div>
> **注意:** iFlow、Qwen Code 和 Gemini CLI 的免费等级已于 2026 年停止。请改用 Kiro / OpenCode Free / Vertex。
>
> **Kiro AI** 于 2025 年 9 月转为付费模式 — 免费等级现在上限为**每月 50 积分**(新账户前 30 天另加 500 试用积分。付费档位Pro $20/月1,000 积分、Pro+ $40/月2,000、Pro Max $100/月5,000、Power $200/月10,000
> **OpenCode Free** 的模型列表会随时间变化(部分模型仅限时免费)— 可能随时变更,恕不另行通知。
> **Vertex AI**:新 GCP 账户的 $300 免费额度仍然有效,但自 2026 年 3 月起 **Gemini API 端点不再消耗这些额度** — 请改用 **Vertex AI Studio** 端点。
> **注意:** iFlow、Qwen 和 Gemini CLI 的免费等级已于 2026 年停止。请改用 Kiro / OpenCode Free / Vertex。
### 🔑 API Key 提供商40+
@@ -504,7 +500,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run
> 使用分析中显示的"成本"**仅用于追踪和比较目的**。
> 9Router 本身**永远不会向你收费**。你只直接向提供商付款(如果使用付费服务)。
>
> **示例:** 如果你的控制面板显示使用 Kiro 免费模型时"总成本 $290",这代表你如果直接使用付费 API 需要支付的金额。你的实际成本 = **$0**Kiro 免费等级:约 50 积分/月)。
> **示例:** 如果你的控制面板显示使用 iFlow 模型时"总成本 $290",这代表你如果直接使用付费 API 需要支付的金额。你的实际成本 = **$0**iFlow 免费无限量)。
>
> 把它想象成一个"节省追踪器",展示你通过使用免费模型或通过 9Router 路由节省了多少钱!
@@ -531,9 +527,9 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run
| **💰 低价** | GLM-5.1 / GLM-4.7 | $0.6/1M | 每日 10AM | 预算备份 |
| | MiniMax M2.7 | $0.2/1M | 5小时滚动 | 最便宜选项 |
| | Kimi K2.5 | $9/月固定 | 10M tokens/月 | 可预测成本 |
| **🆓 免费** | Kiro AI | $0 | 50 积分/月 | Claude 4.5 + GLM-5 + MiniMax 免费(之上为付费档位) |
| | OpenCode Free | $0 | varies* | 无需认证,自动获取模型(列表会变化) |
| | Vertex AI | $300 额度 | 新 GCP 账户 | Gemini 3 Pro + DeepSeek + GLM-5(使用 Vertex AI Studio 端点消耗免费额度) |
| **🆓 免费** | Kiro AI | $0 | 无限量 | Claude 4.5 + GLM-5 + MiniMax 免费 |
| | OpenCode Free | $0 | 无限量 | 无需认证,自动获取模型 |
| | Vertex AI | $300 额度 | 新 GCP 账户 | Gemini 3 Pro + DeepSeek + GLM-5 |
**💡 专业提示:** RTK + Kiro AI + OpenCode Free 组合 = **$0 成本 + 节省 20-40% tokens**
@@ -546,7 +542,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run
**9Router 软件 = 永久免费**(开源,绝不收费)
**控制面板"成本" = 仅用于显示/追踪**(不是实际账单)
**你直接向提供商付款**(订阅或 API 费用)
**免费提供商保持免费**Kiro 约 50 积分/月、OpenCode Free、Vertex $300 额度 = 在免费额度内 $0— 注意 iFlow/Qwen/Gemini CLI 免费等级已于 2026 年停止
**免费提供商保持免费**iFlow、Kiro、Qwen = $0 无限量)
**9Router 永不发送发票** 或扣款
**成本显示如何工作:**
@@ -561,7 +557,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run
• 显示成本:$290
实际检查:
• 提供商:Kiro免费等级约 50 积分/月
• 提供商:iFlow免费无限量
• 实际支付:$0.00
• $290 意味着什么:通过使用免费模型节省的金额!
```
@@ -569,7 +565,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run
**付款规则:**
- **订阅提供商**Claude Code、Codex通过他们的网站直接付款
- **低价提供商**GLM、MiniMax直接付款9Router 只做路由
- **免费提供商**Kiro、OpenCode Free、Vertex):真正的免费,在免费额度内无隐藏费用
- **免费提供商**iFlow、Kiro、Qwen):真正的永久免费,无隐藏费用
- **9Router**:从不收取任何费用,永远不会
---
@@ -598,7 +594,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run
**解决方案:**
```
组合:"free-forever"
1. kr/claude-sonnet-4.5 通过 Kiro 免费使用 Claude 4.5,约 50 积分/月
1. kr/claude-sonnet-4.5 Claude 4.5 免费无限量
2. kr/glm-5 (通过 Kiro 免费使用 GLM-5
3. oc/<auto> OpenCode Free无需认证
@@ -617,7 +613,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run
2. cx/gpt-5.5 (第二个订阅)
3. glm/glm-5.1 (低价,每日重置)
4. minimax/MiniMax-M2.7 最便宜5小时重置
5. kr/claude-sonnet-4.5 通过 Kiro 免费使用,约 50 积分/月
5. kr/claude-sonnet-4.5 免费无限量
结果5 层切换 = 零停机时间
月成本:$20-200订阅+ $10-20备份
@@ -649,7 +645,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run
**示例:**
- **控制面板显示:** "$290 总成本"
- **实际情况:** 你在使用 Kiro 免费模型(约 50 积分/月
- **实际情况:** 你在使用 iFlow免费无限量
- **你的实际成本:** **$0.00**
- **$290 的含义:** 你通过使用免费模型而不是付费 API **节省**的金额!
@@ -674,19 +670,19 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run
<details>
<summary><b>🆓 免费提供商真的是无限量的吗?</b></summary>
**基本上是!** 当前的免费提供商Kiro、OpenCode Free、Vertex是真正的免费但免费等级有上限:
**是** 当前的免费提供商Kiro、OpenCode Free、Vertex是真正的免费**无隐藏费用**。
这些是各公司提供的免费服务:
- **Kiro AI**:通过 AWS Builder ID / Google / GitHub OAuth 使用,免费等级约**每月 50 积分**(新账户前 30 天另加 500 试用积分)。之上提供付费档位。
- **OpenCode Free**:无认证直连代理,模型从 `opencode.ai/zen/v1/models` 自动获取。免费模型列表会随时间变化(部分模型仅限时免费)— 可能随时变更。
- **Vertex AI**:新 Google Cloud 账户可获得 $300 免费额度90 天)。自 2026 年 3 月起 Gemini API 端点不再消耗这些额度 — 请改用 **Vertex AI Studio** 端点。
- **Kiro AI**:通过 AWS Builder ID / Google / GitHub OAuth 免费无限量使用 Claude 4.5 + GLM-5 + MiniMax
- **OpenCode Free**:无认证直连代理,模型从 `opencode.ai/zen/v1/models` 自动获取
- **Vertex AI**:新 Google Cloud 账户可获得 $300 免费额度90 天)
9Router 只是路由你的请求到它们 — 没有"陷阱"或未来的计费。它们是真正的免费服务9Router 让它们易于使用并支持切换。
**已停止的免费等级(不再推荐):**
-**iFlow**曾是免费无限量现在改为付费2026
-**Qwen Code**:阿里巴巴于 2026-04-15 完全停止免费 OAuth 等级
-**Gemini CLI**Google 已于 2026-06-18 完全停止服务(由闭源的 Antigravity CLI 取代)。已停止 — 请勿使用
-**Qwen Code**:阿里巴巴于 2026-04-15 停止免费 OAuth 等级
-**Gemini CLI**仍可用,但与非 CLI 工具Claude、Codex、Cursor...)一起使用可能会导致账户被封 — 仅在你坚持使用 Gemini CLI 本身时才使用
</details>
@@ -697,11 +693,11 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run
1. **从 100% 免费组合开始:**
```
1. kr/glm-5 (通过 Kiro 免费使用 GLM-5约 50 积分/月)
2. OpenCode Free 模型(无认证,自动获取)
3. Vertex AI Gemini 3 Pro使用 Vertex AI Studio 端点 + $300 额度)
1. gc/gemini-3-flash (Google 每月 180K 免费)
2. if/kimi-k2-thinking (iFlow 无限量免费)
3. qw/qwen3-coder-plus (Qwen 无限量免费)
```
**成本:$0/月**(在 Kiro 免费积分上限内OpenCode/Vertex 受各自免费等级限制)
**成本:$0/月**
2. **仅在需要时添加低价备份:**
```
@@ -922,7 +918,7 @@ Vertex 合作伙伴(通过 Vertex 提供 Anthropic / DeepSeek / GLM / Qwen
```
名称free-combo
模型:
1. kr/claude-sonnet-4.5 (通过 Kiro 免费使用 Claude 4.5,约 50 积分/月)
1. kr/claude-sonnet-4.5 (Claude 4.5 免费无限量)
2. kr/glm-5 (通过 Kiro 免费使用 GLM-5)
3. vertex/gemini-3.1-pro-preview ($300 免费额度)
@@ -1172,7 +1168,7 @@ docker stop 9router && docker rm 9router
- `kimi/kimi-k2.5`
- `kimi/kimi-k2.5-thinking`
**Kiro`kr/`** - 免费(约 50 积分/月,之上为付费档位)
**Kiro`kr/`** - 免费无限量
- `kr/claude-sonnet-4.5`
- `kr/claude-haiku-4.5`
- `kr/glm-5`

View File

@@ -4,28 +4,8 @@ const { spawn, exec, execSync } = require("child_process");
const path = require("path");
const fs = require("fs");
const https = require("https");
const net = require("net");
const os = require("os");
// Poll until the server accepts TCP connections on port, or timeout — avoids blind fixed waits.
function waitServerReady(port, { timeoutMs = 15000, intervalMs = 150 } = {}) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve) => {
const tryConnect = () => {
const socket = net.connect({ host: "127.0.0.1", port }, () => {
socket.destroy();
resolve(true);
});
socket.on("error", () => {
socket.destroy();
if (Date.now() >= deadline) return resolve(false);
setTimeout(tryConnect, intervalMs);
});
};
tryConnect();
});
}
// Native spinner - no external dependency
function createSpinner(text) {
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -67,19 +47,6 @@ const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRunt
const { ensureTrayRuntime } = require("./hooks/trayRuntime");
const args = process.argv.slice(2);
// Subcommands (`9router xai video …`) run against an already-running gateway
// and bypass the launcher flow (no runtime self-heal, no server spawn).
if (args[0] === "xai" && args[1] === "video") {
const { run } = require("./src/cli/commands/xaiVideo");
run(args.slice(2))
.then((code) => process.exit(code))
.catch((err) => {
console.error(`${err?.message || err}`);
process.exit(1);
});
return;
}
// Self-heal SQLite runtime deps (sql.js + better-sqlite3) into ~/.9router/runtime
// so the server can resolve them via NODE_PATH. Best-effort — sql.js is required,
// better-sqlite3 is optional. Logs to stderr only on failure.
@@ -152,11 +119,6 @@ Options:
--skip-update Skip auto-update check
-h, --help Show this help message
-v, --version Show version
Commands:
xai video --prompt "..." --output video.mp4
Generate a Grok Imagine video via the running gateway
(see: ${APP_NAME} xai video --help)
`);
process.exit(0);
} else if (args[i] === "--version" || args[i] === "-v") {
@@ -250,18 +212,17 @@ function killCloudflaredByAppPort(appPort) {
function killAllAppProcesses(appPort) {
return new Promise((resolve) => {
try {
// Background: MITM + tunnel/cloudflared run on separate ports/processes —
// killing them doesn't free the app port, so don't block the critical path.
// Server-side MITM manager has stale-lock recovery and starts deferred (~3s).
setImmediate(() => {
try { killProxyByPidFile(); } catch {}
try { killTunnelByPidFile(); } catch {}
try { killCloudflaredByAppPort(appPort); } catch {}
});
// Kill MIT first (privileged process, needs special handling)
killProxyByPidFile();
// Kill cloudflared/tailscale by PID file (precise, only this app's tunnel)
killTunnelByPidFile();
const platform = process.platform;
let pids = [];
// Catch stale PID files: kill cloudflared bound to this app's port
pids.push(...killCloudflaredByAppPort(appPort));
if (platform === "win32") {
// Windows: use WMI to get full CommandLine (tasklist /V doesn't include it)
try {
@@ -538,11 +499,14 @@ if (!fs.existsSync(serverPath)) {
process.exit(1);
}
// Start server immediately; run update check in parallel (not on the critical path).
const updatePromise = checkForUpdate();
killAllAppProcesses(port)
.then(() => killProcessOnPort(port))
.then(() => startServer(updatePromise));
// Check for updates FIRST, then start server
checkForUpdate().then((latestVersion) => {
killAllAppProcesses(port).then(() => {
return killProcessOnPort(port);
}).then(() => {
startServer(latestVersion);
});
});
// Show interface selection menu
async function showInterfaceMenu(latestVersion) {
@@ -592,9 +556,7 @@ async function showInterfaceMenu(latestVersion) {
const MAX_RESTARTS = 2;
const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s
function startServer(updatePromise) {
// Accept either a Promise (parallel update check) or a resolved value.
const latestVersionPromise = Promise.resolve(updatePromise);
function startServer(latestVersion) {
const displayHost = getDisplayHost();
const url = `http://${displayHost}:${port}/dashboard`;
// Surface real network exposure when bound to all interfaces (default 0.0.0.0).
@@ -612,7 +574,7 @@ function startServer(updatePromise) {
function spawnServer() {
serverStartTime = Date.now();
crashLog = [];
const child = spawn(RUNTIME, ["--dns-result-order=ipv4first", "--max-old-space-size=6144", serverPath], {
const child = spawn(RUNTIME, ["--max-old-space-size=6144", serverPath], {
cwd: standaloneDir,
stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"],
detached: true,
@@ -715,19 +677,17 @@ function startServer(updatePromise) {
console.log(`\n🚀 ${pkg.name} v${pkg.version}`);
console.log(`Server: http://${displayHost}:${port}`);
waitServerReady(port).then(() => {
setTimeout(() => {
initTrayIcon();
console.log("\n💡 Router is now running in system tray. Close this terminal if you want.");
console.log(" Right-click tray icon to open dashboard or quit.\n");
});
}, 2000);
return;
}
// Wait for server to be ready, then show interface menu loop + tray
waitServerReady(port).then(async () => {
// Resolve parallel update check (already running); don't block server start on it.
const latestVersion = await latestVersionPromise;
setTimeout(async () => {
// Start tray icon alongside TUI
initTrayIcon();
@@ -785,7 +745,7 @@ function startServer(updatePromise) {
// Windows/Linux: spawn detached bgProcess (systray works fine in child)
console.log(`\n⏳ Starting background process... (tray icon will appear in ~3s)`);
const bgProcess = spawn(process.execPath, ["--dns-result-order=ipv4first", __filename, "--tray", "--skip-update", "-p", port.toString()], {
const bgProcess = spawn(process.execPath, [__filename, "--tray", "--skip-update", "-p", port.toString()], {
detached: true,
stdio: "ignore",
windowsHide: true,
@@ -812,7 +772,7 @@ function startServer(updatePromise) {
cleanup();
process.exit(1);
}
});
}, 3000);
function attachServerEvents() {
server.on("error", (err) => {

View File

@@ -1,6 +1,6 @@
{
"name": "9router",
"version": "0.5.55",
"version": "0.5.8",
"description": "9Router CLI - Start and manage 9Router server",
"bin": {
"9router": "./cli.js"

View File

@@ -7,7 +7,7 @@ const { execSync } = require("child_process");
const cliDir = path.resolve(__dirname, "..");
const appDir = path.resolve(cliDir, "..");
const rootDir = path.resolve(appDir, "..");
const cliAppDir = process.env.NINEROUTER_CLI_APP_DIR || path.join(cliDir, "app");
const cliAppDir = path.join(cliDir, "app");
const buildHomeDir = path.join(cliDir, ".build-home");
const buildDistDirName = ".next-cli-build";
const buildDistDir = path.join(appDir, buildDistDirName);
@@ -81,274 +81,201 @@ function copyRecursive(src, dest) {
}
}
function resolveStandaloneBuild(appDir, buildDistDir) {
const legacyStandaloneRoot = path.join(appDir, ".next", "standalone");
const resolvedStandaloneRoot = path.join(buildDistDir, "standalone");
let standaloneRoot = fs.existsSync(resolvedStandaloneRoot)
? resolvedStandaloneRoot
: legacyStandaloneRoot;
console.log("📦 Building 9Router CLI package with Next.js...\n");
// Next.js 16 nests standalone output under the project name when
// NEXT_TRACING_ROOT_MODE=workspace, e.g. standalone/9router/server.js.
const pkgName = path.basename(appDir);
const nestedRoot = path.join(standaloneRoot, pkgName);
if (fs.existsSync(path.join(nestedRoot, "server.js")) && !fs.existsSync(path.join(standaloneRoot, "server.js"))) {
console.log(` Detected nested standalone output: ${pkgName}/`);
standaloneRoot = nestedRoot;
}
fs.mkdirSync(buildHomeDir, { recursive: true });
fs.mkdirSync(path.join(buildHomeDir, "AppData", "Roaming"), { recursive: true });
fs.mkdirSync(path.join(buildHomeDir, "AppData", "Local"), { recursive: true });
const standaloneApp = fs.existsSync(path.join(standaloneRoot, "server.js"))
? standaloneRoot
: path.join(standaloneRoot, "app");
if (!fs.existsSync(standaloneApp)) {
throw new Error(
"Next.js standalone build not found under .next/standalone; " +
"expected either .next/standalone/server.js or .next/standalone/app/",
);
}
return { standaloneApp, standaloneRoot };
// Step 0: Sync version from app/cli/package.json to app/package.json
console.log("0⃣ Syncing version to app/package.json...");
const cliPkg = JSON.parse(fs.readFileSync(path.join(cliDir, "package.json"), "utf8"));
const appPkgPath = path.join(appDir, "package.json");
const appPkg = JSON.parse(fs.readFileSync(appPkgPath, "utf8"));
if (appPkg.version !== cliPkg.version) {
appPkg.version = cliPkg.version;
fs.writeFileSync(appPkgPath, JSON.stringify(appPkg, null, 2) + "\n");
console.log(`✅ Version synced: ${cliPkg.version}\n`);
} else {
console.log(`✅ Version already synced: ${cliPkg.version}\n`);
}
function copyStandaloneBuild(appDir, buildDistDir, cliAppDir) {
const { standaloneApp, standaloneRoot } = resolveStandaloneBuild(appDir, buildDistDir);
copyRecursive(standaloneApp, cliAppDir);
// Older nested-app layout stores traced node_modules at standalone root.
const standaloneNodeModules = path.join(standaloneRoot, "node_modules");
if (standaloneApp !== standaloneRoot && fs.existsSync(standaloneNodeModules)) {
copyRecursive(standaloneNodeModules, path.join(cliAppDir, "node_modules"));
}
// Step 1: Build app with Next.js (workspace tracing root → traced node_modules in standalone).
console.log("1⃣ Building Next.js app...");
try {
execSync("npm run build", {
stdio: "inherit",
cwd: appDir,
env: {
...process.env,
HOME: buildHomeDir,
USERPROFILE: buildHomeDir,
APPDATA: path.join(buildHomeDir, "AppData", "Roaming"),
LOCALAPPDATA: path.join(buildHomeDir, "AppData", "Local"),
NEXT_DIST_DIR: buildDistDirName,
NEXT_TRACING_ROOT_MODE: "workspace",
}
});
console.log("✅ Next.js build completed\n");
} catch (error) {
console.error("❌ Next.js build failed");
process.exit(1);
}
function mergeServerArtifacts(buildDistDir, cliAppDir) {
const serverSrc = path.join(buildDistDir, "server");
const serverDest = path.join(cliAppDir, buildDistDirName, "server");
if (!fs.existsSync(serverSrc)) {
throw new Error(`Complete Next.js server build not found: ${serverSrc}`);
}
copyRecursive(serverSrc, serverDest);
// Step 2: Clean old app/cli/app if exists
console.log("2⃣ Cleaning old app/cli/app...");
if (fs.existsSync(cliAppDir)) {
fs.rmSync(cliAppDir, { recursive: true, force: true });
}
console.log("✅ Cleaned\n");
// Step 3: Copy Next.js standalone build to app/cli/app.
// Newer Next.js standalone output writes server.js/package.json plus .next/, src/, and
// node_modules/ directly under .next/standalone. Older builds may still use a nested app/.
console.log("3⃣ Copying Next.js standalone build to app/cli/app...");
const standaloneRoot = path.join(appDir, ".next", "standalone");
const standaloneRootResolved = path.join(buildDistDir, "standalone");
let standaloneRootToUse = fs.existsSync(standaloneRootResolved) ? standaloneRootResolved : standaloneRoot;
// Next.js 16 nests standalone output under the project name when NEXT_TRACING_ROOT_MODE=workspace
// e.g. .next-cli-build/standalone/9router/server.js
const pkgName = path.basename(appDir);
const nestedRoot = path.join(standaloneRootToUse, pkgName);
if (fs.existsSync(path.join(nestedRoot, "server.js")) && !fs.existsSync(path.join(standaloneRootToUse, "server.js"))) {
console.log(` Detected nested standalone output: ${pkgName}/`);
standaloneRootToUse = nestedRoot;
}
const standaloneApp = fs.existsSync(path.join(standaloneRootToUse, "server.js"))
? standaloneRootToUse
: path.join(standaloneRootToUse, "app");
if (!fs.existsSync(standaloneApp)) {
console.error("❌ Next.js standalone build not found under .next/standalone");
console.error("Expected either .next/standalone/server.js or .next/standalone/app/");
process.exit(1);
}
copyRecursive(standaloneApp, cliAppDir);
// Older nested-app layout stores traced node_modules at standalone root.
const standaloneNodeModules = path.join(standaloneRootToUse, "node_modules");
if (standaloneApp !== standaloneRootToUse && fs.existsSync(standaloneNodeModules)) {
copyRecursive(standaloneNodeModules, path.join(cliAppDir, "node_modules"));
}
console.log("✅ Copied standalone build\n");
// Step 3a: Copy custom server (injects real socket IP, strips spoofable XFF).
const customServerSrc = path.join(appDir, "custom-server.js");
if (fs.existsSync(customServerSrc)) {
fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js"));
console.log("✅ Copied custom-server.js\n");
} else {
console.warn("⚠️ custom-server.js not found — server will run without real-IP injection\n");
}
function assertRequiredApiArtifacts(cliAppDir) {
const requiredArtifacts = [
"app/api/v1/chat/completions/route.js",
"app/api/v1/messages/route.js",
// Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules.
// Strip better-sqlite3 (native) — it lives in ~/.9router/runtime to avoid
// Windows EBUSY during global CLI updates. node:sqlite (Node ≥22.5) is also
// available as a no-install middle tier.
console.log("3⃣ b Configuring SQLite drivers...");
function ensureModuleInBundle(pkg) {
const dest = path.join(cliAppDir, "node_modules", pkg);
if (fs.existsSync(dest)) {
console.log(`${pkg} already bundled`);
return;
}
const candidates = [
path.join(appDir, "node_modules", pkg),
path.join(rootDir, "node_modules", pkg),
];
const serverDir = path.join(cliAppDir, buildDistDirName, "server");
const missingArtifacts = requiredArtifacts
.map((artifact) => path.join(serverDir, artifact))
.filter((artifact) => !fs.existsSync(artifact));
if (missingArtifacts.length > 0) {
throw new Error(
`Required CLI API route artifact${missingArtifacts.length === 1 ? " is" : "s are"} missing:\n` +
missingArtifacts.join("\n"),
);
const src = candidates.find((p) => fs.existsSync(p));
if (!src) {
console.warn(`⚠️ ${pkg} not found locally — bundle will rely on node:sqlite or runtime install`);
return;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
copyRecursive(src, dest);
console.log(`✅ Bundled ${pkg}`);
}
ensureModuleInBundle("sql.js");
const betterDir = path.join(cliAppDir, "node_modules", "better-sqlite3");
if (fs.existsSync(betterDir)) {
fs.rmSync(betterDir, { recursive: true, force: true });
console.log("✅ Stripped better-sqlite3 (lives in ~/.9router/runtime)");
}
console.log("");
// Step 4: Copy static files
console.log("4⃣ Copying static files...");
const staticSrc = path.join(appDir, ".next", "static");
const staticSrcResolved = path.join(buildDistDir, "static");
const staticDest = path.join(cliAppDir, buildDistDirName, "static");
if (fs.existsSync(staticSrcResolved) || fs.existsSync(staticSrc)) {
copyRecursive(fs.existsSync(staticSrcResolved) ? staticSrcResolved : staticSrc, staticDest);
console.log("✅ Copied static files\n");
} else {
console.log("⏭️ No static files found\n");
}
function buildCliPackage() {
console.log("📦 Building 9Router CLI package with Next.js...\n");
fs.mkdirSync(buildHomeDir, { recursive: true });
fs.mkdirSync(path.join(buildHomeDir, "AppData", "Roaming"), { recursive: true });
fs.mkdirSync(path.join(buildHomeDir, "AppData", "Local"), { recursive: true });
// Step 0: Sync version from app/cli/package.json to app/package.json
console.log("0⃣ Syncing version to app/package.json...");
const cliPkg = JSON.parse(fs.readFileSync(path.join(cliDir, "package.json"), "utf8"));
const appPkgPath = path.join(appDir, "package.json");
const appPkg = JSON.parse(fs.readFileSync(appPkgPath, "utf8"));
if (appPkg.version !== cliPkg.version) {
appPkg.version = cliPkg.version;
fs.writeFileSync(appPkgPath, JSON.stringify(appPkg, null, 2) + "\n");
console.log(`✅ Version synced: ${cliPkg.version}\n`);
} else {
console.log(`✅ Version already synced: ${cliPkg.version}\n`);
}
// Step 1: Build app with Next.js (workspace tracing root → traced node_modules in standalone).
console.log("1⃣ Building Next.js app...");
try {
execSync("npm run build", {
stdio: "inherit",
cwd: appDir,
env: {
...process.env,
HOME: buildHomeDir,
USERPROFILE: buildHomeDir,
APPDATA: path.join(buildHomeDir, "AppData", "Roaming"),
LOCALAPPDATA: path.join(buildHomeDir, "AppData", "Local"),
NEXT_DIST_DIR: buildDistDirName,
NEXT_TRACING_ROOT_MODE: "workspace",
}
});
console.log("✅ Next.js build completed\n");
} catch (error) {
console.error("❌ Next.js build failed");
process.exit(1);
}
// Step 2: Clean old app/cli/app if exists
console.log("2⃣ Cleaning old app/cli/app...");
if (fs.existsSync(cliAppDir)) {
fs.rmSync(cliAppDir, { recursive: true, force: true });
}
console.log("✅ Cleaned\n");
// Step 3: Copy Next.js standalone build to app/cli/app.
// Newer Next.js standalone output writes server.js/package.json plus .next/, src/, and
// node_modules/ directly under .next/standalone. Older builds may still use a nested app/.
console.log("3⃣ Copying Next.js standalone build to app/cli/app...");
try {
copyStandaloneBuild(appDir, buildDistDir, cliAppDir);
} catch (error) {
console.error("❌ Next.js standalone build not found under .next/standalone");
console.error("Expected either .next/standalone/server.js or .next/standalone/app/");
process.exit(1);
}
console.log("✅ Copied standalone build\n");
// Step 3a: Copy custom server (injects real socket IP, strips spoofable XFF).
const customServerSrc = path.join(appDir, "custom-server.js");
if (fs.existsSync(customServerSrc)) {
fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js"));
console.log("✅ Copied custom-server.js\n");
} else {
console.error("❌ custom-server.js not found — without it no request can be proven local,");
console.error(" so the packaged CLI would demand an API key for its own dashboard and /v1.");
process.exit(1);
}
// Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules.
// Strip better-sqlite3 (native) — it lives in ~/.9router/runtime to avoid
// Windows EBUSY during global CLI updates. node:sqlite (Node ≥22.5) is also
// available as a no-install middle tier.
console.log("3⃣ b Configuring SQLite drivers...");
function ensureModuleInBundle(pkg) {
const dest = path.join(cliAppDir, "node_modules", pkg);
if (fs.existsSync(dest)) {
console.log(`${pkg} already bundled`);
return;
}
const candidates = [
path.join(appDir, "node_modules", pkg),
path.join(rootDir, "node_modules", pkg),
];
const src = candidates.find((p) => fs.existsSync(p));
if (!src) {
console.warn(`⚠️ ${pkg} not found locally — bundle will rely on node:sqlite or runtime install`);
return;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
copyRecursive(src, dest);
console.log(`✅ Bundled ${pkg}`);
}
ensureModuleInBundle("sql.js");
// `open` is external (see serverExternalPackages in next.config.mjs), so it must exist in
// the bundle's node_modules or every importer throws MODULE_NOT_FOUND at runtime. Output
// tracing normally copies it; this is the same belt-and-braces guard used for sql.js.
ensureModuleInBundle("open");
const betterDir = path.join(cliAppDir, "node_modules", "better-sqlite3");
if (fs.existsSync(betterDir)) {
fs.rmSync(betterDir, { recursive: true, force: true });
console.log("✅ Stripped better-sqlite3 (lives in ~/.9router/runtime)");
}
console.log("");
// Step 4: Copy static files
console.log("4⃣ Copying static files...");
const staticSrc = path.join(appDir, ".next", "static");
const staticSrcResolved = path.join(buildDistDir, "static");
const staticDest = path.join(cliAppDir, buildDistDirName, "static");
if (fs.existsSync(staticSrcResolved) || fs.existsSync(staticSrc)) {
copyRecursive(fs.existsSync(staticSrcResolved) ? staticSrcResolved : staticSrc, staticDest);
console.log("✅ Copied static files\n");
} else {
console.log("⏭️ No static files found\n");
}
// Step 5: Copy public folder if exists
console.log("5⃣ Copying public folder...");
const publicSrc = path.join(appDir, "public");
const publicDest = path.join(cliAppDir, "public");
if (fs.existsSync(publicSrc)) {
copyRecursive(publicSrc, publicDest);
console.log("✅ Copied public folder\n");
} else {
console.log("⏭️ No public folder found\n");
}
// Step 6: Copy vendor-chunks (required for production)
console.log("6⃣ Copying vendor-chunks...");
const vendorChunksSrc = path.join(appDir, ".next", "server", "vendor-chunks");
const vendorChunksSrcResolved = path.join(buildDistDir, "server", "vendor-chunks");
const vendorChunksDest = path.join(cliAppDir, buildDistDirName, "server", "vendor-chunks");
if (fs.existsSync(vendorChunksSrcResolved) || fs.existsSync(vendorChunksSrc)) {
copyRecursive(fs.existsSync(vendorChunksSrcResolved) ? vendorChunksSrcResolved : vendorChunksSrc, vendorChunksDest);
console.log("✅ Copied vendor-chunks\n");
} else {
console.log("⏭️ No vendor-chunks found\n");
}
// Step 6b: Merge the complete generated server tree. Next.js standalone output
// is trace-pruned and can omit route modules or chunks loaded dynamically.
console.log("6⃣ b Copying complete server artifacts...");
mergeServerArtifacts(buildDistDir, cliAppDir);
assertRequiredApiArtifacts(cliAppDir);
console.log("✅ Copied complete server artifacts\n");
// Step 7: Copy MITM server files (not bundled by Next.js standalone)
console.log("7⃣ Copying MITM server files...");
const mitmSrc = path.join(appDir, "src", "mitm");
const mitmDest = path.join(cliAppDir, "src", "mitm");
if (fs.existsSync(mitmSrc)) {
copyRecursive(mitmSrc, mitmDest);
console.log("✅ Copied MITM files\n");
} else {
console.log("⏭️ No MITM files found\n");
}
// Step 7b: Copy standalone updater (headless Node process for install progress)
console.log("7⃣ b Copying updater files...");
const updaterSrc = path.join(appDir, "src", "lib", "updater");
const updaterDest = path.join(cliAppDir, "src", "lib", "updater");
if (fs.existsSync(updaterSrc)) {
copyRecursive(updaterSrc, updaterDest);
console.log("✅ Copied updater files\n");
} else {
console.log("⏭️ No updater files found\n");
}
// Step 8: Build MITM server (config driven - see app/cli/scripts/buildMitm.js)
console.log("8⃣ Building MITM server...");
try {
execSync("node scripts/buildMitm.js", { stdio: "inherit", cwd: cliDir });
console.log("✅ MITM server build completed\n");
} catch (error) {
console.error("❌ MITM build failed");
process.exit(1);
}
console.log("✨ CLI package build completed!");
console.log(`📁 Output: ${cliAppDir}`);
try {
const { execSync: exec } = require("child_process");
const size = exec(`du -sh "${cliAppDir}"`, { encoding: "utf8" }).trim();
console.log(`📊 Package size: ${size.split("\t")[0]}`);
} catch (e) {
// Silent fail on size check
}
// Step 5: Copy public folder if exists
console.log("5⃣ Copying public folder...");
const publicSrc = path.join(appDir, "public");
const publicDest = path.join(cliAppDir, "public");
if (fs.existsSync(publicSrc)) {
copyRecursive(publicSrc, publicDest);
console.log("✅ Copied public folder\n");
} else {
console.log("⏭️ No public folder found\n");
}
module.exports = {
assertRequiredApiArtifacts,
copyStandaloneBuild,
mergeServerArtifacts,
};
if (require.main === module) {
buildCliPackage();
// Step 6: Copy vendor-chunks (required for production)
console.log("6⃣ Copying vendor-chunks...");
const vendorChunksSrc = path.join(appDir, ".next", "server", "vendor-chunks");
const vendorChunksSrcResolved = path.join(buildDistDir, "server", "vendor-chunks");
const vendorChunksDest = path.join(cliAppDir, buildDistDirName, "server", "vendor-chunks");
if (fs.existsSync(vendorChunksSrcResolved) || fs.existsSync(vendorChunksSrc)) {
copyRecursive(fs.existsSync(vendorChunksSrcResolved) ? vendorChunksSrcResolved : vendorChunksSrc, vendorChunksDest);
console.log("✅ Copied vendor-chunks\n");
} else {
console.log("⏭️ No vendor-chunks found\n");
}
// Step 7: Copy MITM server files (not bundled by Next.js standalone)
console.log("7⃣ Copying MITM server files...");
const mitmSrc = path.join(appDir, "src", "mitm");
const mitmDest = path.join(cliAppDir, "src", "mitm");
if (fs.existsSync(mitmSrc)) {
copyRecursive(mitmSrc, mitmDest);
console.log("✅ Copied MITM files\n");
} else {
console.log("⏭️ No MITM files found\n");
}
// Step 7b: Copy standalone updater (headless Node process for install progress)
console.log("7⃣ b Copying updater files...");
const updaterSrc = path.join(appDir, "src", "lib", "updater");
const updaterDest = path.join(cliAppDir, "src", "lib", "updater");
if (fs.existsSync(updaterSrc)) {
copyRecursive(updaterSrc, updaterDest);
console.log("✅ Copied updater files\n");
} else {
console.log("⏭️ No updater files found\n");
}
// Step 8: Build MITM server (config driven - see app/cli/scripts/buildMitm.js)
console.log("8⃣ Building MITM server...");
try {
execSync("node scripts/buildMitm.js", { stdio: "inherit", cwd: cliDir });
console.log("✅ MITM server build completed\n");
} catch (error) {
console.error("❌ MITM build failed");
process.exit(1);
}
console.log("✨ CLI package build completed!");
console.log(`📁 Output: ${cliAppDir}`);
try {
const { execSync: exec } = require("child_process");
const size = exec(`du -sh "${cliAppDir}"`, { encoding: "utf8" }).trim();
console.log(`📊 Package size: ${size.split("\t")[0]}`);
} catch (e) {
// Silent fail on size check
}

View File

@@ -12,8 +12,7 @@ const BUILD_CONFIG = {
const cliDir = path.resolve(__dirname, "..");
const appDir = path.resolve(cliDir, "..");
const cliAppDir = process.env.NINEROUTER_CLI_APP_DIR || path.join(cliDir, "app");
const cliMitmDir = path.join(cliAppDir, "src", "mitm");
const cliMitmDir = path.join(cliDir, "app", "src", "mitm");
// Bundle everything — no externals. This keeps MITM runtime self-contained so
// it can be copied to DATA_DIR/runtime/ and spawned from there (escapes
// node_modules file locks that block `npm i -g 9router@latest` on Windows).

View File

@@ -1,300 +0,0 @@
/**
* `9router xai video` — generate a Grok Imagine video through the local
* 9router gateway and save the result as an MP4 file.
*
* Flow: POST /v1/videos/generations → poll GET /v1/videos/{request_id}
* until done/failed/timeout → download video.url → atomic rename.
*
* No OAuth tokens or Authorization headers are ever printed.
*/
const http = require("http");
const https = require("https");
const fs = require("fs");
const path = require("path");
const DEFAULT_PORT = 20128;
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_MODEL = "xai/grok-imagine-video";
const DEFAULT_TIMEOUT_SEC = 600;
const DEFAULT_POLL_INTERVAL_MS = 5000;
const TERMINAL_STATUSES = new Set(["done", "failed", "completed", "error", "expired", "cancelled"]);
const FAILED_STATUSES = new Set(["failed", "error", "expired", "cancelled"]);
const HELP = `
Usage: 9router xai video --prompt "..." [options]
Generate a Grok Imagine video via your local 9router gateway
(requires a connected xAI account — Grok Build OAuth or API key).
Options:
--prompt <text> Video description (required)
--output <file> Output MP4 path (default: video.mp4)
--model <id> Model (default: ${DEFAULT_MODEL})
--duration <seconds> Video duration
--aspect-ratio <ratio> e.g. 16:9, 9:16, 1:1
--resolution <res> 480p | 720p | 1080p
--image <path-or-url> Image input for image-to-video
--timeout <seconds> Max wait for the job (default: ${DEFAULT_TIMEOUT_SEC})
--port <port> Gateway port (default: ${DEFAULT_PORT})
--host <host> Gateway host (default: ${DEFAULT_HOST})
--api-key <key> 9router API key (or env NINE_ROUTER_API_KEY)
-h, --help Show this help
`;
function sanitizeText(text) {
return String(text ?? "").replace(/Bearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]");
}
function parseArgs(argv) {
const opts = {
model: DEFAULT_MODEL,
output: "video.mp4",
timeoutSec: DEFAULT_TIMEOUT_SEC,
port: DEFAULT_PORT,
host: DEFAULT_HOST,
apiKey: process.env.NINE_ROUTER_API_KEY || null,
pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const next = () => argv[++i];
if (a === "--prompt") opts.prompt = next();
else if (a === "--output" || a === "-o") opts.output = next();
else if (a === "--model") opts.model = next();
else if (a === "--duration") opts.duration = parseInt(next(), 10);
else if (a === "--aspect-ratio") opts.aspectRatio = next();
else if (a === "--resolution") opts.resolution = next();
else if (a === "--image") opts.image = next();
else if (a === "--timeout") opts.timeoutSec = parseInt(next(), 10) || DEFAULT_TIMEOUT_SEC;
else if (a === "--port" || a === "-p") opts.port = parseInt(next(), 10) || DEFAULT_PORT;
else if (a === "--host" || a === "-H") opts.host = next() || DEFAULT_HOST;
else if (a === "--api-key") opts.apiKey = next();
else if (a === "--poll-interval-ms") opts.pollIntervalMs = parseInt(next(), 10) || DEFAULT_POLL_INTERVAL_MS;
else if (a === "-h" || a === "--help") opts.help = true;
else {
throw new Error(`Unknown option: ${a}`);
}
}
return opts;
}
/** Local file path → base64 data URL; URLs pass through untouched. */
function imageInputToUrl(input) {
if (/^(https?:|data:)/i.test(input)) return input;
const buf = fs.readFileSync(input);
const ext = path.extname(input).toLowerCase();
const mime = ext === ".png" ? "image/png" : ext === ".webp" ? "image/webp" : "image/jpeg";
return `data:${mime};base64,${buf.toString("base64")}`;
}
/** Minimal JSON request against the local gateway. Returns { status, headers, body }. */
function gatewayRequest({ host, port, apiKey, method, reqPath, body, signal }) {
return new Promise((resolve, reject) => {
const payload = body ? JSON.stringify(body) : null;
const headers = { Accept: "application/json" };
if (payload) {
headers["Content-Type"] = "application/json";
headers["Content-Length"] = Buffer.byteLength(payload);
}
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
const req = http.request({ hostname: host, port, path: reqPath, method, headers, signal }, (res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => {
let parsed = null;
try { parsed = data ? JSON.parse(data) : null; } catch { /* keep raw */ }
resolve({ status: res.statusCode, headers: res.headers, body: parsed, raw: data });
});
});
req.on("error", reject);
if (payload) req.write(payload);
req.end();
});
}
const sleep = (ms, signal) =>
new Promise((resolve, reject) => {
const t = setTimeout(resolve, ms);
signal?.addEventListener?.("abort", () => { clearTimeout(t); reject(new Error("aborted")); }, { once: true });
});
/**
* Poll GET /v1/videos/{id} until a terminal status or deadline.
* @returns {Promise<object>} final poll body (status done) — throws on failed/timeout.
*/
async function pollUntilDone({ host, port, apiKey, requestId, connectionId, timeoutSec, pollIntervalMs, signal, onProgress }) {
const deadline = Date.now() + timeoutSec * 1000;
while (true) {
if (signal?.aborted) throw new Error("aborted");
if (Date.now() > deadline) {
throw new Error(`Timed out after ${timeoutSec}s waiting for video job ${requestId}`);
}
const res = await gatewayRequestWithConnection({ host, port, apiKey, requestId, connectionId, signal });
if (res.status === 200 && res.body) {
const status = String(res.body.status || "").toLowerCase();
onProgress?.(status || "pending", res.body.progress);
if (FAILED_STATUSES.has(status)) {
const msg = res.body.error?.message || res.body.error || "video generation failed";
throw new Error(`Job ${requestId} failed: ${sanitizeText(typeof msg === "string" ? msg : JSON.stringify(msg))}`);
}
if (TERMINAL_STATUSES.has(status)) return res.body;
} else if (res.status >= 400 && res.status !== 429 && res.status !== 503) {
throw new Error(`Polling failed (HTTP ${res.status}): ${sanitizeText(res.raw?.slice(0, 300))}`);
}
await sleep(pollIntervalMs, signal);
}
}
function gatewayRequestWithConnection({ host, port, apiKey, requestId, connectionId, signal }) {
return new Promise((resolve, reject) => {
const headers = { Accept: "application/json" };
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
if (connectionId) headers["x-connection-id"] = connectionId;
const req = http.request(
{ hostname: host, port, path: `/v1/videos/${encodeURIComponent(requestId)}`, method: "GET", headers, signal },
(res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => {
let parsed = null;
try { parsed = data ? JSON.parse(data) : null; } catch { /* keep raw */ }
resolve({ status: res.statusCode, body: parsed, raw: data });
});
}
);
req.on("error", reject);
req.end();
});
}
/**
* Download a URL to `outputPath` via a `.part` temp file with atomic rename.
* The temp file is removed on any failure.
*/
async function downloadToFile(url, outputPath, { signal } = {}) {
const partPath = `${outputPath}.part`;
await new Promise((resolve, reject) => {
const cleanupAnd = (fn) => (err) => {
try { fs.unlinkSync(partPath); } catch { /* not created yet */ }
fn(err);
};
const get = (target, redirectsLeft) => {
const mod = target.startsWith("https:") ? https : http;
const req = mod.get(target, { signal }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
res.resume();
return get(new URL(res.headers.location, target).toString(), redirectsLeft - 1);
}
if (res.statusCode !== 200) {
res.resume();
return cleanupAnd(reject)(new Error(`Download failed: HTTP ${res.statusCode}`));
}
const out = fs.createWriteStream(partPath);
res.pipe(out);
out.on("finish", () => out.close(resolve));
out.on("error", cleanupAnd(reject));
res.on("error", cleanupAnd(reject));
});
req.on("error", cleanupAnd(reject));
};
get(url, 5);
});
fs.renameSync(partPath, outputPath);
}
async function run(argv) {
let opts;
try {
opts = parseArgs(argv);
} catch (err) {
console.error(`${err.message}`);
console.log(HELP);
return 1;
}
if (opts.help) {
console.log(HELP);
return 0;
}
if (!opts.prompt) {
console.error("❌ --prompt is required");
console.log(HELP);
return 1;
}
const controller = new AbortController();
const partPath = `${opts.output}.part`;
const onSigint = () => {
controller.abort();
try { fs.unlinkSync(partPath); } catch { /* absent */ }
console.error("\n✋ Cancelled");
process.exit(130);
};
process.on("SIGINT", onSigint);
try {
const body = { model: opts.model, prompt: opts.prompt };
if (opts.duration) body.duration = opts.duration;
if (opts.aspectRatio) body.aspect_ratio = opts.aspectRatio;
if (opts.resolution) body.resolution = opts.resolution;
if (opts.image) body.image = { url: imageInputToUrl(opts.image) };
console.log(`🎬 Requesting video (${opts.model})…`);
const create = await gatewayRequest({
host: opts.host, port: opts.port, apiKey: opts.apiKey,
method: "POST", reqPath: "/v1/videos/generations", body, signal: controller.signal,
});
if (create.status !== 200 || !create.body?.request_id) {
const detail = create.body?.error?.message || create.body?.error || create.raw || `HTTP ${create.status}`;
console.error(`❌ Create failed: ${sanitizeText(typeof detail === "string" ? detail : JSON.stringify(detail)).slice(0, 500)}`);
if (create.status === 400 && /No credentials/i.test(String(detail))) {
console.error(" Connect an xAI account first: dashboard → Providers → xAI (Grok).");
}
return 1;
}
const requestId = create.body.request_id;
const connectionId = create.headers["x-9router-connection-id"] || null;
console.log(`📋 Job accepted: ${requestId}`);
let lastLine = "";
const result = await pollUntilDone({
host: opts.host, port: opts.port, apiKey: opts.apiKey,
requestId, connectionId,
timeoutSec: opts.timeoutSec, pollIntervalMs: opts.pollIntervalMs,
signal: controller.signal,
onProgress: (status, progress) => {
const line = `${status}${Number.isFinite(progress) ? ` ${progress}%` : ""}`;
if (line !== lastLine) {
lastLine = line;
if (process.stdout.isTTY) process.stdout.write(`\r\x1b[K${line}`);
else console.log(line);
}
},
});
if (process.stdout.isTTY) process.stdout.write("\n");
const videoUrl = result.video?.url || result.video?.file_output?.public_url;
if (!videoUrl) {
console.error("❌ Job finished but no video URL was returned");
return 1;
}
console.log("⬇️ Downloading…");
await downloadToFile(videoUrl, opts.output, { signal: controller.signal });
console.log(`✅ Saved ${opts.output}`);
return 0;
} catch (err) {
if (process.stdout.isTTY) process.stdout.write("\n");
console.error(`${sanitizeText(err?.message || String(err))}`);
return 1;
} finally {
process.removeListener("SIGINT", onSigint);
}
}
module.exports = { run, parseArgs, pollUntilDone, downloadToFile, imageInputToUrl, sanitizeText };

View File

@@ -53,12 +53,6 @@ const PROVIDER_MODELS = {
{ id: "glm-4.7" },
],
ag: [
{ id: "gemini-3.7-flash-high" },
{ id: "gemini-3.7-flash-medium" },
{ id: "gemini-3.7-flash-low" },
{ id: "gemini-3.6-flash-high" },
{ id: "gemini-3.6-flash-medium" },
{ id: "gemini-3.6-flash-low" },
{ id: "gemini-3-flash-agent" },
{ id: "gemini-3.5-flash-low" },
{ id: "gemini-3.5-flash-extra-low" },
@@ -84,7 +78,6 @@ const PROVIDER_MODELS = {
{ id: "grok-code-fast-1" },
],
kr: [
{ id: "claude-sonnet-5" },
{ id: "claude-sonnet-4.5" },
{ id: "claude-haiku-4.5" },
],
@@ -101,8 +94,6 @@ const PROVIDER_MODELS = {
{ id: "claude-3-5-sonnet-20241022" },
],
gemini: [
{ id: "gemini-3.6-flash" },
{ id: "gemini-3.5-flash-lite" },
{ id: "gemini-3-pro-preview" },
{ id: "gemini-2.5-pro" },
{ id: "gemini-2.5-flash" },
@@ -139,7 +130,7 @@ const APIKEY_PROVIDERS = {
openrouter: { id: "openrouter", name: "OpenRouter" },
glm: { id: "glm", name: "GLM Coding" },
minimax: { id: "minimax", name: "Minimax Coding" },
kimi: { id: "kimi", name: "Kimi" },
kimi: { id: "kimi", name: "Kimi Coding" },
openai: { id: "openai", name: "OpenAI" },
anthropic: { id: "anthropic", name: "Anthropic" },
gemini: { id: "gemini", name: "Gemini" },

View File

@@ -2,65 +2,14 @@
# IPC: stdin JSON commands, stdout JSON events
param([string]$IconPath, [string]$Tooltip)
$ErrorActionPreference = "Stop"
Add-Type @"
using System;
using System.Runtime.InteropServices;
public static class WinDpiAwareness {
public static IntPtr PerMonitorAwareV2 { get { return new IntPtr(-4); } }
public static IntPtr PerMonitorAware { get { return new IntPtr(-3); } }
[DllImport("user32.dll")]
public static extern bool SetProcessDpiAwarenessContext(IntPtr value);
[DllImport("user32.dll")]
public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr value);
[DllImport("shcore.dll")]
public static extern int SetProcessDpiAwareness(int value);
[DllImport("user32.dll")]
public static extern bool SetProcessDPIAware();
}
"@
function Enable-HighDpiAwareness {
$contexts = @(
[WinDpiAwareness]::PerMonitorAwareV2,
[WinDpiAwareness]::PerMonitorAware
)
foreach ($context in $contexts) {
try {
if ([WinDpiAwareness]::SetProcessDpiAwarenessContext($context)) { break }
} catch {}
}
try { [WinDpiAwareness]::SetProcessDpiAwareness(2) | Out-Null } catch {}
try { [WinDpiAwareness]::SetProcessDPIAware() | Out-Null } catch {}
foreach ($context in $contexts) {
try {
$previous = [WinDpiAwareness]::SetThreadDpiAwarenessContext($context)
if ($previous -ne [IntPtr]::Zero) { break }
} catch {}
}
}
Enable-HighDpiAwareness
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
[System.Windows.Forms.Application]::EnableVisualStyles()
[System.Windows.Forms.Application]::SetCompatibleTextRenderingDefault($false)
$script:notifyIcon = New-Object System.Windows.Forms.NotifyIcon
$script:notifyIcon.Icon = New-Object System.Drawing.Icon($IconPath)
$script:notifyIcon.Text = $Tooltip

View File

@@ -1,51 +1,7 @@
const http = require("http");
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
const { pathToFileURL } = require("url");
const origCreate = http.createServer.bind(http);
// Per-process secret proving x-9r-real-ip was stamped below rather than sent by the client.
// A bare `next start` / `next dev` never loads this file, so it cannot produce a matching
// header even though the env var is inherited by child processes. Named like x-9r-cli-token
// so the request-detail header sanitizer redacts it too.
const PEER_TOKEN = crypto.randomBytes(24).toString("hex");
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
let backgroundRefreshStarted = false;
function startBackgroundTokenRefreshFromCustomServer() {
if (backgroundRefreshStarted) return;
backgroundRefreshStarted = true;
// Prefer source path (repo / standalone that still has src). Fail-open if missing
// — initializeApp also starts the same scheduler when the Next app boots.
const modPath = path.join(__dirname, "src", "sse", "services", "backgroundTokenRefresh.js");
import(pathToFileURL(modPath).href)
.then((m) => {
try {
m.startBackgroundTokenRefresh();
} catch (e) {
console.error("[BackgroundTokenRefresh] start failed:", e && e.message ? e.message : e);
}
const stop = () => {
try {
m.stopBackgroundTokenRefresh();
} catch {
/* ignore */
}
};
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
})
.catch((e) => {
// Expected in published CLI standalone (src/ not on disk). App bootstrap covers it.
if (process.env.DEBUG_BACKGROUND_TOKEN_REFRESH) {
console.error("[BackgroundTokenRefresh] import failed:", e && e.message ? e.message : e);
}
});
}
// Wrap Next standalone HTTP server: derive client IP from the TCP socket
// (unspoofable) and strip client-supplied forwarding headers so downstream
// rate-limiting keys on the real peer address instead of attacker-controlled XFF.
@@ -66,74 +22,11 @@ http.createServer = (...args) => {
delete req.headers["x-9r-real-ip"];
delete req.headers["x-forwarded-for"];
delete req.headers["x-9r-via-proxy"];
delete req.headers["x-9r-peer-token"];
req.headers["x-9r-real-ip"] = ip;
req.headers["x-9r-peer-token"] = PEER_TOKEN;
if (viaProxy) req.headers["x-9r-via-proxy"] = "1";
return handler(req, res);
};
const server = origCreate(...rest, wrapped);
server.once("listening", () => {
startBackgroundTokenRefreshFromCustomServer();
});
const origEmit = server.emit;
// JBR 25 sends h2c upgrades that the HTTP/1.1 server would otherwise close.
server.emit = function (event, ...eventArgs) {
const [req, socket, head] = eventArgs;
if (event !== "upgrade" || String(req.headers.upgrade || "").toLowerCase() !== "h2c") {
return origEmit.call(this, event, ...eventArgs);
}
const contentLength = Number(req.headers["content-length"] || 0);
if (!Number.isSafeInteger(contentLength) || contentLength < 0) {
socket.destroy();
return true;
}
const chunks = [head];
let received = head.length;
const serve = () => {
// Replay the upgraded request through the existing HTTP/1.1 handler.
const replay = new http.IncomingMessage(socket);
Object.assign(replay, { method: req.method, url: req.url, headers: req.headers, complete: true });
if (received) replay.push(Buffer.concat(chunks, received).subarray(0, contentLength));
replay.push(null);
const res = new http.ServerResponse(replay);
res.shouldKeepAlive = false;
res.assignSocket(socket);
res.once("finish", () => socket.end());
Promise.resolve().then(() => wrapped(replay, res)).catch((error) => {
console.error("Failed to downgrade h2c request", error);
socket.destroy();
});
};
if (received >= contentLength) serve();
else {
socket.on("data", function readBody(chunk) {
chunks.push(chunk);
received += chunk.length;
if (received < contentLength) return;
socket.off("data", readBody);
serve();
});
socket.resume();
}
delete req.headers.upgrade;
delete req.headers["http2-settings"];
req.headers.connection = "close";
return true;
};
return server;
return origCreate(...rest, wrapped);
};
if (require.main === module) {
const standalone = path.join(__dirname, "server.js");
if (fs.existsSync(standalone)) {
require(standalone);
} else {
// Repo checkout has no standalone build next to us. `next start` builds its HTTP
// server in-process, so the wrapper above still sanitizes every request.
const nextBin = require.resolve("next/dist/bin/next");
process.argv = [process.argv[0], nextBin, "start", ...process.argv.slice(2)];
require(nextBin);
}
}
require("./server.js");

View File

@@ -1,30 +1,23 @@
services:
9router:
image: decolua/9router:latest
build:
context: .
image: 9router:local
container_name: 9router
restart: always
restart: unless-stopped
env_file:
- .env
environment:
NODE_ENV: production
PORT: 20128
HOSTNAME: 0.0.0.0
DATA_DIR: /app/data
BASE_URL: http://localhost:20128
NEXT_PUBLIC_BASE_URL: http://localhost:20128
ports:
- "20128:20128"
volumes:
- 9router-data:/app/data
env_file:
- .env
environment:
DATA_DIR: /app/data
PORT: "20128"
HOSTNAME: "0.0.0.0"
NODE_ENV: production
HEADROOM_URL: http://headroom:8787
depends_on:
- headroom
headroom:
image: ghcr.io/chopratejas/headroom:latest
container_name: headroom
restart: always
ports:
- "8787:8787"
volumes:
9router-data:
name: 9router-data

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,328 +0,0 @@
# GPT-5.6 Codex Reasoning Overrides Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Preserve Codex-advertised Max and Ultra overrides for GPT-5.6 Sol and Terra, preserve Max for Luna, and convert Luna Ultra to Max without changing Kiro or generic OpenAI-format behavior.
**Architecture:** Keep the supported reasoning matrix in the existing `getThinkingLevels(provider, model)` resolver and reuse that result in both translation and Codex executor normalization. The dashboard already consumes this resolver, so no UI component change is required. Unsupported top-end levels remain safely normalized, with Luna Ultra selecting Luna's supported Max level.
**Tech Stack:** JavaScript ES modules, Next.js, Vitest, Codex Responses transport.
## Global Constraints
- Apply the new overrides only to the OpenAI Codex provider (`codex`, exposed as `cx/`).
- Sol and Terra support `max` and `ultra`; Luna supports `max` but not `ultra`.
- Convert Luna `ultra` requests to `max` in both translated and native passthrough request paths.
- Preserve existing Kiro and generic OpenAI-compatible normalization.
- Do not add runtime model-catalog fetching, dependencies, pricing changes, or unrelated refactors.
- Write each behavior test first and observe the expected failure before changing production code.
---
### Task 1: Provider-scoped GPT-5.6 level matrix
**Files:**
- Modify: `tests/unit/thinking-levels-gpt56-sol.test.js`
- Modify: `open-sse/providers/thinkingLevels.js`
**Interfaces:**
- Consumes: `getThinkingLevels(provider, model)` and existing capability metadata.
- Produces: `getThinkingLevels(provider, model): string[] | null` with Codex-only GPT-5.6 level overrides.
- [ ] **Step 1: Replace the Sol-only assertions with the complete behavior matrix**
Use literal expected arrays so each model/provider contract is independently checked:
```js
it.each([
["gpt-5.6-sol", ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]],
["gpt-5.6-terra", ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]],
["gpt-5.6-luna", ["none", "minimal", "low", "medium", "high", "xhigh", "max"]],
["gpt-5.6-sol-review", ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]],
["gpt-5.6-terra-review", ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]],
["gpt-5.6-luna-review", ["none", "minimal", "low", "medium", "high", "xhigh", "max"]],
])("returns Codex levels for %s", (model, expected) => {
expect(getThinkingLevels("codex", model)).toEqual(expected);
});
it("does not expose Codex-only GPT-5.6 overrides on Kiro", () => {
expect(getThinkingLevels("kiro", "gpt-5.6-sol")).toEqual([
"none", "minimal", "low", "medium", "high", "xhigh",
]);
});
```
Keep the older Codex-model assertion to protect the existing `gpt-5.3-codex` behavior.
- [ ] **Step 2: Run the level test and verify it fails for the missing matrix/provider scoping**
Run:
```bash
npx vitest run tests/unit/thinking-levels-gpt56-sol.test.js
```
Expected: FAIL because Sol lacks Ultra, Terra/Luna lack Max, and Kiro currently inherits Sol Max.
- [ ] **Step 3: Add provider-aware pattern matching and the three Codex model rules**
Update `PATTERN_THINKING` entries to accept an optional `provider` field and match it in `getThinkingLevels`:
```js
const CODEX_GPT_5_6_LEVELS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
const PATTERN_THINKING = [
{ provider: "codex", pattern: "*gpt-5.6-sol*", levels: [...CODEX_GPT_5_6_LEVELS, "ultra"] },
{ provider: "codex", pattern: "*gpt-5.6-terra*", levels: [...CODEX_GPT_5_6_LEVELS, "ultra"] },
{ provider: "codex", pattern: "*gpt-5.6-luna*", levels: CODEX_GPT_5_6_LEVELS },
{ pattern: "*codex*", levels: ["low", "medium", "high", "xhigh"] },
];
const hit = PATTERN_THINKING.find((entry) =>
(!entry.provider || entry.provider === provider) && matchPattern(entry.pattern, model)
);
```
- [ ] **Step 4: Re-run the level test and verify it passes**
Run:
```bash
npx vitest run tests/unit/thinking-levels-gpt56-sol.test.js
```
Expected: 1 test file passed with no failures.
- [ ] **Step 5: Commit the capability matrix**
```bash
git add open-sse/providers/thinkingLevels.js tests/unit/thinking-levels-gpt56-sol.test.js
git commit -m "feat(codex): expose GPT-5.6 reasoning overrides"
```
### Task 2: Model-aware shared thinking translation
**Files:**
- Modify: `tests/translator/thinking-unified.test.js`
- Modify: `open-sse/translator/concerns/thinkingUnified.js`
**Interfaces:**
- Consumes: `getThinkingLevels(provider, cleanModel): string[] | null` from Task 1.
- Produces: `parseSuffix(model)` support for `ultra` and `applyThinking(...)` output that preserves supported Codex levels.
- [ ] **Step 1: Add failing suffix and translation tests**
Add a literal parser assertion:
```js
expect(parseSuffix("gpt-5.6-sol(ultra)")).toEqual({
cleanModel: "gpt-5.6-sol",
override: { mode: "level", level: "ultra" },
});
```
Add table-driven Codex assertions using direct request fields:
```js
it.each([
["gpt-5.6-sol", "max", "max"],
["gpt-5.6-sol", "ultra", "ultra"],
["gpt-5.6-terra", "max", "max"],
["gpt-5.6-terra", "ultra", "ultra"],
["gpt-5.6-luna", "max", "max"],
["gpt-5.6-luna", "ultra", "max"],
])("normalizes Codex %s effort %s to %s", (model, effort, expected) => {
const out = apply("openai-responses", model, { reasoning: { effort } }, "codex");
expect(out.reasoning_effort).toBe(expected);
});
```
Add a parenthesized override assertion and Kiro isolation assertion:
```js
expect(apply("openai-responses", "gpt-5.6-sol(ultra)", {}, "codex").reasoning_effort).toBe("ultra");
expect(apply("openai", "gpt-5.6-sol", { reasoning_effort: "max" }, "kiro").reasoning_effort).toBe("xhigh");
```
- [ ] **Step 2: Run the translator test and verify it fails for Ultra parsing and preserved Max/Ultra**
Run:
```bash
npx vitest run tests/translator/thinking-unified.test.js
```
Expected: FAIL because Ultra suffixes are ignored and OpenAI translation clamps Max to XHigh.
- [ ] **Step 3: Implement supported-level normalization in the shared translator**
Import `getThinkingLevels`. Recognize `ultra` explicitly in `parseSuffix` without adding it to the budget map. Resolve supported levels once in `applyThinking` and pass them to `applyFormat`.
Use this normalization rule for the OpenAI format:
```js
function normalizeOpenAILevel(level, supportedLevels) {
if (level !== "max" && level !== "ultra") return level;
if (supportedLevels?.includes(level)) return level;
if (level === "ultra" && supportedLevels?.includes("max")) return "max";
return "xhigh";
}
```
Keep `none`, automatic effort, budget conversion, and every non-OpenAI format unchanged.
- [ ] **Step 4: Re-run the translator and generic OpenAI clamp tests**
Run:
```bash
npx vitest run tests/translator/thinking-unified.test.js tests/unit/thinking-effort-openai-max-clamp.test.js
```
Expected: 2 test files passed; generic OpenAI Max still becomes XHigh.
- [ ] **Step 5: Commit shared translation support**
```bash
git add open-sse/translator/concerns/thinkingUnified.js tests/translator/thinking-unified.test.js
git commit -m "feat(codex): preserve supported reasoning efforts"
```
### Task 3: Codex native passthrough normalization
**Files:**
- Modify: `tests/unit/codex-fast-capacity.test.js`
- Modify: `open-sse/executors/codex.js`
**Interfaces:**
- Consumes: `getThinkingLevels("codex", upstreamModel): string[] | null` from Task 1.
- Produces: `CodexExecutor.transformRequest(...)` payloads with model-supported upstream `reasoning.effort` values.
- [ ] **Step 1: Add failing Codex executor behavior tests**
Add a separate `describe("Codex reasoning normalization", ...)` block with real `transformRequest` calls:
```js
it.each([
["gpt-5.6-sol", "max", "max"],
["gpt-5.6-sol", "ultra", "ultra"],
["gpt-5.6-terra", "max", "max"],
["gpt-5.6-terra", "ultra", "ultra"],
["gpt-5.6-luna", "max", "max"],
["gpt-5.6-luna", "ultra", "max"],
])("normalizes %s effort %s to %s", (model, effort, expected) => {
const body = new CodexExecutor().transformRequest(model, {
model,
input: "hi",
reasoning: { effort },
}, true, {});
expect(body.reasoning.effort).toBe(expected);
});
it("resolves review models before applying the reasoning matrix", () => {
const body = new CodexExecutor().transformRequest("gpt-5.6-terra-review", {
model: "gpt-5.6-terra-review",
input: "hi",
reasoning_effort: "ultra",
}, true, {});
expect(body.model).toBe("gpt-5.6-terra");
expect(body.reasoning.effort).toBe("ultra");
});
```
Keep the existing GPT-5.5 Max-to-XHigh fast-tier test.
- [ ] **Step 2: Run the executor test and verify supported values fail by being clamped**
Run:
```bash
npx vitest run tests/unit/codex-fast-capacity.test.js
```
Expected: FAIL because current normalization maps supported Max to XHigh and does not map Luna Ultra to Max.
- [ ] **Step 3: Make Codex normalization model-aware**
Import `getThinkingLevels` and replace the global Max clamp with:
```js
function normalizeReasoningEffort(model, value) {
const supportedLevels = getThinkingLevels("codex", model);
if (supportedLevels?.includes(value)) return value;
if (value === "ultra" && supportedLevels?.includes("max")) return "max";
if (value === "max" || value === "ultra") return "xhigh";
return value;
}
```
Call it only after `body.model` has resolved review aliases to their upstream base model. Pass `body.model` for both `reasoning_effort` and existing `reasoning.effort` request shapes.
- [ ] **Step 4: Re-run the executor and focused feature suites**
Run:
```bash
npx vitest run tests/unit/codex-fast-capacity.test.js tests/unit/thinking-levels-gpt56-sol.test.js tests/translator/thinking-unified.test.js tests/unit/thinking-effort-openai-max-clamp.test.js
```
Expected: 4 test files passed with no failures.
- [ ] **Step 5: Commit native Codex normalization**
```bash
git add open-sse/executors/codex.js tests/unit/codex-fast-capacity.test.js
git commit -m "feat(codex): forward GPT-5.6 max and ultra efforts"
```
### Task 4: Full verification and pull request
**Files:**
- Verify all changed production, test, design, and plan files.
**Interfaces:**
- Consumes: completed Tasks 1-3.
- Produces: verified branch pushed to `origin` and a pull request targeting `decolua/9router:master`.
- [ ] **Step 1: Run all focused regression tests**
```bash
npx vitest run tests/unit/thinking-levels-gpt56-sol.test.js tests/translator/thinking-unified.test.js tests/unit/thinking-effort-openai-max-clamp.test.js tests/unit/codex-fast-capacity.test.js
```
Expected: all selected test files and tests pass.
- [ ] **Step 2: Run the complete unit test suite**
```bash
npx vitest run tests/unit tests/translator
```
Expected: all test files pass with zero failed tests.
- [ ] **Step 3: Run the production build**
```bash
npm run build
```
Expected: Next.js production build exits with status 0.
- [ ] **Step 4: Verify repository hygiene and requirement coverage**
```bash
git diff --check upstream/master...HEAD
git status --short --branch
git log --oneline upstream/master..HEAD
```
Expected: no whitespace errors, no uncommitted source changes, and only scoped feature commits.
- [ ] **Step 5: Push the feature branch and open the pull request**
```bash
git push -u origin codex/gpt-5-6-reasoning-overrides
gh pr create --repo decolua/9router --base master --head seakleangnhak:codex/gpt-5-6-reasoning-overrides --title "feat(codex): support GPT-5.6 Max and Ultra overrides" --body $'## Summary\n- expose Max and Ultra for Codex GPT-5.6 Sol and Terra\n- expose Max for Codex GPT-5.6 Luna and normalize Luna Ultra to Max\n- keep Kiro and generic OpenAI-compatible reasoning behavior unchanged\n\n## Verification\n- `npx vitest run tests/unit tests/translator`\n- `npm run build`'
```
The pull request body must summarize the Codex-only support matrix, Luna Ultra-to-Max fallback, Kiro isolation, and fresh test/build evidence.

View File

@@ -1,122 +0,0 @@
# GPT-5.6 Codex Reasoning Overrides Design
## Goal
Expose and preserve the reasoning levels currently advertised by the OpenAI
Codex model catalog for GPT-5.6 Sol, Terra, and Luna when they are routed
through the `codex` provider (`cx/`).
The supported override matrix is:
| Model family | Max | Ultra |
| --- | --- | --- |
| GPT-5.6 Sol | Yes | Yes |
| GPT-5.6 Terra | Yes | Yes |
| GPT-5.6 Luna | Yes | No |
The same matrix applies to 9router's virtual `-review` variants because they
resolve to the corresponding upstream base model.
## Scope
This change is limited to OpenAI Codex (`cx/`) routes. Kiro (`kr/`) and other
OpenAI-format providers retain their existing reasoning-level behavior even
when they expose models with the same GPT-5.6 names.
The change covers the complete local request path:
1. The provider page advertises only the levels supported by each Codex model.
2. A copied model suffix such as `gpt-5.6-sol(ultra)` is parsed as a reasoning
override.
3. The shared thinking translator preserves a supported Codex override while
retaining the existing `xhigh` fallback for unsupported OpenAI levels.
4. The Codex executor sends supported `max` and `ultra` values unchanged to the
upstream Codex Responses endpoint.
## Current Behavior
`gpt-5.6-luna` and the other GPT-5.6 models already exist in the Codex model
registry. The capability picker has a global Sol-only `max` pattern, which also
affects providers such as Kiro unintentionally. The shared OpenAI translator
and Codex executor then convert `max` to `xhigh`, so the advertised override is
not preserved end to end. `ultra` is not recognized as a model suffix.
## Design
### Provider-scoped level resolution
Extend the existing model-pattern overrides in
`open-sse/providers/thinkingLevels.js` with an optional provider constraint.
Add three Codex-only GPT-5.6 patterns in most-specific order:
- Sol: existing levels plus `max` and `ultra`.
- Terra: existing levels plus `max` and `ultra`.
- Luna: existing levels plus `max`.
Matching remains wildcard-based so virtual `-review` variants inherit the
base model's levels. Provider matching prevents these overrides from changing
Kiro or other providers.
### Shared translation
Teach the suffix parser to recognize `ultra` as a discrete level without
assigning it a synthetic token budget. When applying the OpenAI wire format,
reuse the resolved per-provider model levels:
- Preserve `max` or `ultra` when the target provider/model explicitly supports
the requested level.
- Convert `ultra` to `max` for GPT-5.6 Luna, preserving the highest level Luna
supports.
- Convert other unsupported `max` or `ultra` requests to `xhigh`, preserving
the existing safe fallback for generic OpenAI-compatible providers.
- Leave all existing lower levels and `none` handling unchanged.
This keeps one capability source for the dashboard and translation behavior
instead of duplicating the GPT-5.6 matrix.
### Codex executor
Make Codex reasoning normalization model-aware. After virtual review models
are resolved to their upstream base model, preserve a requested level when
the Codex capability resolver lists it. Continue converting unsupported
`max` or `ultra` values to `xhigh`, except that Luna converts `ultra` to its
supported `max` level.
Do not add `max` to the executor's legacy hyphen-suffix parser because
`gpt-5.1-codex-max` is an actual model identifier. Dashboard overrides use the
existing parenthesized suffix and the shared translator removes that suffix
before executor dispatch.
## Error and Compatibility Behavior
- `cx/gpt-5.6-luna(ultra)` becomes `max` rather than sending an unsupported
level upstream.
- Non-GPT-5.6 Codex models retain their current supported levels and fallback
behavior.
- Kiro GPT-5.6 routes no longer inherit the Codex Sol-only picker override and
continue using Kiro's existing effort normalization.
- Direct request fields and parenthesized model overrides follow the same
model-aware rules.
## Testing
Use test-driven development with focused unit coverage:
1. Level resolver tests for Sol, Terra, Luna, their review variants, an older
Codex model, and Kiro isolation.
2. Shared translator tests proving `max` and `ultra` survive only for supported
Codex model/provider combinations, Luna `ultra` becomes `max`, and other
unsupported combinations become `xhigh`.
3. Codex executor tests proving native and translated request shapes preserve
supported values after upstream model resolution.
4. Existing thinking translation and Codex executor suites to guard generic
OpenAI clamping and fast-tier behavior.
5. Project lint/build checks in proportion to the changed JavaScript modules.
## Non-goals
- Runtime fetching or caching of the Codex model catalog.
- Adding these levels to Kiro or another provider.
- Changing model pricing, quotas, defaults, or service tiers.
- Adding Codex Ultra's multi-agent orchestration behavior inside 9router;
9router only forwards the catalog-advertised reasoning override.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,951 +0,0 @@
<div align="center">
<img src="../images/9router.png?1" alt="9Router Dashboard" width="800"/>
# 9Router - Router AI Gratis
**Jangan berhenti ngoding. Otomatis dialihkan ke model AI gratis & murah dengan smart fallback.**
**Hubungkan semua tool AI coding (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) ke 40+ provider AI dan 100+ model.**
[![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router)
[![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router)
[![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE)
[🚀 Mulai Cepat](#-mulai-cepat) • [💡 Fitur](#-fitur-utama) • [📖 Setup](#-panduan-setup) • [🌐 Website](https://9router.com)
[🇻🇳 Tiếng Việt](./README.vi.md) • [🇨🇳 中文](./README.zh-CN.md) • [🇯🇵 日本語](./README.ja-JP.md) • [🇮🇩 Bahasa Indonesia](./README.id-ID.md)
</div>
---
## 🤔 Kenapa 9Router?
**Berhenti buang-buang uang dan terhambat limit:**
- ❌ Kuota langganan hangus tiap bulan tanpa terpakai
- ❌ Rate limit bikin ngoding berhenti di tengah jalan
- ❌ API mahal ($2050/bulan per provider)
- ❌ Harus gonta-ganti provider secara manual
**9Router menyelesaikan itu semua:**
-**Maksimalkan langganan** - lacak kuota dan habiskan sebelum reset
-**Fallback otomatis** - langganan → murah → gratis, tanpa downtime
-**Multi-akun** - round-robin antar akun untuk tiap provider
-**Universal** - mendukung Claude Code, Codex, Gemini CLI, Cursor, Cline, dan tool CLI apa pun
---
## 🔄 Cara Kerja
```
┌─────────────┐
│ Tool CLI │ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...)
│ kamu │
└──────┬──────┘
│ http://localhost:20128/v1
┌─────────────────────────────────────────┐
│ 9Router (Smart Router) │
│ • Konversi format (OpenAI ↔ Claude) │
│ • Pelacakan kuota │
│ • Refresh token otomatis │
└──────┬──────────────────────────────────┘
├─→ [Tier 1: Langganan] Claude Code, Codex, Gemini CLI
│ ↓ kuota habis
├─→ [Tier 2: Murah] GLM ($0.6/1M), MiniMax ($0.2/1M)
│ ↓ batas budget tercapai
└─→ [Tier 3: Gratis] iFlow, Qwen, Kiro (unlimited)
Hasil: ngoding tanpa berhenti, biaya minimum
```
---
## ⚡ Mulai Cepat
**1. Install secara global:**
```bash
npm install -g 9router
9router
```
🎉 Dashboard terbuka di `http://localhost:20128`
**2. Hubungkan provider gratis (tanpa perlu daftar):**
Dashboard → Providers → hubungkan **Claude Code** atau **Antigravity** → login OAuth → selesai!
**3. Pakai di tool CLI kamu:**
```
Konfigurasi Claude Code/Codex/Gemini CLI/OpenClaw/Cursor/Cline:
Endpoint: http://localhost:20128/v1
API Key: [salin dari dashboard]
Model: if/kimi-k2-thinking
```
**Cuma itu!** Mulai ngoding dengan model AI gratis.
**Alternatif: jalankan dari source (repo ini):**
Paket repo ini bersifat privat (`9router-app`), jadi menjalankan dari source/Docker adalah jalur yang diharapkan untuk pengembangan lokal.
```bash
cp .env.example .env
npm install
PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
```
Mode produksi:
```bash
npm run build
PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start
```
URL default:
- Dashboard: `http://localhost:20128/dashboard`
- API kompatibel OpenAI: `http://localhost:20128/v1`
---
## 🎥 Video Tutorial
<div align="center">
### 📺 Panduan Setup Lengkap - 9Router + Claude Code Gratis
[![9Router + Claude Code Setup](https://img.youtube.com/vi/raEyZPg5xE0/maxresdefault.jpg)](https://www.youtube.com/watch?v=raEyZPg5xE0)
**🎬 Tonton tutorial langkah demi langkah:**
- ✅ Install dan setup 9Router
- ✅ Konfigurasi Claude Sonnet 4.5 gratis
- ✅ Integrasi dengan Claude Code
- ✅ Demo live coding
**⏱️ Durasi:** 20 menit | **👥 Dibuat oleh:** Developer Community
[▶️ Tonton di YouTube](https://www.youtube.com/watch?v=o3qYCyjrFYg)
</div>
---
## 🛠️ Tool CLI yang Didukung
9Router bekerja mulus dengan semua tool AI coding utama:
<div align="center">
<table>
<tr>
<td align="center" width="120">
<img src="../public/providers/claude.png" width="60" alt="Claude Code"/><br/>
<b>Claude-Code</b>
</td>
<td align="center" width="120">
<img src="../public/providers/openclaw.png" width="60" alt="OpenClaw"/><br/>
<b>OpenClaw</b>
</td>
<td align="center" width="120">
<img src="../public/providers/codex.png" width="60" alt="Codex"/><br/>
<b>Codex</b>
</td>
<td align="center" width="120">
<img src="../public/providers/opencode.png" width="60" alt="OpenCode"/><br/>
<b>OpenCode</b>
</td>
<td align="center" width="120">
<img src="../public/providers/cursor.png" width="60" alt="Cursor"/><br/>
<b>Cursor</b>
</td>
<td align="center" width="120">
<img src="../public/providers/antigravity.png" width="60" alt="Antigravity"/><br/>
<b>Antigravity</b>
</td>
</tr>
<tr>
<td align="center" width="120">
<img src="../public/providers/cline.png" width="60" alt="Cline"/><br/>
<b>Cline</b>
</td>
<td align="center" width="120">
<img src="../public/providers/continue.png" width="60" alt="Continue"/><br/>
<b>Continue</b>
</td>
<td align="center" width="120">
<img src="../public/providers/droid.png" width="60" alt="Droid"/><br/>
<b>Droid</b>
</td>
<td align="center" width="120">
<img src="../public/providers/roo.png" width="60" alt="Roo"/><br/>
<b>Roo</b>
</td>
<td align="center" width="120">
<img src="../public/providers/copilot.png" width="60" alt="Copilot"/><br/>
<b>Copilot</b>
</td>
<td align="center" width="120">
<img src="../public/providers/kilocode.png" width="60" alt="Kilo Code"/><br/>
<b>Kilo Code</b>
</td>
</tr>
</table>
</div>
---
## 🌐 Provider yang Didukung
### 🔐 Provider OAuth
<div align="center">
<table>
<tr>
<td align="center" width="120">
<img src="../public/providers/claude.png" width="60" alt="Claude Code"/><br/>
<b>Claude-Code</b>
</td>
<td align="center" width="120">
<img src="../public/providers/antigravity.png" width="60" alt="Antigravity"/><br/>
<b>Antigravity</b>
</td>
<td align="center" width="120">
<img src="../public/providers/codex.png" width="60" alt="Codex"/><br/>
<b>Codex</b>
</td>
<td align="center" width="120">
<img src="../public/providers/github.png" width="60" alt="GitHub"/><br/>
<b>GitHub</b>
</td>
<td align="center" width="120">
<img src="../public/providers/cursor.png" width="60" alt="Cursor"/><br/>
<b>Cursor</b>
</td>
</tr>
</table>
</div>
### 🆓 Provider Gratis
<div align="center">
<table>
<tr>
<td align="center" width="150">
<img src="../public/providers/iflow.png" width="70" alt="iFlow"/><br/>
<b>iFlow AI</b><br/>
<sub>8+ model • unlimited</sub>
</td>
<td align="center" width="150">
<img src="../public/providers/qwen.png" width="70" alt="Qwen"/><br/>
<b>Qwen Code</b><br/>
<sub>3+ model • unlimited</sub>
</td>
<td align="center" width="150">
<img src="../public/providers/gemini-cli.png" width="70" alt="Gemini CLI"/><br/>
<b>Gemini CLI</b><br/>
<sub>180 ribu request/bulan gratis</sub>
</td>
<td align="center" width="150">
<img src="../public/providers/kiro.png" width="70" alt="Kiro"/><br/>
<b>Kiro AI</b><br/>
<sub>Claude • unlimited</sub>
</td>
</tr>
</table>
</div>
### 🔑 Provider API Key (40+)
<div align="center">
<table>
<tr>
<td align="center" width="100">
<img src="../public/providers/openrouter.png" width="50" alt="OpenRouter"/><br/>
<sub>OpenRouter</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/glm.png" width="50" alt="GLM"/><br/>
<sub>GLM</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/kimi.png" width="50" alt="Kimi"/><br/>
<sub>Kimi</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/minimax.png" width="50" alt="MiniMax"/><br/>
<sub>MiniMax</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/openai.png" width="50" alt="OpenAI"/><br/>
<sub>OpenAI</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/anthropic.png" width="50" alt="Anthropic"/><br/>
<sub>Anthropic</sub>
</td>
</tr>
<tr>
<td align="center" width="100">
<img src="../public/providers/gemini.png" width="50" alt="Gemini"/><br/>
<sub>Gemini</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/deepseek.png" width="50" alt="DeepSeek"/><br/>
<sub>DeepSeek</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/groq.png" width="50" alt="Groq"/><br/>
<sub>Groq</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/xai.png" width="50" alt="xAI"/><br/>
<sub>xAI</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/mistral.png" width="50" alt="Mistral"/><br/>
<sub>Mistral</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/perplexity.png" width="50" alt="Perplexity"/><br/>
<sub>Perplexity</sub>
</td>
</tr>
<tr>
<td align="center" width="100">
<img src="../public/providers/together.png" width="50" alt="Together"/><br/>
<sub>Together AI</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/fireworks.png" width="50" alt="Fireworks"/><br/>
<sub>Fireworks</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/cerebras.png" width="50" alt="Cerebras"/><br/>
<sub>Cerebras</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/cohere.png" width="50" alt="Cohere"/><br/>
<sub>Cohere</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/nvidia.png" width="50" alt="NVIDIA"/><br/>
<sub>NVIDIA</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/siliconflow.png" width="50" alt="SiliconFlow"/><br/>
<sub>SiliconFlow</sub>
</td>
</tr>
</table>
<p><i>...dan 20+ provider lain seperti Nebius, Chutes, Hyperbolic, serta endpoint custom yang kompatibel dengan OpenAI/Anthropic</i></p>
</div>
---
## 💡 Fitur Utama
| Fitur | Ringkasan | Manfaat |
|-------|-----------|---------|
| 🎯 **Smart Fallback 3 Tingkat** | Routing otomatis: langganan → murah → gratis | Ngoding tanpa berhenti, zero downtime |
| 📊 **Pelacakan Kuota Real-time** | Hitungan token live + hitung mundur reset | Nilai langganan termanfaatkan maksimal |
| 🔄 **Konversi Format** | OpenAI ↔ Claude ↔ Gemini mulus | Bekerja dengan tool CLI apa pun |
| 👥 **Dukungan Multi-akun** | Beberapa akun per provider | Load balancing + redundansi |
| 🔄 **Auto Refresh Token** | Token OAuth diperbarui otomatis | Tidak perlu login ulang manual |
| 🎨 **Combo Kustom** | Buat kombinasi model tanpa batas | Fallback sesuai kebutuhanmu |
| 📝 **Log Request** | Log lengkap request/response | Troubleshooting jadi mudah |
| 💾 **Cloud Sync** | Sinkronkan pengaturan antar perangkat | Setup sama di mana pun |
| 📊 **Analitik Penggunaan** | Lacak token, biaya, dan tren | Optimalkan pengeluaran |
| 🌐 **Deploy di Mana Saja** | Localhost, VPS, Docker, Cloudflare Workers | Opsi deployment fleksibel |
<details>
<summary><b>📖 Detail Fitur</b></summary>
### 🎯 Smart Fallback 3 Tingkat
Buat combo dengan fallback otomatis:
```
Combo: "my-coding-stack"
1. cc/claude-opus-4-6 (langganan)
2. glm/glm-4.7 (backup murah, $0.6/1M)
3. if/kimi-k2-thinking (fallback gratis)
→ Otomatis beralih saat kuota habis atau terjadi error
```
### 📊 Pelacakan Kuota Real-time
- Konsumsi token per provider
- Hitung mundur reset (5 jam, harian, mingguan)
- Estimasi biaya untuk tier berbayar
- Laporan pengeluaran bulanan
### 🔄 Konversi Format
Konversi mulus antar format:
- **OpenAI** ↔ **Claude****Gemini****OpenAI Responses**
- Tool CLI mengirim dalam format OpenAI → 9Router mengonversi → provider menerima dalam format nativenya
- Bekerja dengan semua tool yang mendukung custom OpenAI endpoint
### 👥 Dukungan Multi-akun
- Tambahkan beberapa akun per provider
- Round-robin otomatis atau routing berbasis prioritas
- Saat satu akun mencapai kuota, fallback ke akun berikutnya
### 🔄 Auto Refresh Token
- Token OAuth di-refresh otomatis sebelum kedaluwarsa
- Tidak perlu autentikasi ulang manual
- Pengalaman mulus di semua provider
### 🎨 Combo Kustom
- Buat kombinasi model tanpa batas
- Campur tier langganan, murah, dan gratis
- Beri nama combo agar mudah diakses
- Bagikan combo antar perangkat lewat cloud sync
### 📝 Log Request
- Log lengkap request/response dalam mode debug
- Lacak API call, header, dan payload
- Troubleshoot masalah integrasi
- Ekspor log untuk dianalisis
### 💾 Cloud Sync
- Sinkronkan provider, combo, dan pengaturan antar perangkat
- Sinkronisasi latar belakang otomatis
- Penyimpanan terenkripsi yang aman
- Akses setup dari mana saja
#### Catatan tentang cloud runtime
- Untuk produksi, disarankan memakai variabel cloud sisi server:
- `BASE_URL` (URL callback internal yang dipakai scheduler sinkronisasi)
- `CLOUD_URL` (base URL endpoint cloud sync)
- `NEXT_PUBLIC_BASE_URL` dan `NEXT_PUBLIC_CLOUD_URL` masih didukung untuk kompatibilitas/UI, tetapi runtime server memprioritaskan `BASE_URL`/`CLOUD_URL`.
- Request cloud sync memakai timeout + perilaku fail-fast untuk menghindari UI menggantung saat DNS/jaringan cloud tidak tersedia.
### 📊 Analitik Penggunaan
- Lacak pemakaian token per provider dan per model
- Estimasi biaya dan tren pengeluaran
- Laporan dan insight bulanan
- Optimalkan pengeluaran AI
> **💡 PENTING - tentang biaya di dashboard:**
>
> "Biaya" yang ditampilkan pada analitik penggunaan **hanya untuk pelacakan dan perbandingan**.
> 9Router sendiri **tidak menagih apa pun**. Kamu hanya membayar langsung ke provider jika memakai layanan berbayar.
>
> **Contoh:** jika dashboard menampilkan "Total biaya $290" untuk pemakaian model iFlow,
> itu adalah jumlah yang seharusnya kamu bayar bila memakai API berbayar secara langsung. Biaya sebenarnya = **$0** (iFlow gratis tanpa batas).
>
> Anggap saja ini "pelacak penghematan" yang menunjukkan berapa banyak yang kamu hemat lewat model gratis dan routing 9Router!
### 🌐 Deploy di Mana Saja
- 💻 **Localhost** - default, jalan offline
- ☁️ **VPS/Cloud** - berbagi antar perangkat
- 🐳 **Docker** - deploy satu perintah
- 🚀 **Cloudflare Workers** - jaringan edge global
</details>
---
## 💰 Ringkasan Harga
| Tier | Provider | Biaya | Reset Kuota | Cocok Untuk |
|------|----------|-------|-------------|-------------|
| **💳 Langganan** | Claude Code (Pro) | $20/bulan | 5 jam + mingguan | Yang sudah punya langganan |
| | Codex (Plus/Pro) | $20-200/bulan | 5 jam + mingguan | Pengguna OpenAI |
| | Gemini CLI | **Gratis** | 180rb/bulan + 1rb/hari | Semua orang! |
| | GitHub Copilot | $10-19/bulan | Bulanan | Pengguna GitHub |
| **💰 Murah** | GLM-4.7 | $0.6/1M | Setiap hari jam 10.00 | Backup hemat |
| | MiniMax M2.1 | $0.2/1M | Rolling 5 jam | Opsi paling murah |
| | Kimi K2 | $9/bulan flat | 10 juta token/bulan | Biaya yang bisa diprediksi |
| **🆓 Gratis** | iFlow | $0 | Unlimited | 8 model gratis |
| | Qwen | $0 | Unlimited | 3 model gratis |
| | Kiro | $0 | Unlimited | Claude gratis |
**💡 Tips pro:** combo Gemini CLI (180rb request/bulan gratis) + iFlow (gratis unlimited) = biaya $0!
---
### 📊 Tentang Biaya dan Penagihan 9Router
**Fakta soal penagihan 9Router:**
**Software 9Router = gratis selamanya** (open source, tanpa tagihan)
**"Biaya" di dashboard = tampilan/pelacakan saja** (bukan tagihan sungguhan)
**Pembayaran langsung ke provider** (langganan atau biaya API)
**Provider gratis tetap gratis** (iFlow, Kiro, Qwen = $0 unlimited)
**9Router tidak mengirim invoice** atau menagih kartumu
**Cara kerja tampilan biaya:**
Dashboard menampilkan **estimasi biaya** seandainya kamu memakai API berbayar secara langsung. Ini **bukan tagihan**, melainkan alat pembanding yang menunjukkan penghematanmu.
**Contoh skenario:**
```
Tampilan dashboard:
• Total request: 1.662
• Total token: 47 juta
• Biaya tertampil: $290
Kenyataannya:
• Provider: iFlow (gratis unlimited)
• Yang benar-benar dibayar: $0.00
• Arti $290: jumlah yang kamu hemat dengan memakai model gratis!
```
**Aturan pembayaran:**
- **Provider langganan** (Claude Code, Codex): bayar langsung di website masing-masing
- **Provider murah** (GLM, MiniMax): bayar langsung, 9Router hanya melakukan routing
- **Provider gratis** (iFlow, Kiro, Qwen): benar-benar gratis selamanya, tanpa biaya tersembunyi
- **9Router**: tidak menagih apa pun
---
## 🎯 Studi Kasus
### Kasus 1: "Saya punya langganan Claude Pro"
**Masalah:** kuota hangus tanpa terpakai, kena rate limit saat ngoding berat
**Solusi:**
```
Combo: "maximize-claude"
1. cc/claude-opus-4-6 (manfaatkan langganan semaksimal mungkin)
2. glm/glm-4.7 (backup murah saat kuota habis)
3. if/kimi-k2-thinking (fallback darurat gratis)
Biaya bulanan: $20 (langganan) + ~$5 (backup) = total $25
vs. $20 + kena limit = frustrasi
```
### Kasus 2: "Saya mau biaya nol"
**Masalah:** tidak mampu bayar langganan, tapi butuh AI coding yang andal
**Solusi:**
```
Combo: "free-forever"
1. gc/gemini-3-flash (180rb request/bulan gratis)
2. if/kimi-k2-thinking (gratis unlimited)
3. qw/qwen3-coder-plus (gratis unlimited)
Biaya bulanan: $0
Kualitas: model siap produksi
```
### Kasus 3: "Ngoding 24/7 tanpa terputus"
**Masalah:** deadline mepet, downtime tidak dapat ditoleransi
**Solusi:**
```
Combo: "always-on"
1. cc/claude-opus-4-6 (kualitas terbaik)
2. cx/gpt-5.2-codex (langganan kedua)
3. glm/glm-4.7 (murah, reset harian)
4. minimax/MiniMax-M2.1 (paling murah, reset 5 jam)
5. if/kimi-k2-thinking (gratis unlimited)
Hasil: 5 lapis fallback = zero downtime
Biaya bulanan: $20-200 (langganan) + $10-20 (backup)
```
### Kasus 4: "Saya mau pakai AI gratis di OpenClaw"
**Masalah:** butuh asisten AI di aplikasi pesan (WhatsApp, Telegram, Slack...), sepenuhnya gratis
**Solusi:**
```
Combo: "openclaw-free"
1. if/glm-4.7 (gratis unlimited)
2. if/minimax-m2.1 (gratis unlimited)
3. if/kimi-k2-thinking (gratis unlimited)
Biaya bulanan: $0
Cara akses: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
```
---
## ❓ FAQ
<details>
<summary><b>📊 Kenapa dashboard menampilkan biaya yang besar?</b></summary>
Dashboard melacak pemakaian token dan menampilkan **estimasi biaya** seandainya kamu memakai API berbayar secara langsung. Ini **bukan tagihan nyata**, melainkan acuan untuk melihat berapa banyak yang kamu hemat dengan memakai model gratis atau langganan yang sudah ada lewat 9Router.
**Contoh:**
- **Tampilan dashboard:** "Total biaya $290"
- **Kenyataan:** sedang memakai iFlow (gratis unlimited)
- **Biaya sebenarnya:** **$0.00**
- **Arti $290:** jumlah yang **dihemat** karena memakai model gratis alih-alih API berbayar!
Tampilan biaya adalah "pelacak penghematan" untuk memahami pola pemakaian dan peluang optimasi.
</details>
<details>
<summary><b>💳 Apakah 9Router menagih saya?</b></summary>
**Tidak.** 9Router adalah software open source gratis yang berjalan di komputermu sendiri. Tidak ada penagihan sama sekali.
**Kamu membayar ke:**
-**Provider langganan** (Claude Code $20/bulan, Codex $20-200/bulan) → bayar langsung di website masing-masing
-**Provider murah** (GLM, MiniMax) → bayar langsung, 9Router hanya me-routing request
-**9Router sendiri****tidak menagih apa pun**
9Router adalah proxy/router lokal. Ia tidak menyimpan informasi kartu kredit, tidak bisa mengirim invoice, dan tidak punya sistem penagihan. Sepenuhnya software gratis.
</details>
<details>
<summary><b>🆓 Apakah provider gratis benar-benar unlimited?</b></summary>
**Ya!** Provider yang ditandai gratis (iFlow, Kiro, Qwen) benar-benar unlimited dan **tanpa biaya tersembunyi**.
Ini adalah layanan gratis yang disediakan masing-masing perusahaan:
- **iFlow**: akses gratis unlimited ke 8+ model via OAuth
- **Kiro**: model Claude gratis unlimited via AWS Builder ID
- **Qwen**: akses gratis unlimited ke model Qwen via device authentication
9Router hanya me-routing request — tidak ada "jebakan" atau tagihan di kemudian hari. Layanannya memang gratis, dan 9Router membuatnya lebih mudah dipakai dengan dukungan fallback.
**Catatan:** beberapa provider langganan (Antigravity, GitHub Copilot) punya masa preview gratis dan bisa jadi berbayar nanti, tetapi hal itu diumumkan secara jelas oleh provider tersebut, bukan oleh 9Router.
</details>
<details>
<summary><b>💰 Bagaimana cara menekan biaya AI seminimal mungkin?</b></summary>
**Strategi free-first:**
1. **Mulai dari combo 100% gratis:**
```
1. gc/gemini-3-flash (180rb/bulan gratis dari Google)
2. if/kimi-k2-thinking (gratis unlimited dari iFlow)
3. qw/qwen3-coder-plus (gratis unlimited dari Qwen)
```
**Biaya: $0/bulan**
2. **Tambahkan backup murah hanya bila perlu:**
```
4. glm/glm-4.7 ($0.6 per 1 juta token)
```
**Tambahan biaya: bayar sesuai pemakaian saja**
3. **Gunakan provider langganan paling akhir:**
- Hanya jika kamu memang sudah punya
- 9Router memaksimalkan nilainya lewat pelacakan kuota
**Hasil:** sebagian besar pengguna bisa jalan dengan $0/bulan hanya dengan tier gratis!
</details>
<details>
<summary><b>📈 Bagaimana kalau pemakaian tiba-tiba melonjak?</b></summary>
Smart fallback 9Router mencegah tagihan tak terduga:
**Skenario:** kuota habis di tengah sprint coding
**Tanpa 9Router:**
- ❌ Kena rate limit → kerja berhenti → frustrasi
- ❌ Atau: tagihan API mahal tanpa disengaja
**Dengan 9Router:**
- ✅ Langganan mencapai batas → otomatis fallback ke tier murah
- ✅ Tier murah jadi mahal → otomatis fallback ke tier gratis
- ✅ Ngoding tidak berhenti → biaya tetap terprediksi
**Kamu yang pegang kendali:** atur batas pengeluaran per provider di dashboard, dan 9Router akan mematuhinya.
</details>
---
## 📖 Panduan Setup
<details>
<summary><b>🔐 Provider Langganan (maksimalkan nilainya)</b></summary>
### Claude Code (Pro/Max)
```bash
Dashboard → Providers → hubungkan Claude Code
→ login OAuth → refresh token otomatis
→ pelacakan kuota 5 jam + mingguan
Model:
cc/claude-opus-4-6
cc/claude-sonnet-4-5-20250929
cc/claude-haiku-4-5-20251001
```
**Tips pro:** pakai Opus untuk tugas kompleks, Sonnet kalau mengutamakan kecepatan. 9Router melacak kuota per model!
### OpenAI Codex (Plus/Pro)
```bash
Dashboard → Providers → hubungkan Codex
→ login OAuth (port 1455)
→ reset 5 jam + mingguan
Model:
cx/gpt-5.2-codex
cx/gpt-5.1-codex-max
```
### Gemini CLI (180rb request/bulan gratis!)
```bash
Dashboard → Providers → hubungkan Gemini CLI
→ Google OAuth
→ 180rb/bulan + 1rb/hari
Model:
gc/gemini-3-flash-preview
gc/gemini-2.5-pro
```
**Value terbaik:** free tier-nya besar sekali! Pakai ini sebelum tier berbayar.
### GitHub Copilot
```bash
Dashboard → Providers → hubungkan GitHub
→ OAuth via GitHub
→ reset bulanan (tanggal 1 tiap bulan)
Model:
gh/gpt-5
gh/claude-4.5-sonnet
gh/gemini-3-pro
```
</details>
<details>
<summary><b>💰 Provider Murah (backup)</b></summary>
### GLM-4.7 (reset harian, $0.6/1M)
1. Daftar: [Zhipu AI](https://open.bigmodel.cn/)
2. Ambil API key dari Coding Plan
3. Dashboard → tambahkan API key:
- Provider: `glm`
- API Key: `your-key`
**Pemakaian:** `glm/glm-4.7`
**Tips pro:** Coding Plan memberi kuota 3x lipat dengan biaya 1/7! Reset setiap hari jam 10.00.
### MiniMax M2.1 (reset 5 jam, $0.20/1M)
1. Daftar: [MiniMax](https://www.minimax.io/)
2. Ambil API key
3. Dashboard → tambahkan API key
**Pemakaian:** `minimax/MiniMax-M2.1`
**Tips pro:** opsi termurah dengan konteks panjang (1 juta token)!
### Kimi K2 ($9/bulan flat)
1. Berlangganan: [Moonshot AI](https://platform.moonshot.ai/)
2. Ambil API key
3. Dashboard → tambahkan API key
**Pemakaian:** `kimi/kimi-latest`
**Tips pro:** $9/bulan flat untuk 10 juta token = biaya efektif $0.90/1M!
</details>
<details>
<summary><b>🆓 Provider Gratis (backup darurat)</b></summary>
### iFlow (8 model gratis)
```bash
Dashboard → hubungkan iFlow
→ login OAuth iFlow
→ pemakaian unlimited
Model:
if/kimi-k2-thinking
if/qwen3-coder-plus
if/glm-4.7
if/minimax-m2
if/deepseek-r1
```
### Qwen (3 model gratis)
```bash
Dashboard → hubungkan Qwen
→ autentikasi device code
→ pemakaian unlimited
Model:
qw/qwen3-coder-plus
qw/qwen3-coder-flash
```
### Kiro (Claude gratis)
```bash
Dashboard → hubungkan Kiro
→ AWS Builder ID atau Google/GitHub
→ pemakaian unlimited
Model:
kr/claude-sonnet-4.5
kr/claude-haiku-4.5
```
</details>
<details>
<summary><b>🎨 Membuat Combo</b></summary>
### Contoh 1: maksimalkan langganan → backup murah
```
Dashboard → Combos → buat baru
Nama: premium-coding
Model:
1. cc/claude-opus-4-6 (langganan, utama)
2. glm/glm-4.7 (backup murah, $0.6/1M)
3. minimax/MiniMax-M2.1 (fallback termurah, $0.20/1M)
Pemakaian di CLI: premium-coding
Contoh biaya bulanan (100 juta token):
80 juta lewat Claude (langganan): tambahan $0
15 juta lewat GLM: $9
5 juta lewat MiniMax: $1
Total: $10
```
### Contoh 2: combo 100% gratis
```
Nama: free-forever
Model:
1. gc/gemini-3-flash (180rb request/bulan gratis)
2. if/kimi-k2-thinking (gratis unlimited)
3. qw/qwen3-coder-plus (gratis unlimited)
4. kr/claude-sonnet-4.5 (gratis unlimited)
Biaya bulanan: $0
```
### Tips membuat combo
- Urutkan dari kualitas/prioritas tertinggi ke fallback paling murah
- Selalu taruh minimal satu provider gratis di posisi terakhir
- Pakai nama combo yang deskriptif agar mudah dipilih dari CLI
- Aktifkan cloud sync agar combo ikut tersedia di perangkat lain
</details>
---
## 🐳 Deployment
<details>
<summary><b>Docker</b></summary>
```bash
docker run -d \
--name 9router \
-p 20128:20128 \
-v 9router-data:/app/data \
-e PORT=20128 \
-e BASE_URL=http://localhost:20128 \
ghcr.io/decolua/9router:latest
```
Dashboard: `http://localhost:20128/dashboard`
</details>
<details>
<summary><b>VPS / Cloud</b></summary>
```bash
npm install -g 9router
PORT=20128 HOSTNAME=0.0.0.0 BASE_URL=https://your-domain.com 9router
```
Disarankan menaruhnya di belakang reverse proxy (Nginx/Caddy) dengan HTTPS, dan membatasi akses hanya untuk dirimu sendiri.
</details>
<details>
<summary><b>Cloudflare Workers</b></summary>
```bash
npm run build
npx wrangler deploy
```
Atur `BASE_URL` dan `CLOUD_URL` sebagai environment variable di dashboard Cloudflare.
</details>
---
## 🧪 Troubleshooting
| Masalah | Kemungkinan Penyebab | Solusi |
|---------|----------------------|--------|
| Tool CLI tidak bisa konek | Endpoint salah | Pastikan `http://localhost:20128/v1` |
| 401 / Unauthorized | API key salah | Salin ulang key dari dashboard |
| Model tidak ditemukan | Prefix provider salah | Pakai format `provider/model`, mis. `if/kimi-k2-thinking` |
| Selalu fallback ke gratis | Kuota langganan habis | Cek hitung mundur reset di dashboard |
| OAuth gagal | Port callback terpakai | Tutup proses lain (mis. port 1455 untuk Codex) |
| UI menggantung saat sync | DNS/jaringan cloud bermasalah | Cek `CLOUD_URL`; sync memakai timeout fail-fast |
Aktifkan mode debug di dashboard untuk melihat log lengkap request/response.
---
## 🤝 Kontribusi
Kontribusi sangat diterima!
1. Fork repo ini
2. Buat branch fitur (`git checkout -b feature/nama-fitur`)
3. Commit perubahanmu (`git commit -m 'feat: tambah fitur X'`)
4. Push ke branch (`git push origin feature/nama-fitur`)
5. Buka Pull Request
---
## 📄 Lisensi
MIT License — lihat [LICENSE](https://github.com/decolua/9router/blob/main/LICENSE) untuk detailnya.
---
<div align="center">
**Kalau 9Router membantumu, kasih ⭐ di [GitHub](https://github.com/decolua/9router)!**
[🌐 Website](https://9router.com) • [📦 npm](https://www.npmjs.com/package/9router) • [🐛 Laporkan Bug](https://github.com/decolua/9router/issues)
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,723 +0,0 @@
นี่คือเอกสารแปลภาษาไทยของไฟล์ Markdown ต้นฉบับ โดยรักษาโครงสร้างและซินแท็กซ์ทางเทคนิคทั้งหมดไว้เหมือนเดิม
<div align="center">
<img src="../images/9router.png?1" alt="แดชบอร์ด 9Router" width="800"/>
# 9Router - Free AI Router
**ไม่ต้องหยุดเขียนโค้ด ประหยัดโทเค็น 20-40% ด้วย RTK + สลับอัตโนมัติไปยังโมเดล AI ฟรีและราคาถูก**
**ผู้ให้บริการ AI ฟรีสำหรับ OpenClaw**
<p align="center">
<img src="../public/providers/openclaw.png" alt="OpenClaw" width="80"/>
</p>
[![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router)
[![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router)
[![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE)
[🚀 เริ่มต้นใช้งาน](#-quick-start) • [💡 ฟีเจอร์](#-key-features) • [📖 การตั้งค่า](#-setup-guide) • [🌐 เว็บไซต์](https://9router.com)
</div>
---
## 🤔 ทำไมต้อง 9Router?
**หยุดเสียเงินและเจอขีดจำกัด:**
- ❌ โควตาสมาชิกหมดอายุโดยไม่ได้ใช้ทุกเดือน
- ❌ Rate Limit หยุดคุณระหว่างเขียนโค้ด
- ❌ ค่า API แพง ($20-50/เดือน ต่อผู้ให้บริการแต่ละราย)
- ❌ ต้องสลับผู้ให้บริการด้วยตนเอง
**9Router แก้ปัญหาเหล่านี้:**
-**ประหยัดโทเค็น RTK** - บีบอัดผลลัพธ์จากเครื่องมือ (`git diff`, `grep`, `ls`...) ก่อนส่งให้ LLM
-**เพิ่มประสิทธิภาพสมาชิก** - ติดตามโควตา ใช้ทุกบิตก่อนรีเซ็ต
-**สลับอัตโนมัติ** - สมาชิก → ถูก → ฟรี, ไม่มีเวลาหยุดทำงาน
-**รองรับหลายบัญชี** - Round-robin ระหว่างบัญชีของผู้ให้บริการแต่ละราย
-**ใช้งานได้ทุกที่** - ใช้ได้กับ Claude Code, Codex, Cursor, Cline, เครื่องมือ CLI ใดก็ได้
---
## 🔄 วิธีการทำงาน
```
┌─────────────┐
│ Your CLI │ (Claude Code, Codex, OpenClaw, Cursor, Cline...)
│ Tool │
└──────┬──────┘
│ http://localhost:20128/v1
┌─────────────────────────────────────────────┐
│ 9Router (Smart Router) │
│ • RTK Token Saver (ตัดโทเค็น tool_result) │
│ • แปลงรูปแบบ (OpenAI ↔ Claude) │
│ • ติดตามโควตา │
│ • รีเฟรชโทเค็นอัตโนมัติ │
└──────┬──────────────────────────────────────┘
├─→ [Tier 1: สมาชิก] Claude Code, Codex, GitHub Copilot
│ ↓ โควตาหมด
├─→ [Tier 2: ถูก] GLM ($0.6/1M), MiniMax ($0.2/1M)
│ ↓ งบหมด
└─→ [Tier 3: ฟรี] Kiro, OpenCode Free, Vertex ($300 เครดิตฟรี)
ผลลัพธ์: ไม่ต้องหยุดเขียนโค้ด ค่าใช้จ่ายน้อยที่สุด + ประหยัดโทเค็น 20-40% ด้วย RTK
```
---
## ⚡ เริ่มต้นใช้งาน
**1. ติดตั้งแบบ Global:**
```bash
npm install -g 9router
9router
```
🎉 เปิดแดชบอร์ดที่ `http://localhost:20128`
**2. เชื่อมต่อผู้ให้บริการฟรี (ไม่ต้องสมัคร):**
แดชบอร์ด → Providers → เชื่อมต่อ **Kiro AI** (Claude ฟรีไม่จำกัด) หรือ **OpenCode Free** (ไม่ต้องยืนยันตัวตน) → เสร็จ!
**3. ใช้ในเครื่องมือ CLI ของคุณ:**
```
ตั้งค่า Claude Code/Codex/OpenClaw/Cursor/Cline:
Endpoint: http://localhost:20128/v1
API Key: [คัดลอกจากแดชบอร์ด]
Model: kr/claude-sonnet-4.5
```
**เสร็จแล้ว!** เริ่มเขียนโค้ดด้วยโมเดล AI ฟรี
**วิธีอื่น: รันจากซอร์สโค้ด (เก็บรักษาไว้ใน repo นี้):**
Repo นี้เป็น private package (`9router-app`) ดังนั้นการรันจากซอร์ส/Docker คือเส้นทางพัฒนาท้องถิ่นที่คาดไว้
```bash
cp .env.example .env
npm install
PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
```
โหมด Production:
```bash
npm run build
PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start
```
URL ค่าเริ่มต้น:
- แดชบอร์ด: `http://localhost:20128/dashboard`
- OpenAI-compatible API: `http://localhost:20128/v1`
---
## 🛠️ เครื่องมือ CLI ที่รองรับ
9Router ทำงานได้อย่างราบรื่นกับเครื่องมือเขียนโค้ด AI ทุกประเภท:
<div align="center">
<table>
<tr>
<td align="center" width="120">
<img src="../public/providers/claude.png" width="60" alt="Claude Code"/><br/>
<b>Claude-Code</b>
</td>
<td align="center" width="120">
<img src="../public/providers/openclaw.png" width="60" alt="OpenClaw"/><br/>
<b>OpenClaw</b>
</td>
<td align="center" width="120">
<img src="../public/providers/codex.png" width="60" alt="Codex"/><br/>
<b>Codex</b>
</td>
<td align="center" width="120">
<img src="../public/providers/opencode.png" width="60" alt="OpenCode"/><br/>
<b>OpenCode</b>
</td>
<td align="center" width="120">
<img src="../public/providers/cursor.png" width="60" alt="Cursor"/><br/>
<b>Cursor</b>
</td>
<td align="center" width="120">
<img src="../public/providers/antigravity.png" width="60" alt="Antigravity"/><br/>
<b>Antigravity</b>
</td>
</tr>
<tr>
<td align="center" width="120">
<img src="../public/providers/cline.png" width="60" alt="Cline"/><br/>
<b>Cline</b>
</td>
<td align="center" width="120">
<img src="../public/providers/continue.png" width="60" alt="Continue"/><br/>
<b>Continue</b>
</td>
<td align="center" width="120">
<img src="../public/providers/droid.png" width="60" alt="Droid"/><br/>
<b>Droid</b>
</td>
<td align="center" width="120">
<img src="../public/providers/roo.png" width="60" alt="Roo"/><br/>
<b>Roo</b>
</td>
<td align="center" width="120">
<img src="../public/providers/copilot.png" width="60" alt="Copilot"/><br/>
<b>Copilot</b>
</td>
<td align="center" width="120">
<img src="../public/providers/kilocode.png" width="60" alt="Kilo Code"/><br/>
<b>Kilo Code</b>
</td>
</tr>
</table>
</div>
---
## ผู้ให้บริการที่รองรับ
### 🔐 ผู้ให้บริการ OAuth
<div align="center">
<table>
<tr>
<td align="center" width="120">
<img src="../public/providers/claude.png" width="60" alt="Claude Code"/><br/>
<b>Claude-Code</b>
</td>
<td align="center" width="120">
<img src="../public/providers/antigravity.png" width="60" alt="Antigravity"/><br/>
<b>Antigravity</b>
</td>
<td align="center" width="120">
<img src="../public/providers/codex.png" width="60" alt="Codex"/><br/>
<b>Codex</b>
</td>
<td align="center" width="120">
<img src="../public/providers/github.png" width="60" alt="GitHub"/><br/>
<b>GitHub</b>
</td>
<td align="center" width="120">
<img src="../public/providers/cursor.png" width="60" alt="Cursor"/><br/>
<b>Cursor</b>
</td>
</tr>
</table>
</div>
### 🆓 ผู้ให้บริการฟรี
<div align="center">
<table>
<tr>
<td align="center" width="150">
<img src="../public/providers/kiro.png" width="70" alt="Kiro"/><br/>
<b>Kiro AI</b><br/>
<sub>Claude 4.5 + GLM-5 + MiniMax • ไม่จำกัด ฟรี</sub>
</td>
<td align="center" width="150">
<img src="../public/providers/opencode.png" width="70" alt="OpenCode"/><br/>
<b>OpenCode Free</b><br/>
<sub>ไม่ต้องยืนยันตัวตน • ดึงโมเดลอัตโนมัติ • ไม่จำกัด ฟรี</sub>
</td>
<td align="center" width="150">
<img src="../public/providers/gemini.png" width="70" alt="Vertex AI"/><br/>
<b>Vertex AI</b><br/>
<sub>Gemini 3 Pro + GLM-5 + DeepSeek • เครดิตฟรี $300</sub>
</td>
</tr>
</table>
</div>
> **หมายเหตุ:** iFlow, Qwen และ Gemini CLI หยุดให้บริการในปี 2026 แล้ว ใช้ Kiro / OpenCode Free / Vertex แทน
### 🔑 ผู้ให้บริการ API Key (40+)
<div align="center">
<table>
<tr>
<td align="center" width="100">
<img src="../public/providers/openrouter.png" width="50" alt="OpenRouter"/><br/>
<sub>OpenRouter</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/glm.png" width="50" alt="GLM"/><br/>
<sub>GLM</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/kimi.png" width="50" alt="Kimi"/><br/>
<sub>Kimi</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/minimax.png" width="50" alt="MiniMax"/><br/>
<sub>MiniMax</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/openai.png" width="50" alt="OpenAI"/><br/>
<sub>OpenAI</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/anthropic.png" width="50" alt="Anthropic"/><br/>
<sub>Anthropic</sub>
</td>
</tr>
<tr>
<td align="center" width="100">
<img src="../public/providers/gemini.png" width="50" alt="Gemini"/><br/>
<sub>Gemini</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/deepseek.png" width="50" alt="DeepSeek"/><br/>
<sub>DeepSeek</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/groq.png" width="50" alt="Groq"/><br/>
<sub>Groq</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/xai.png" width="50" alt="xAI"/><br/>
<sub>xAI</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/mistral.png" width="50" alt="Mistral"/><br/>
<sub>Mistral</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/perplexity.png" width="50" alt="Perplexity"/><br/>
<sub>Perplexity</sub>
</td>
</tr>
<tr>
<td align="center" width="100">
<img src="../public/providers/together.png" width="50" alt="Together"/><br/>
<sub>Together AI</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/fireworks.png" width="50" alt="Fireworks"/><br/>
<sub>Fireworks</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/cerebras.png" width="50" alt="Cerebras"/><br/>
<sub>Cerebras</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/cohere.png" width="50" alt="Cohere"/><br/>
<sub>Cohere</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/nvidia.png" width="50" alt="NVIDIA"/><br/>
<sub>NVIDIA</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/siliconflow.png" width="50" alt="SiliconFlow"/><br/>
<sub>SiliconFlow</sub>
</td>
</tr>
</table>
<p><i>...และผู้ให้บริการอีกกว่า 20 ราย รวมถึง Nebius, Chutes, Hyperbolic และ OpenAI/Anthropic compatible endpoints แบบกำหนดเอง</i></p>
</div>
---
## 💡 ฟีเจอร์หลัก
| ฟีเจอร์ | ทำอะไร | ทำไมถึงสำคัญ |
|---------|--------------|----------------|
| 🚀 **RTK Token Saver** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | บีบอัดผลลัพธ์จากเครื่องมือ (`git diff`, `grep`, `ls`, `tree`...) ก่อนส่งให้ LLM | ประหยัด **โทเค็น input 20-40%** ต่อคำขอ |
| 🧠 **Headroom Token Saver** ([Headroom](https://github.com/chopratejas/headroom)) | พร็อกซี `/v1/compress` ภายนอกก่อนเลือกผู้ให้บริการ | ประหยัดโทเค็นบริบทมากขึ้นโดยไม่ต้องเปลี่ยน client |
| 🪨 **Caveman Mode** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | ฉีด caveman-speak prompt → LLM ตอบสั้นกระชับ เนื้อหาทางเทคนิคยังครบถ้วน | ประหยัด **โทเค็น output สูงสุด 65%** |
| 🐴 **Ponytail** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | ฉีด prompt "lazy senior dev" → LLM เขียนโค้ดน้อยที่สุด YAGNI-first (Lite/Full/Ultra) | **โทเค็น output น้อยลง, ไม่ต้อง refactor มาก** |
| 🎯 **Smart 3-Tier Fallback** | เลือกเส้นทางอัตโนมัติ: สมาชิก → ถูก → ฟรี | ไม่ต้องหยุดเขียนโค้ด, ไม่มีเวลาหยุดทำงาน |
| 📊 **ติดตามโควตาแบบ Real-Time** | นับโทเค็นแบบ live + นับถอยหลังรีเซ็ต | เพิ่มประสิทธิภาพมูลค่าสมาชิก |
| 🔄 **แปลงรูปแบบ** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | ใช้ได้กับเครื่องมือ CLI ทุกประเภท |
| 👥 **รองรับหลายบัญชี** | หลายบัญชีต่อผู้ให้บริการ | Load balancing + สำรองข้อมูล |
| 🔄 **รีเฟรชโทเค็นอัตโนมัติ** | OAuth token รีเฟรชอัตโนมัติ | ไม่ต้องล็อกอินซ้ำด้วยตนเอง |
| 🎨 **Combo กำหนดเอง** | สร้างการผสมผสานโมเดลไม่จำกัด | ปรับแต่ง fallback ตามความต้องการ |
| 📝 **บันทึก Request** | โหมด debug พร้อม log request/response ครบถ้วน | แก้ไขปัญหาได้ง่าย |
| 💾 **ซิงค์คลาวด์** | ซิงค์การตั้งค่าระหว่างอุปกรณ์ | การตั้งค่าเดียวกันทุกที่ |
| 📊 **วิเคราะห์การใช้งาน** | ติดตามโทเค็น, ค่าใช้จ่าย, แนวโน้มตามเวลา | ปรับแต่งค่าใช้จ่าย |
| 🌐 **Deploy ได้ทุกที่** | Localhost, VPS, Docker, Cloudflare Workers | ตัวเลือก deploy ที่ยืดหยุ่น |
<details>
<summary><b>📖 รายละเอียดฟีเจอร์</b></summary>
### 🚀 RTK Token Saver
ผลลัพธ์จากเครื่องมือ (`git diff`, `grep`, `find`, `ls`, `tree`, log dumps...) มักกินงบประมาณ prompt 30-50% RTK ตรวจสอบและบีบอัดอย่างชาญฉลาดแบบ lossless **ก่อน**คำขอถึง LLM:
- **ตัวกรอง:** `git-diff`, `git-status`, `grep`, `find`, `ls`, `tree`, `dedup-log`, `smart-truncate`, `read-numbered`, `search-list`
- **ตรวจจับอัตโนมัติ:** ไม่ต้องตั้งค่า — RTK .peek 1KB แรกของแต่ละ `tool_result` และเลือกตัวกรองที่ถูกต้อง
- **ปลอดภัยโดยการออกแบบ:** ถ้าตัวกรองล้มเหลว, ขว้าง error, หรือทำให้ผลลัพธ์ใหญ่ขึ้น RTK จะเก็บข้อความต้นฉบับไว้โดยเงียบๆ ไม่มี error ทำให้คำขอของคุณล้มเหลว
- **ใช้ได้ทุกที่:** ใช้ได้กับทุกรูปแบบ (OpenAI, Claude, Gemini, Cursor, Kiro, OpenAI Responses) เพราะทำงาน **ก่อน**การแปลงรูปแบบใดๆ
- **เปิดใช้งานเป็นค่าเริ่มต้น:** ปิด/เปิดได้ตลอดเวลาใน แดชบอร์ด → ตั้งค่า Endpoint
```
ไม่ใช้ RTK: ส่ง 47K โทเค็นให้ LLM
ใช้ RTK: ส่ง 28K โทเค็นให้ LLM (ประหยัด 40% · บริบทเดียวกัน · คำตอบเดียวกัน)
```
### 🧠 Headroom Token Saver
Headroom เป็นตัวเลือกและทำงานแยกกัน 9Router เรียก endpoint `/v1/compress` ของ Headroom จากนั้นยังคงเลือกเส้นทาง, fallback, auth และติดตามการใช้งานตามปกติ:
```
Client → 9Router → Headroom /v1/compress → 9Router → provider
```
ตั้งค่าท้องถิ่น:
```bash
pip install "headroom-ai[proxy]"
headroom proxy --port 8787
```
เปิดใช้งานใน แดชบอร์ด → Endpoint → Token Saver → Headroom URL ค่าเริ่มต้น: `http://localhost:8787`
ตัวอย่าง Docker:
```bash
# Headroom service ใน Docker network เดียวกัน
http://host.docker.internal:8787
```
ถ้า Headroom ดับหรือคืน error, 9Router จะ fail open และส่งคำขอต้นฉบับ
### 🐴 Ponytail (Lazy Senior Dev)
Ponytail ฉีด prompt *"lazy senior dev"* เข้าไปในทุกคำขอ ทำให้ LLM เขียนโค้ดน้อยที่สุดแบบ YAGNI-first — ลบมากกว่าเพิ่ม, stdlib มากกว่า dep ใหม่, one-liner มากกว่า abstraction
- **Lite** — สร้างตามที่ขอ, บอกชื่อทางเลือกที่ lazy กว่า
- **Full** — บังคับ YAGNI ladder: stdlib → native → existing deps → one-liner → minimal code
- **Ultra** — YAGNI extremist: ลบก่อน, ส่ง one-liner, ตั้งคำถามกับ requirement ที่เหลือในคำตอบเดียวกัน
```
ไม่ใช้ Ponytail: โค้ดเยอะ, abstraction เยอะ, "เผื่อไว้" scaffolding
ใช้ Ponytail: diff สั้นที่สุดที่ทำงานได้, ไม่เพิ่ม abstraction ที่ไม่ได้ขอ, โทเค็นน้อยลง
```
ไม่มีวันแลก: input validation, error handling ที่ป้องกัน data loss, security, accessibility หรือสิ่งที่ขอมาอย่างชัดเจน เปิดใช้งานใน แดชบอร์ด → Endpoint → Ponytail ใช้คู่กับ Caveman (ความกระชับ output) และ RTK (การบีบอัด input) ได้
### 🎯 Smart 3-Tier Fallback
สร้าง combo พร้อม fallback อัตโนมัติ:
```
Combo: "my-coding-stack"
1. cc/claude-opus-4-6 (สมาชิกของคุณ)
2. glm/glm-4.7 (สำรองราคาถูก, $0.6/1M)
3. if/kimi-k2-thinking (fallback ฟรี)
→ สลับอัตโนมัติเมื่อโควตาหมดหรือเกิด error
```
### 📊 ติดตามโควตาแบบ Real-Time
- การใช้โทเค็นต่อผู้ให้บริการ
- นับถอยหลังรีเซ็ต (5 ชั่วโมง, รายวัน, รายสัปดาห์)
- ประมาณการค่าใช้จ่ายสำหรับชั้นแบบเสียค่าใช้จ่าย
- รายงานค่าใช้จ่ายรายเดือน
### 🔄 แปลงรูปแบบ
แปลงรูปแบบได้อย่างราบรื่น:
- **OpenAI** ↔ **Claude****Gemini****Cursor****Kiro****Vertex****Antigravity****Ollama****OpenAI Responses**
- เครื่องมือ CLI ของคุณส่งรูปแบบ OpenAI → 9Router แปลง → ผู้ให้บริการได้รับรูปแบบต้นฉบับ
- ใช้ได้กับเครื่องมือใดก็ได้ที่รองรับ custom OpenAI endpoints
### 👥 รองรับหลายบัญชี
- เพิ่มหลายบัญชีสำหรับผู้ให้บริการแต่ละราย
- เลือกเส้นทาง round-robin หรือตามลำดับความสำคัญอัตโนมัติ
- Fallback ไปยังบัญชีถัดไปเมื่อบัญชีหนึ่งชนโควตา
### 🔄 รีเฟรชโทเค็นอัตโนมัติ
- OAuth token รีเฟรชอัตโนมัติก่อนหมดอายุ
- ไม่ต้องยืนยันตัวตนใหม่ด้วยตนเอง
- ประสบการณ์ที่ราบรื่นบนผู้ให้บริการทุกราย
### 🎨 Combo กำหนดเอง
- สร้างการผสมผสานโมเดลไม่จำกัด
- ผสมชั้นสมาชิก, ราคาถูกและฟรี
- ตั้งชื่อ combo เพื่อเข้าถึงง่าย
- แชร์ combo ระหว่างอุปกรณ์ด้วยการซิงค์คลาวด์
### 📝 บันทึก Request
- เปิดโหมด debug เพื่อดู log request/response ครบถ้วน
- ติดตาม API calls, headers และ payloads
- แก้ไขปัญหาการเชื่อมต่อ
- Export log เพื่อวิเคราะห์
### 💾 ซิงค์คลาวด์
- ซิงค์ผู้ให้บริการ, combo และการตั้งค่าระหว่างอุปกรณ์
- ซิงค์เบื้องหลังอัตโนมัติ
- จัดเก็บข้อมูลแบบเข้ารหัสปลอดภัย
- เข้าถึงการตั้งค่าของคุณจากทุกที่
### 📊 วิเคราะห์การใช้งาน
- ติดตามการใช้โทเค็นตามผู้ให้บริการและโมเดล
- ประมาณการค่าใช้จ่ายและแนวโน้มค่าใช้จ่าย
- รายงานและข้อมูลเชิงลึกรายเดือน
- ปรับแต่งค่าใช้จ่าย AI ของคุณ
### 🌐 Deploy ได้ทุกที่
- 💻 **Localhost** - ค่าเริ่มต้น, ทำงานออฟไลน์
- ☁️ **VPS/Cloud** - แชร์ระหว่างอุปกรณ์
- 🐳 **Docker** - Deploy ด้วยคำสั่งเดียว
- 🚀 **Cloudflare Workers** - เครือข่าย edge ทั่วโลก
</details>
---
## 💰 สรุปราคา
| ประเภท | ผู้ให้บริการ | ค่าใช้จ่าย | รีเซ็ตโควตา | ดีที่สุดสำหรับ |
|------|----------|------|-------------|----------|
| **💳 สมาชิก** | Claude Code (Pro) | $20/เดือน | 5 ชม. + รายสัปดาห์ | มีสมาชิกอยู่แล้ว |
| | Codex (Plus/Pro) | $20-200/เดือน | 5 ชม. + รายสัปดาห์ | ผู้ใช้ OpenAI |
| | GitHub Copilot | $10-19/เดือน | รายเดือน | ผู้ใช้ GitHub |
| **💰 ราคาถูก** | GLM-4.7 | $0.6/1M | ทุกวัน 10:00 AM | สำรองงบ |
| | MiniMax M2.1 | $0.2/1M | 5 ชั่วโมง | ถูกที่สุด |
| | Kimi K2 | $9/เดือน คงที่ | 10M โทเค็น/เดือน | ค่าใช้จ่ายที่คาดเดาได้ |
| **🆓 ฟรี** | Kiro | $0 | ไม่จำกัด | Claude ฟรี |
| | OpenCode Free | $0 | ไม่จำกัด | ไม่ต้องยืนยันตัวตน |
| | Vertex AI | $0 | $300 เครดิตฟรี | Gemini 3 Pro |
**💡 เคล็ดลับ:** เริ่มจาก combo Kiro (Claude ฟรีไม่จำกัด) + OpenCode Free (ไม่ต้องยืนยันตัวตน) = ค่าใช้จ่าย $0!
---
## 🎯 กรณีการใช้งาน
### กรณีที่ 1: "ฉันมีสมาชิก Claude Pro"
**ปัญหา:** โควตาหมดอายุโดยไม่ได้ใช้, Rate Limit ตอนเขียนโค้ดหนัก
**วิธีแก้:**
```
Combo: "maximize-claude"
1. cc/claude-opus-4-6 (ใช้สมาชิกเต็มที่)
2. glm/glm-4.7 (สำรองราคาถูกเมื่อโควตาหมด)
3. kr/claude-sonnet-4.5 (fallback ฉุกเฉินฟรี)
ค่าใช้จ่ายรายเดือน: $20 (สมาชิก) + ~$5 (สำรอง) = $25 รวม
เทียบกับ $20 + ชนโควตา = ผิดหวัง
```
### กรณีที่ 2: "ฉันต้องการค่าใช้จ่ายเป็นศูนย์"
**ปัญหา:** ไม่มีงบจ่ายสมาชิก, ต้องการ AI เขียนโค้ดที่เชื่อถือได้
**วิธีแก้:**
```
Combo: "free-forever"
1. kr/claude-sonnet-4.5 (Claude ฟรีไม่จำกัด)
2. oc/* (OpenCode Free ไม่ต้องยืนยันตัวตน)
3. vertex/gemini-3.1-pro-preview (Vertex $300 เครดิตฟรี)
ค่าใช้จ่ายรายเดือน: $0
คุณภาพ: โมเดลพร้อมใช้งาน production
```
### กรณีที่ 3: "ฉันต้องเขียนโค้ด 24/7 ไม่มีสะดุด"
**ปัญหา:** Deadline, ไม่สามารถหยุดทำงานได้
**วิธีแก้:**
```
Combo: "always-on"
1. cc/claude-opus-4-6 (คุณภาพดีที่สุด)
2. cx/gpt-5.5 (สมาชิกที่สอง)
3. glm/glm-5.1 (ราคาถูก, รีเซ็ตทุกวัน)
4. minimax/MiniMax-M2.7 (ถูกที่สุด, รีเซ็ต 5 ชม.)
5. kr/claude-sonnet-4.5 (ฟรีไม่จำกัด)
ผลลัพธ์: 5 ชั้น fallback = ไม่มีเวลาหยุดทำงาน
ค่าใช้จ่ายเดือน: $20-200 (สมาชิก) + $10-20 (สำรอง)
```
### กรณีที่ 4: "ฉันต้องการ AI ฟรีใน OpenClaw"
**ปัญหา:** ต้องการ AI assistant ในแอปพลิเคชันแชท (WhatsApp, Telegram, Slack...), ฟรีทั้งหมด
**วิธีแก้:**
```
Combo: "openclaw-free"
1. kr/claude-sonnet-4.5 (Claude ฟรีไม่จำกัด)
2. kr/glm-5 (GLM ฟรีไม่จำกัด)
3. kr/MiniMax-M2.5 (MiniMax ฟรีไม่จำกัด)
ค่าใช้จ่ายรายเดือน: $0
เข้าถึงผ่าน: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
```
---
## ❓ คำถามที่พบบ่อย
<details>
<summary><b>💳 9Router เก็บเงินฉันหรือไม่?</b></summary>
**ไม่.** 9Router เป็นซอฟต์แวร์ฟรีแบบ open source ที่ทำงานบนเครื่องของคุณเอง มันไม่มีวันเรียกเก็บเงินจากคุณ
**คุณจ่ายเงินเฉพาะ:**
-**ผู้ให้บริการสมาชิก** (Claude Code $20/เดือน, Codex $20-200/เดือน) → จ่ายตรงให้พวกเขาบนเว็บไซต์ของพวกเขา
-**ผู้ให้บริการราคาถูก** (GLM, MiniMax) → จ่ายตรงให้พวกเขา, 9Router แค่เลือกเส้นทางคำขอของคุณ
-**ตัว 9Router เอง****ไม่มีวันเรียกเก็บเงินใดๆ ทั้งสิ้น**
9Router เป็น proxy/router ท้องถิ่น มันไม่มีบัตรเครดิตของคุณ, ไม่สามารถส่งใบแจ้งหนี้ได้ และไม่มีระบบชำระเงิน เป็นซอฟต์แวร์ฟรีทั้งหมด
</details>
<details>
<summary><b>🆓 ผู้ให้บริการฟรีไม่จำกัดจริงหรือ?</b></summary>
**จริง!** ผู้ให้บริการที่ระบุว่าฟรี (Kiro, OpenCode Free, Vertex) ไม่จำกัดจริงๆ **ไม่มีค่าใช้จ่ายแอบแฝง**
นี่คือบริการฟรีที่บริษัทต่างๆ ให้บริการ:
- **Kiro**: Claude ฟรีไม่จำกัดผ่าน AWS Builder ID
- **OpenCode Free**: ไม่ต้องยืนยันตัวตน, ดึงโมเดลอัตโนมัติ
- **Vertex AI**: $300 เครดิตฟรีสำหรับ Gemini 3 Pro
9Router แค่เลือกเส้นทางคำขอของคุณไปหาพวกเขา — ไม่มี "กับดัก" หรือการเรียกเก็บเงินในอนาคต เป็นบริการที่ฟรีจริงๆ และ 9Router ทำให้ใช้งานง่ายด้วยการรองรับ fallback
</details>
<details>
<summary><b>💰 ทำอย่างไรเพื่อลดค่าใช้จ่าย AI จริงของฉัน?</b></summary>
**กลยุทธ์ Free First:**
1. **เริ่มจาก combo ฟรี 100%:**
```
1. kr/claude-sonnet-4.5 (Claude ฟรีไม่จำกัด)
2. oc/* (OpenCode Free ไม่ต้องยืนยันตัวตน)
3. vertex/gemini-3.1-pro-preview ($300 เครดิตฟรี)
```
**ค่าใช้จ่าย: $0/เดือน**
2. **เพิ่มสำรองราคาถูก** เมื่อจำเป็นเท่านั้น:
```
4. glm/glm-5.1 ($0.6/1M โทเค็น)
```
**ค่าใช้จ่ายเพิ่มเติม:** จ่ายเฉพาะที่ใช้
3. **ใช้ผู้ให้บริการสมาชิก** ก็ต่อเมื่อมีอยู่แล้ว:
- 9Router ช่วยเพิ่มประสิทธิภาพมูลค่าของพวกเขาผ่านการติดตามโควตา
**ผลลัพธ์:** ผู้ใช้ส่วนใหญ่สามารถทำงานที่ $0/เดือน โดยใช้เฉพาะชั้นฟรี!
</details>
---
## 🐛 การแก้ไขปัญหา
**"Language model did not provide messages"**
- โควตาผู้ให้บริการหมด → ตรวจสอบตัวติดตามโควตาในแดชบอร์ด
- วิธีแก้: ใช้ combo fallback หรือสลับไปชั้นที่ถูกกว่า
**Rate Limiting**
- สมาชิกหมดโควตา → Fallback ไป GLM/MiniMax
- เพิ่ม combo: `cc/claude-opus-4-6 → glm/glm-5.1 → kr/claude-sonnet-4.5`
**OAuth Token หมดอายุ**
- รีเฟรชอัตโนมัติโดย 9Router
- ถ้าปัญหายังคงอยู่: แดชบอร์ด → ผู้ให้บริการ → เชื่อมต่อใหม่
**ค่าใช้จ่ายสูง**
- เปิดใช้ RTK ใน แดชบอร์ด → ตั้งค่า Endpoint (เปิดเป็นค่าเริ่มต้น, ประหยัด 20-40% โทเค็น)
- ตรวจสอบสถิติการใช้งานในแดชบอร์ด
- สลับโมเดลหลักไป GLM/MiniMax
- ใช้ชั้นฟรี (Kiro, OpenCode Free, Vertex) สำหรับงานที่ไม่สำคัญ
**แดชบอร์ดเปิดผิดพอร์ต**
- ตั้ง `PORT=20128` และ `NEXT_PUBLIC_BASE_URL=http://localhost:20128`
**ล็อกอินครั้งแรกไม่ทำงาน**
- ตรวจสอบ `INITIAL_PASSWORD` ใน `.env`
- ถ้ายังไม่ตั้งค่า รหัสผ่านสำรองคือ `123456`
**ไม่มี request log ใต้ `logs/`**
- ตั้ง `ENABLE_REQUEST_LOGS=true`
---
## 🛠️ Tech Stack
- **Runtime**: Node.js 20+
- **Framework**: Next.js 16
- **UI**: React 19 + Tailwind CSS 4
- **Database**: SQLite (better-sqlite3 / node:sqlite / sql.js fallback)
- **Streaming**: Server-Sent Events (SSE)
- **Auth**: OAuth 2.0 (PKCE) + JWT + API Keys
---
## 📝 API Reference
### Chat Completions
```bash
POST http://localhost:20128/v1/chat/completions
Authorization: Bearer your-api-key
Content-Type: application/json
{
"model": "cc/claude-opus-4-6",
"messages": [
{"role": "user", "content": "เขียนฟังก์ชันเพื่อ..."}
],
"stream": true
}
```
### List Models
```bash
GET http://localhost:20128/v1/models
Authorization: Bearer your-api-key
→ คืนค่าโมเดลทั้งหมด + combo ในรูปแบบ OpenAI
```
---
## 📧 สนับสนุน
- **เว็บไซต์**: [9router.com](https://9router.com)
- **GitHub**: [github.com/decolua/9router](https://github.com/decolua/9router)
- **Issues**: [github.com/decolua/9router/issues](https://github.com/decolua/9router/issues)
---
## 👥 ผู้มีส่วนร่วม
ขอขอบคุณผู้มีส่วนร่วมทุกคนที่ช่วยทำให้ 9Router ดียิ่งขึ้น!
[![Contributors](https://contrib.rocks/image?repo=decolua/9router&max=150&columns=15&anon=1)](https://github.com/decolua/9router/graphs/contributors)
---
## 📄 ลิขสิทธิ์
MIT License - ดู [LICENSE](../LICENSE) สำหรับรายละเอียด
---
<div align="center">
<sub>สร้างด้วย ❤️ สำหรับนักพัฒนาที่เขียนโค้ด 24/7</sub>
</div>

View File

@@ -1,15 +1,21 @@
Dưới đây là bản dịch tiếng Việt của tài liệu Markdown, giữ nguyên toàn bộ cú pháp và cấu trúc kỹ thuật.
<div align="center">
<img src="../images/9router.png?1" alt="Bảng điều khiển 9Router" width="800"/>
# 9Router - Free AI Router & Token Saver
# 9Router - Free AI Router
**Không bao giờ ngừng code. Tiết kiệm 20-40% token với RTK + tự động dự phòng sang các mô hình AI MIỄN PHÍ & giá rẻ.**
**Không bao giờ ngừng code. Tự động định tuyến tới các mô hình AI MIỄN PHÍ & giá rẻ với cơ chế dự phòng thông minh.**
**Kết nối tất cả công cụ AI Code (Claude Code, Codex, Cursor, Cline, Copilot, Antigravity...) tới 40+ Nhà cung cấp AI & 100+ Mô hình.**
**Nhà cung cấp AI Miễn cho OpenClaw.**
<p align="center">
<img src="../public/providers/openclaw.png" alt="OpenClaw" width="80"/>
</p>
[![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router)
[![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router)
[![License](https://github.com/decolua/9router/blob/main/LICENSE)](https://github.com/decolua/9router/blob/main/LICENSE)
[![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE)
[🚀 Bắt đầu nhanh](#-quick-start) • [💡 Tính năng](#-key-features) • [📖 Cài đặt](#-setup-guide) • [🌐 Website](https://9router.com)
</div>
@@ -18,21 +24,19 @@
## 🤔 Tại sao chọn 9Router?
**Ngừng lãng phí tiền bạc, token và không bao giờ lo chạm giới hạn (rate limit):**
**Ngừng lãng phí tiền bạc và gặp phải giới hạn:**
- ❌ Hạn mức gói đăng ký hết hạn mỗi tháng mà không dùng hết
- ❌ Giới hạn tốc độ (rate limit) làm gián đoạn công việc mid-coding
-Kết quả của công cụ (git diff, grep, ls...) ngốn rất nhiều token
-Chi phí API đắt đỏ ($20-50/tháng cho từng nhà cung cấp)
- ❌ Phải chuyển đổi thủ công giữa các nhà cung cấp AI
- ❌ Giới hạn tốc độ (rate limit) ngăn bạn giữaừng khi code
-Các API đắt đỏ ($20-50/tháng cho mỗi nhà cung cấp)
-Phải chuyển đổi thủ công giữa các nhà cung cấp
**9Router giải quyết vấn đề này:**
-**RTK Token Saver** - Tự động nén nội dung `tool_result`, tiết kiệm 20-40% token trên mỗi request
-**Tối đa hóa gói đăng ký** - Theo dõi hạn mức, tận dụng triệt để trước khi reset
-**Tự động dự phòng (Auto Fallback)** - Gói đăng ký → Giá rẻ → Miễn phí, không lo downtime
-**Đa tài khoản (Multi-account)** - Xoay vòng (round-robin) các tài khoản cho mỗi nhà cung cấp
-**Phổ quát (Universal)** - Hoạt động với Claude Code, Codex, Cursor, Cline, Antigravity và mọi công cụ CLI
-**Tối đa hóa gói đăng ký** - Theo dõi hạn mức, sử dng từng bit trước khi reset
-**Tự động dự phòng** - Gói đăng ký → Giá rẻ → Miễn phí, thời gian chết bằng không
-**Đa tài khoản** - Vòng tròn (round-robin) các tài khoản của mỗi nhà cung cấp
-**Phổ quát** - Hoạt động với Claude Code, Codex, Gemini CLI, Cursor, Cline, bất kỳ công cụ CLI nào
---
@@ -40,26 +44,25 @@
```
┌─────────────┐
Công cụ │ (Claude Code, Codex, OpenClaw, Cursor, Cline, Antigravity...)
CLI AI
Your CLI │ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...)
Tool
└──────┬──────┘
│ http://localhost:20128/v1
┌─────────────────────────────────────────────
│ 9Router (Smart Router)
│ • RTK Token Saver (nén tool_result token) │
│ • Dịch chuyển định dạng (OpenAI ↔ Claude)
│ • Quota tracking (theo dõi hạn mức)
│ • Tự động làm mới OAuth Token │
└──────┬──────────────────────────────────────┘
┌────────────────────────────────────────┐
│ 9Router (Smart Router) │
│ • Format translation (OpenAI ↔ Claude) │
│ • Quota tracking
│ • Auto token refresh
└──────┬──────────────────────────────────┘
├─→ [Tier 1: GÓI ĐĂNG KÝ] Claude Code, Codex, GitHub Copilot
│ ↓ hết hạn mức quota
├─→ [Tier 2: GIÁ RẺ] GLM ($0.6/1M), MiniMax ($0.2/1M)
↓ chạm ngân sách
└─→ [Tier 3: MIỄN PHÍ] Kiro AI, OpenCode Free, Vertex AI ($300 credits)
├─→ [Tier 1: SUBSCRIPTION] Claude Code, Codex, Gemini CLI
│ ↓ quota exhausted
├─→ [Tier 2: CHEAP] GLM ($0.6/1M), MiniMax ($0.2/1M)
budget limit
└─→ [Tier 3: FREE] iFlow, Qwen, Kiro (unlimited)
Kết quả: Không bao giờ ngừng code, chi phí tối thiểu + tiết kiệm 20-40% token qua RTK
Result: Never stop coding, minimal cost
```
---
@@ -73,26 +76,26 @@ npm install -g 9router
9router
```
🎉 Bảng điều khiển (Dashboard) sẽ tự động mở tại `http://localhost:20128`
🎉 Bảng điều khiển mở tại `http://localhost:20128`
**2. Kết nối nhà cung cấp MIỄN PHÍ (không cần đăng ký):**
Bảng điều khiển → Providers Kết nối **Kiro AI** (~50 credits/tháng miễn phí: Claude 4.5 + GLM-5 + MiniMax) hoặc **OpenCode Free** (không cần auth) → Xong!
Bảng điều khiển → Providers -> Kết nối **ude Code** hoặc **Antigravity** -> Đăng nhập OAuth -> Xong!
**3. Sử dụng trong công cụ CLI của bạn:**
```
Cài đặt Claude Code/Codex/OpenClaw/Cursor/Cline/Antigravity:
Cài đặt Claude Code/Codex/Gemini CLI/OpenClaw/Cursor/Cline:
Endpoint: http://localhost:20128/v1
API Key: [sao chép từ bảng điều khiển]
Model: kr/claude-sonnet-4.5
Model: if/kimi-k2-thinking
```
**Thế là xong!** Bắt đầu code ngay với các mô hình AI MIỄN PHÍ.
**Xong rồi!** Bắt đầu code với các mô hình AI MIỄN PHÍ.
**Phương án khác: chạy từ nguồn (repository này):**
**Phương án khác: chạy từ nguồn (k lưu trữ này):**
Gói kho lưu trữ này là riêng tư (`9router-app`), vì vậy việc chạy từ nguồn/Docker là cách phát triển cục bộ mặc định.
Gói kho lưu trữ này là riêng tư (`9router-app`), vì vậy việc thực thi nguồn/Docker là đường dẫn phát triển cục bộ dự kiến.
```bash
cp .env.example .env
@@ -108,12 +111,11 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run
```
URL mặc định:
- Bảng điều khiển Dashboard: `http://localhost:20128/dashboard`
- Bảng điều khiển: `http://localhost:20128/dashboard`
- API tương thích OpenAI: `http://localhost:20128/v1`
---
## 🎥 Hướng dẫn Video
<div align="center">

View File

@@ -42,26 +42,25 @@
```
┌─────────────┐
│ Your CLI │ (Claude Code, Codex, OpenClaw, Cursor, Cline, Antigravity...)
│ Your CLI │ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...)
│ Tool │
└──────┬──────┘
│ http://localhost:20128/v1
│ http://localhost:201281
┌─────────────────────────────────────────────
│ 9Router (Smart Router)
│ • RTK Token Saver (节省 20-40% Token)
│ • 格式转换 (OpenAI ↔ Claude)
│ • 配额追踪 (Quota tracking)
│ • 自动刷新 OAuth Token │
└──────┬──────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ 9Router (Smart Router) │
│ • Format translation (OpenAI ↔ Claude)
│ • Quota tracking
│ • Auto token refresh
└──────┬──────────────────────────────────┘
├─→ [Tier 1: 订阅] Claude Code, Codex, GitHub Copilot
│ ↓ 配额用尽
├─→ [Tier 2: 低价] GLM ($0.6/1M), MiniMax ($0.2/1M)
│ ↓ 触及预算上限
└─→ [Tier 3: 免费] Kiro AI, OpenCode Free, Vertex AI ($300 credits)
├─→ [Tier 1: SUBSCRIPTION] Claude Code, Codex, Gemini CLI
│ ↓ quota exhausted
├─→ [Tier 2: CHEAP] GLM ($0.6/1M), MiniMax ($0.2/1M)
│ ↓ budget limit
└─→ [Tier 3: FREE] iFlow, Qwen, Kiro (unlimited)
结果:永不停歇的编程体验,最低成本 + 通过 RTK 节省 20-40% Token
Result: Never stop coding, minimal cost
```
---

View File

@@ -13,14 +13,7 @@ const proxyClientMaxBodySize = process.env.NINEROUTER_PROXY_CLIENT_MAX_BODY_SIZE
const nextConfig = {
distDir: process.env.NEXT_DIST_DIR || ".next",
output: "standalone",
// `open` must stay external. It derives its own directory from `import.meta.url`, and
// webpack replaces that with the absolute path of the BUILD machine as a string literal.
// A release built on macOS therefore ships `file:///Users/.../open/index.js`, which
// `fileURLToPath` rejects on Windows ("File URL path must be absolute" — no drive
// letter). That throw happens at module scope, so every consumer of `open` dies on
// import — including xAI/Grok token refresh, which loads the OAuth service that imports
// it. Keeping it external preserves the real `import.meta.url` at runtime.
serverExternalPackages: ["better-sqlite3", "sql.js", "node:sqlite", "bun:sqlite", "open"],
serverExternalPackages: ["better-sqlite3", "sql.js", "node:sqlite", "bun:sqlite"],
turbopack: {
root: tracingRoot
},
@@ -37,8 +30,6 @@ const nextConfig = {
proxyClientMaxBodySize,
// Cache fetch responses across HMR refreshes for faster dev reloads.
serverComponentsHmrCache: true,
// Tree-shake heavy barrel imports to cut compile + bundle size
optimizePackageImports: ["@xyflow/react", "@dnd-kit/core", "@dnd-kit/sortable", "material-symbols", "marked"],
},
webpack: (config, { isServer }) => {
// Ignore fs/path modules in browser bundle
@@ -75,14 +66,6 @@ const nextConfig = {
source: "/responses",
destination: "/api/v1/responses"
},
{
source: "/v1beta/:path*",
destination: "/api/v1beta/:path*"
},
{
source: "/v1beta",
destination: "/api/v1beta"
},
{
source: "/v1/:path*",
destination: "/api/v1/:path*"

View File

@@ -1,7 +1,5 @@
import { platform, arch, hostname } from "os";
import { platform, arch } from "os";
import { PROVIDERS, PROVIDER_OAUTH } from "./providers.js";
import { ANTIGRAVITY_IDE_USER_AGENT } from "../providers/shared.js";
import { createRequire } from "module";
// === Gemini CLI === derive từ registry gemini-cli.transport
export const GEMINI_CLI_VERSION = PROVIDERS["gemini-cli"]?.cliVersion;
@@ -61,7 +59,7 @@ export function getPlatformEnum() {
}
export function getPlatformUserAgent() {
return ANTIGRAVITY_IDE_USER_AGENT;
return `antigravity/1.104.0 ${platform()}/${arch()}`;
}
export const CLIENT_METADATA = {
@@ -131,22 +129,13 @@ export const AG_DEFAULT_TOOLS = new Set([
// Antigravity chat/stream headers
export const ANTIGRAVITY_HEADERS = {
"User-Agent": ANTIGRAVITY_IDE_USER_AGENT
"User-Agent": `antigravity/1.107.0 ${platform()}/${arch()}`
};
// Cloud Code Assist API endpoints differ by client ecosystem.
// Cloud Code Assist API
export const CLOUD_CODE_API = {
"gemini-cli": {
loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
},
// Project discovery (loadCodeAssist/onboardUser) stays on PROD — the daily host
// rejects these auth/onboarding calls. Only chat traffic uses the daily host
// (see transport.apiEndpoint in registry/antigravity.js, set to bypass prod 429).
antigravity: {
loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
},
loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
};
export const LOAD_CODE_ASSIST_HEADERS = {
@@ -156,13 +145,6 @@ export const LOAD_CODE_ASSIST_HEADERS = {
"Client-Metadata": JSON.stringify({ ideType: IDE_TYPE.ANTIGRAVITY, platform: getPlatformEnum(), pluginType: PLUGIN_TYPE.GEMINI }),
};
// Real Antigravity IDE doesn't send X-Goog-Api-Client/Client-Metadata on loadCodeAssist/onboardUser —
// Google's backend fingerprints those and silently refuses to provision a cloudaicompanionProject.
export const ANTIGRAVITY_LOAD_CODE_ASSIST_HEADERS = {
"Content-Type": "application/json",
"User-Agent": ANTIGRAVITY_IDE_USER_AGENT,
};
export const LOAD_CODE_ASSIST_METADATA = {
ideType: IDE_TYPE.ANTIGRAVITY,
platform: getPlatformEnum(),
@@ -183,48 +165,17 @@ export const OAUTH_ENDPOINTS = {
google: { token: "https://oauth2.googleapis.com/token", auth: "https://accounts.google.com/o/oauth2/auth" },
openai: { token: PROVIDER_OAUTH["codex"]?.tokenUrl, auth: PROVIDER_OAUTH["codex"]?.authorizeUrl },
anthropic: { token: PROVIDER_OAUTH["claude"]?.tokenUrl, auth: "https://api.anthropic.com/v1/oauth/authorize" }, // ≠ claude.authorizeUrl (claude.ai login) — keep
qwen: { token: PROVIDER_OAUTH["qwen"]?.tokenUrl, auth: PROVIDER_OAUTH["qwen"]?.deviceCodeUrl },
iflow: { token: PROVIDER_OAUTH["iflow"]?.tokenUrl, auth: PROVIDER_OAUTH["iflow"]?.authorizeUrl },
github: { token: PROVIDER_OAUTH["github"]?.tokenUrl, auth: PROVIDER_OAUTH["github"]?.authorizeUrl, deviceCode: PROVIDER_OAUTH["github"]?.deviceCodeUrl },
};
let _appVersion;
function getAppPackageVersion() {
if (_appVersion) return _appVersion;
try {
const require = createRequire(import.meta.url);
_appVersion = require("../../package.json").version || "0.0.0";
} catch {
_appVersion = process.env.npm_package_version || "0.0.0";
}
return _appVersion;
}
// Kimi Code OAuth / API headers (CLIProxyAPI internal/auth/kimi commonHeaders parity).
// deviceId must stay stable per connection for the whole OAuth session.
export function buildKimiHeaders(deviceId) {
const osName = platform();
const architecture = arch();
let deviceModel = `${osName} ${architecture}`;
if (osName === "darwin") deviceModel = `macOS ${architecture}`;
else if (osName === "win32") deviceModel = `Windows ${architecture}`;
else if (osName === "linux") deviceModel = `Linux ${architecture}`;
let deviceName = "unknown";
try {
deviceName = hostname() || "unknown";
} catch {
deviceName = "unknown";
}
const resolvedId = (typeof deviceId === "string" && deviceId.trim())
? deviceId.trim()
: `kimi-${Date.now()}`;
// Generate Kimi OAuth custom headers
export function buildKimiHeaders() {
return {
"X-Msh-Platform": "9router",
"X-Msh-Version": getAppPackageVersion(),
"X-Msh-Device-Name": deviceName,
"X-Msh-Device-Model": deviceModel,
"X-Msh-Device-Id": resolvedId,
"X-Msh-Version": "2.1.2",
"X-Msh-Device-Model": typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown",
"X-Msh-Device-Id": `kimi-${Date.now()}`
};
}

View File

@@ -1,10 +0,0 @@
export const GROK_CLI_VERSION = "0.2.99";
export const GROK_CLI_MODEL = "grok-build";
export const GROK_CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
export const GROK_CLI_CLIENT_IDENTIFIER = "grok-shell";
export const GROK_CLI_USER_AGENT = `grok-shell/${GROK_CLI_VERSION} (linux; x86_64)`;
export function supportsGrokCliReasoningEffort(model) {
// ponytail: unknown models omit effort until live metadata reaches dispatch.
return /^grok-4\.5(?:$|-)/.test(String(model || ""));
}

View File

@@ -8,24 +8,18 @@
* - `-agentic` model suffix detection + chunked-write system prompt
* - reasoning / thinking trigger detection (Anthropic-Beta header,
* Claude `thinking`, OpenAI `reasoning_effort`, AMP/Cursor magic tag)
* - schema-specific native effort fields for supported GPT and Claude models
* - legacy `<thinking_mode>` system-prompt injection for other models
* - the `<thinking_mode>enabled</thinking_mode>` system-prompt injection
* that turns Kiro reasoning on
*
* Kiro upstream does not advertise `-agentic` model IDs; they are a 9router
* fiction. The suffix is stripped before the request leaves this process.
*/
import { extractThinking, parseSuffix } from "../translator/concerns/thinkingUnified.js";
import { extractThinking } from "../translator/concerns/thinkingUnified.js";
import { effortToBudget } from "../translator/concerns/thinking.js";
export const KIRO_AGENTIC_SUFFIX = "-agentic";
export const KIRO_THINKING_SUFFIX = "-thinking";
export const KIRO_TOOL_NAME_MAX_LENGTH = 64;
export const KIRO_TOOL_DESCRIPTION_MAX_LENGTH = 10237;
export const KIRO_TOOL_ID_MAX_LENGTH = 64;
export const KIRO_CODEWHISPERER_TARGET =
"AmazonCodeWhispererStreamingService.GenerateAssistantResponse";
export const KIRO_ENDPOINT_FALLBACK_STATUSES = new Set([401, 403, 404]);
// Public default CodeWhisperer profile ARNs (us-east-1), keyed by auth method.
// Used when an account cannot resolve its own profileArn. Builder ID and social
@@ -46,39 +40,6 @@ export function resolveDefaultProfileArn(authMethod) {
export const KIRO_THINKING_BUDGET_DEFAULT = 16000;
/**
* Resolve a Kiro model after consuming the generic model(level) suffix.
* The suffix is a 9router request override, not part of Kiro's upstream model id.
*/
export function resolveKiroModelIntent(model) {
const { cleanModel, override } = parseSuffix(model);
return {
model: cleanModel,
...resolveKiroModel(cleanModel),
thinkingOverride: override,
};
}
/** Apply a parsed model(level) override without mutating the caller's body. */
export function applyKiroThinkingOverride(body, override) {
if (!override) return body;
const next = { ...body };
if (override.mode === "budget") {
delete next.output_config;
delete next.reasoning_effort;
delete next.reasoning;
next.thinking = { type: "enabled", budget_tokens: override.budget };
return next;
}
next.output_config = {
...(body.output_config || {}),
effort: override.mode === "level" ? override.level : override.mode,
};
return next;
}
export const KIRO_AGENTIC_SYSTEM_PROMPT = `
# CRITICAL: CHUNKED WRITE PROTOCOL (MANDATORY)
@@ -148,7 +109,6 @@ export function resolveKiroThinkingBudget(body, headers, model) {
const cfg = extractThinking(body);
if (cfg) {
if (cfg.mode === "none") return null;
if (cfg.mode === "level" && cfg.level === "disabled") return null;
if (cfg.mode === "budget") return cfg.budget;
if (cfg.mode === "level") return effortToBudget(cfg.level) ?? KIRO_THINKING_BUDGET_DEFAULT;
return KIRO_THINKING_BUDGET_DEFAULT;
@@ -171,86 +131,6 @@ export function resolveKiroThinkingBudget(body, headers, model) {
return null;
}
export function extractKiroEffortLevel(body) {
const effort =
body?.output_config?.effort ??
body?.reasoning_effort ??
(typeof body?.reasoning === "object" ? body.reasoning?.effort : null);
if (typeof effort !== "string") return null;
const normalized = effort.toLowerCase();
if (normalized === "none" || normalized === "off" || normalized === "disabled") return null;
if (normalized === "xhigh" || normalized === "max") return "high";
if (["low", "medium", "high"].includes(normalized)) return normalized;
return null;
}
function extractKiroGptEffortLevel(body) {
const effort =
body?.output_config?.effort ??
body?.reasoning_effort ??
(typeof body?.reasoning === "object" ? body.reasoning?.effort : null);
if (typeof effort !== "string") return null;
const normalized = effort.toLowerCase();
if (normalized === "max") return "xhigh";
// Kiro CLI does not advertise an explicit GPT "none" wire value; omit it.
if (["low", "medium", "high", "xhigh"].includes(normalized)) {
return normalized;
}
return null;
}
export function buildKiroAdditionalModelRequestFields(body, effortPath = "output_config") {
const effort = effortPath === "reasoning"
? extractKiroGptEffortLevel(body)
: extractKiroEffortLevel(body);
if (!effort) return undefined;
if (effortPath === "reasoning") {
// Mirrors Kiro CLI/KAS buildEffortRequestFields("reasoning") for GPT.
return { reasoning: { effort } };
}
// Mirrors Kiro CLI/KAS buildEffortRequestFields("output_config").
return {
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort },
};
}
export function resolveKiroEffortPath(model) {
if (typeof model !== "string") return null;
const normalized = model.toLowerCase().replace(/-/g, ".");
if (/(?:^|[/.])gpt[/.]5[/.]6(?:[/.]|$)/.test(normalized)) {
return "reasoning";
}
if (!normalized.includes("claude")) return null;
const match = normalized.match(/(?:^|[/.])claude(?:[/.][a-z]+)*[/.](\d+)(?:[/.](\d+))?(?:[/.]|$)/);
if (!match) return null;
const [, majorText, minorText] = match;
const major = Number(majorText);
const minor = minorText === undefined ? null : Number(minorText);
const dateSuffixMinor = minor !== null && minor >= 1000;
// Kiro rejected additionalModelRequestFields on legacy 4.5 models in live smoke.
// Default future Claude/Kiro models to supported so new model releases do not
// need a code allowlist update.
return major < 4 || (major === 4 && (minor === null || minor <= 5 || dateSuffixMinor))
? null
: "output_config";
}
export function supportsKiroAdditionalModelRequestFields(model) {
return resolveKiroEffortPath(model) !== null;
}
export function usesKiroNativeGptEffort(body, model) {
return resolveKiroEffortPath(model) === "reasoning"
&& extractKiroGptEffortLevel(body) !== null;
}
export function buildKiroAdditionalModelRequestFieldsForModel(body, model) {
const effortPath = resolveKiroEffortPath(model);
if (!effortPath) return undefined;
return buildKiroAdditionalModelRequestFields(body, effortPath);
}
/**
* Detect whether an inbound request is asking for reasoning / thinking output.
* Thin wrapper over resolveKiroThinkingBudget (single source of truth).

View File

@@ -2,8 +2,9 @@ import { PROVIDERS } from "./providers.js";
import REGISTRY from "../providers/registry/index.js";
// PROVIDER_MODELS now built from providers/registry (transport + models co-located)
import { PROVIDER_MODELS } from "../providers/index.js";
import { modelQuotaFamily, modelStrip, modelTargetFormat, modelSupportedFormats, normalizeModelId } from "../providers/models/schema.js";
import { modelQuotaFamily, modelStrip, modelTargetFormat } from "../providers/models/schema.js";
import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js";
export { PROVIDER_MODELS };
@@ -17,82 +18,46 @@ export function getDefaultModel(aliasOrId) {
return models?.[0]?.id || null;
}
// Providers whose registry uses dots in version numbers (e.g. "claude-sonnet-4.5").
// For these, we tolerate clients sending dashes ("claude-sonnet-4-5") by normalizing
// digit-hyphen-digit to digit-dot-digit before lookup. Other providers are left untouched.
const DOT_VERSION_PROVIDERS = new Set(["kr", "kiro"]);
// Find a registry entry by id. For Kiro models, tolerates dash/dot version separators
// ("claude-sonnet-4-5" ~= "claude-sonnet-4.5"). Other providers use exact match only.
function findModel(models, modelId, aliasOrId) {
if (!models) return undefined;
const found = models.find(m => m.id === modelId);
if (found) return found;
if (!DOT_VERSION_PROVIDERS.has(aliasOrId)) return undefined;
const normalized = normalizeModelId(modelId);
if (normalized === modelId) return undefined;
return models.find(m => m.id === normalized);
}
export function isValidModel(aliasOrId, modelId, passthroughProviders = new Set()) {
if (passthroughProviders.has(aliasOrId)) return true;
const models = PROVIDER_MODELS[aliasOrId];
if (!models) return false;
return !!findModel(models, modelId, aliasOrId);
return models.some(m => m.id === modelId);
}
export function findModelName(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId];
if (!models) return modelId;
const found = findModel(models, modelId, aliasOrId);
const found = models.find(m => m.id === modelId);
return found?.name || modelId;
}
export function getModelTargetFormat(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId];
if (!models) return null;
return modelTargetFormat(findModel(models, modelId, aliasOrId));
}
// Declared upstream formats for a model (registry `supportedFormats`). Drives the
// per-model guard on the sourceFormat-matched transport; null when undeclared.
export function getModelSupportedFormats(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId];
if (!models) return null;
return modelSupportedFormats(findModel(models, modelId, aliasOrId));
return modelTargetFormat(models.find(m => m.id === modelId));
}
export function getModelType(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId];
if (!models) return null;
const found = findModel(models, modelId, aliasOrId);
const found = models.find(m => m.id === modelId);
return found?.kind || found?.type || null;
}
export function getModelUpstreamId(aliasOrId, modelId) {
// Split off thinking suffix "(level)" so lookup hits the base id; re-append it to
// the result so downstream applyThinking still sees the suffix (body.model is stripped separately).
const sufMatch = typeof modelId === "string" ? modelId.match(/\([^()]+\)\s*$/) : null;
const suffix = sufMatch ? sufMatch[0] : "";
const baseId = suffix ? modelId.slice(0, sufMatch.index).trim() : modelId;
const models = PROVIDER_MODELS[aliasOrId];
const found = findModel(models, baseId, aliasOrId);
const resolvedId = found?.upstreamModelId || found?.id;
if (resolvedId) {
const presetMatch = resolvedId.match(/\([^()]+\)\s*$/);
const presetSuffix = presetMatch?.[0] || "";
const resolvedBase = presetSuffix ? resolvedId.slice(0, presetMatch.index).trim() : resolvedId;
return resolvedBase + (suffix || presetSuffix);
const found = models?.find(m => m.id === modelId);
if (found?.upstreamModelId) return found.upstreamModelId;
if (aliasOrId === "cx" && typeof modelId === "string" && modelId.endsWith(CODEX_REVIEW_SUFFIX)) {
return modelId.slice(0, -CODEX_REVIEW_SUFFIX.length);
}
if (aliasOrId === "cx" && typeof baseId === "string" && baseId.endsWith(CODEX_REVIEW_SUFFIX)) {
return baseId.slice(0, -CODEX_REVIEW_SUFFIX.length) + suffix;
}
return baseId + suffix;
return modelId;
}
export function getModelQuotaFamily(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId];
return modelQuotaFamily(findModel(models, modelId, aliasOrId));
return modelQuotaFamily(models?.find(m => m.id === modelId));
}
// OAuth short aliases — derived from registry `alias` (single source). everything else: alias = id.
@@ -114,5 +79,5 @@ export function getModelsByProviderId(providerId) {
// Get strip list for a model entry (explicit opt-in only)
// Returns array of content types to strip, e.g. ["image", "audio"]
export function getModelStrip(alias, modelId) {
return modelStrip(findModel(PROVIDER_MODELS[alias], modelId, alias));
return modelStrip(PROVIDER_MODELS[alias]?.find(m => m.id === modelId));
}

View File

@@ -39,15 +39,6 @@ function envMs(name, def) {
return Number.isFinite(n) && n > 0 ? n : def;
}
function envUrl(name, def) {
const raw = process.env[name]?.trim();
return raw || def;
}
// SearXNG endpoint used by the unauthenticated web-search provider.
// Configure this for a separate Docker service or remote SearXNG instance.
export const SEARXNG_URL = envUrl("SEARXNG_URL", "http://localhost:8888/search");
// Inter-chunk stall timeout (once tokens are flowing). Generous headroom so
// slow reasoning models aren't aborted mid-stream. Env: STREAM_STALL_TIMEOUT_MS.
export const STREAM_STALL_TIMEOUT_MS = envMs("STREAM_STALL_TIMEOUT_MS", 360 * 1000);
@@ -58,15 +49,10 @@ export const STREAM_FIRST_CHUNK_TIMEOUT_MS = envMs("STREAM_FIRST_CHUNK_TIMEOUT_M
// Fetch connect timeout: abort if upstream doesn't return response headers within this duration
export const FETCH_CONNECT_TIMEOUT_MS = envMs("FETCH_CONNECT_TIMEOUT_MS", 60 * 1000);
// Gemini native TTS fetch timeout: abort if Google does not return response headers in time.
export const GEMINI_NATIVE_TTS_FETCH_TIMEOUT_MS = envMs("GEMINI_NATIVE_TTS_FETCH_TIMEOUT_MS", 45 * 1000);
// Default token limits
export const DEFAULT_MAX_TOKENS = 64000;
export const DEFAULT_MIN_TOKENS = 32000;
export const TOKEN_SAVER_HEADER = "x-9router-token-saver";
// Retry config for 429 responses (legacy - kept for backward compatibility)
export const RETRY_CONFIG = {
maxAttempts: 2,

View File

@@ -33,21 +33,6 @@ const GEMINI_VOICES = [
"Vindemiatrix", "Sadachbia", "Sadaltager", "Sulafat",
].map((id) => ({ id, name: id, type: "tts" }));
// Xiaomi MiMo preset voices (from https://mimo.mi.com/docs/zh-CN/quick-start/usage-guide/audio/speech-synthesis-v2.5).
// Voice id is passed via `audio.voice`; `mimo_default` = default (冰糖 on CN cluster, Mia elsewhere).
// Voices are language-independent — the spoken language is a separate hint, not bound to the voice.
const MIMO_VOICES = [
{ id: "mimo_default", name: "mimo_default" },
{ id: "冰糖", name: "冰糖" },
{ id: "茉莉", name: "茉莉" },
{ id: "苏打", name: "苏打" },
{ id: "白桦", name: "白桦" },
{ id: "Mia", name: "Mia" },
{ id: "Chloe", name: "Chloe" },
{ id: "Milo", name: "Milo" },
{ id: "Dean", name: "Dean" },
].map((v) => ({ type: "tts", ...v }));
// ── TTS Config (config-driven, single source of truth) ─────────────────────
export const TTS_MODELS_CONFIG = {
openai: {
@@ -111,25 +96,15 @@ export const TTS_MODELS_CONFIG = {
},
gemini: {
models: [
{ id: "gemini-3.1-flash-tts-preview", name: "Gemini 3.1 Flash TTS", type: "tts" },
{ id: "gemini-2.5-flash-preview-tts", name: "Gemini 2.5 Flash TTS", type: "tts" },
{ id: "gemini-2.5-pro-preview-tts", name: "Gemini 2.5 Pro TTS", type: "tts" },
],
voices: {
"gemini-3.1-flash-tts-preview": GEMINI_VOICES,
"gemini-2.5-flash-preview-tts": GEMINI_VOICES,
"gemini-2.5-pro-preview-tts": GEMINI_VOICES,
},
allVoices: GEMINI_VOICES,
},
"xiaomi-mimo": {
models: [
{ id: "mimo-v2.5-tts", name: "MiMo V2.5 TTS", type: "tts" },
],
voices: {
"mimo-v2.5-tts": MIMO_VOICES,
},
},
};
// ── Helper: get voices for a specific model ────────────────────────────────

View File

@@ -1,12 +1,11 @@
import crypto from "crypto";
import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js";
import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX } from "../config/appConstants.js";
import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, INTERNAL_REQUEST_HEADER, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX } from "../config/appConstants.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { resolveSessionId } from "../utils/sessionManager.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { cleanJSONSchemaForAntigravity } from "../translator/formats/gemini.js";
import { DEFAULT_THINKING_AG_SIGNATURE } from "../config/defaultThinkingSignature.js";
// Sanitize function name: Gemini requires [a-zA-Z_][a-zA-Z0-9_.:\-]{0,63}
function sanitizeFunctionName(name) {
@@ -17,26 +16,7 @@ function sanitizeFunctionName(name) {
}
const MAX_RETRY_AFTER_MS = 10000;
const ANTIGRAVITY_TRANSIENT_RETRY_MAX_MS = 15000;
const MAX_ANTIGRAVITY_OUTPUT_TOKENS = 64000;
const ANTIGRAVITY_IDE_REQUEST_ID_RE = /^agent\/[^/]+\/\d+\/[^/]+\/\d+$/;
const ANTIGRAVITY_TRANSIENT_ERROR_PATTERNS = [
/high\s+traffic/i,
/agent\s+(execution\s+)?terminated\s+due\s+to\s+error/i,
/capacity/i,
/temporarily\s+unavailable/i,
/timeout/i,
/stream\s+(ended|closed|terminated|interrupted)/i,
/empty\s+response/i,
];
const ANTIGRAVITY_TRANSIENT_STATUSES = new Set([
HTTP_STATUS.SERVER_ERROR,
HTTP_STATUS.BAD_GATEWAY,
HTTP_STATUS.SERVICE_UNAVAILABLE,
HTTP_STATUS.GATEWAY_TIMEOUT,
]);
const MAX_ANTIGRAVITY_OUTPUT_TOKENS = 16384;
// Fields Google generateContent rejects (Claude/OpenAI/Qwen thinking fields set at body root by thinkingUnified.js)
const ANTIGRAVITY_REQUEST_BLACKLIST = [
@@ -88,27 +68,6 @@ function parseImageConfig(model) {
return config;
}
function uuidFromSeed(seed) {
const bytes = crypto.createHash("sha256").update(String(seed || "antigravity")).digest().subarray(0, 16);
bytes[6] = (bytes[6] & 0x0f) | 0x50;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = bytes.toString("hex");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
function buildIdeRequestId({ body, request, credentials, model, requestType }) {
if (ANTIGRAVITY_IDE_REQUEST_ID_RE.test(body?.requestId || "")) {
return body.requestId;
}
const sessionId = request?.sessionId || body?.request?.sessionId || credentials?._clientSessionId || credentials?.connectionId || credentials?.email || "anonymous";
const conversationId = uuidFromSeed(`antigravity:conversation:${sessionId}`);
const trajectoryId = uuidFromSeed(`antigravity:trajectory:${sessionId}:${model}:${requestType}`);
const contentCount = Array.isArray(request?.contents) ? request.contents.length : 1;
const step = Math.max(1, contentCount * 2 - 1);
return `agent/${conversationId}/${Date.now()}/${trajectoryId}/${step}`;
}
export class AntigravityExecutor extends BaseExecutor {
constructor() {
super("antigravity", PROVIDERS.antigravity);
@@ -126,20 +85,20 @@ export class AntigravityExecutor extends BaseExecutor {
// sessionId comes from transformRequest output; base.execute runs transformRequest before
// buildHeaders, so we read it from instance state cached there (fallback: explicit arg).
buildHeaders(credentials, stream = true, sessionId = null) {
const sid = sessionId || this._lastSessionId;
return {
"Content-Type": "application/json",
"Authorization": `Bearer ${credentials.accessToken}`,
"User-Agent": this.config.headers?.["User-Agent"] || ANTIGRAVITY_HEADERS["User-Agent"],
[INTERNAL_REQUEST_HEADER.name]: INTERNAL_REQUEST_HEADER.value,
...(sid && { "X-Machine-Session-Id": sid }),
"Accept": stream ? "text/event-stream" : "application/json"
};
}
transformRequest(model, body, stream, credentials) {
const projectId = credentials?.projectId || this.generateProjectId();
// OpenAI clients may include stream_options even for non-streaming calls.
// Google generateContent rejects that combination before processing the request.
if (stream !== true) delete body.stream_options;
// ─── Image generation: completely different request structure ───
if (isImageModel(model)) {
const imageConfig = parseImageConfig(model);
@@ -164,26 +123,25 @@ export class AntigravityExecutor extends BaseExecutor {
});
this._lastSessionId = sessionId;
const request = {
contents,
generationConfig: {
temperature: 1.0,
topP: 0.95,
topK: 40,
maxOutputTokens: 8192,
imageConfig,
},
sessionId,
// No tools, no systemInstruction, no safetySettings for image gen
};
return {
project: projectId,
model: cleanModel,
userAgent: "antigravity",
requestType: "image_gen",
requestId: buildIdeRequestId({ body, request, credentials, model: cleanModel, requestType: "image_gen" }),
request,
requestId: `agent-${crypto.randomUUID()}`,
request: {
contents,
generationConfig: {
temperature: 1.0,
topP: 0.95,
topK: 40,
maxOutputTokens: 8192,
imageConfig,
},
sessionId,
// No tools, no systemInstruction, no safetySettings for image gen
},
};
}
@@ -201,19 +159,8 @@ export class AntigravityExecutor extends BaseExecutor {
if (p.thoughtSignature && !p.functionCall && !p.text) return false;
return true;
});
// Gemini 3+ rejects functionCall parts without thoughtSignature. Clients (Claude Code, IDE)
// don't persist thoughtSignature in their history, so backfill the default signature on any
// functionCall part that arrives without one.
const needsBackfill = parts?.some(p => p.functionCall && !p.thoughtSignature) ?? false;
if (role !== c.role || parts?.length !== c.parts?.length || needsBackfill) {
return {
...c, role,
parts: needsBackfill
? parts.map(p => (p.functionCall && !p.thoughtSignature)
? { ...p, thoughtSignature: DEFAULT_THINKING_AG_SIGNATURE }
: p)
: parts,
};
if (role !== c.role || parts?.length !== c.parts?.length) {
return { ...c, role, parts };
}
return c;
});
@@ -223,40 +170,21 @@ export class AntigravityExecutor extends BaseExecutor {
if (tools && tools.length > 0) {
// Merge all groups into a single functionDeclarations group (Gemini expects 1 group)
const seenToolNames = new Set();
const allDeclarations = [];
for (const group of tools) {
for (const fn of group.functionDeclarations || []) {
const name = sanitizeFunctionName(fn.name);
if (seenToolNames.has(name)) continue;
seenToolNames.add(name);
allDeclarations.push({
...fn,
name,
parameters: fn.parameters
? cleanJSONSchemaForAntigravity(structuredClone(fn.parameters))
: { type: "object", properties: { reason: { type: "string", description: "Brief explanation" } }, required: ["reason"] }
});
}
}
const allDeclarations = tools.flatMap(group =>
(group.functionDeclarations || []).map(fn => ({
...fn,
name: sanitizeFunctionName(fn.name),
parameters: fn.parameters
? cleanJSONSchemaForAntigravity(structuredClone(fn.parameters))
: { type: "object", properties: { reason: { type: "string", description: "Brief explanation" } }, required: ["reason"] }
}))
);
tools = allDeclarations.length > 0 ? [{ functionDeclarations: allDeclarations }] : [];
}
// Strip tools/toolConfig (handled separately) and blacklisted fields that Google rejects
const { tools: _originalTools, toolConfig: _originalToolConfig, ...requestWithoutTools } = body.request || {};
stripBlacklisted(requestWithoutTools);
// Rewrite competitive system prompts (e.g. Zed IDE's Claude prompt) to prevent Antigravity from
// flagging the request and immediately blocking it with a 429 Quota Exhausted response.
if (requestWithoutTools.systemInstruction?.parts) {
const oldText = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
for (const part of requestWithoutTools.systemInstruction.parts) {
if (typeof part.text === "string" && part.text.includes(oldText)) {
part.text = part.text.split(oldText).join("");
}
}
}
const generationConfig = { ...(requestWithoutTools.generationConfig || {}) };
if (generationConfig.maxOutputTokens > MAX_ANTIGRAVITY_OUTPUT_TOKENS) {
generationConfig.maxOutputTokens = MAX_ANTIGRAVITY_OUTPUT_TOKENS;
@@ -280,10 +208,10 @@ export class AntigravityExecutor extends BaseExecutor {
return {
...body,
project: projectId,
model: body.model || model,
model: model,
userAgent: "antigravity",
requestType: "agent",
requestId: buildIdeRequestId({ body, request: transformedRequest, credentials, model, requestType: "agent" }),
requestId: `agent-${crypto.randomUUID()}`,
request: transformedRequest
};
}
@@ -377,49 +305,23 @@ export class AntigravityExecutor extends BaseExecutor {
return totalMs > 0 ? totalMs : null;
}
extractErrorMessage(errorJson, bodyText = "") {
return [
errorJson?.error?.message,
errorJson?.message,
errorJson?.error,
bodyText,
].filter(Boolean).map(v => typeof v === "string" ? v : JSON.stringify(v)).join("\n");
}
isTransientAntigravityError(status, message) {
if (status === HTTP_STATUS.RATE_LIMITED) return true;
if (ANTIGRAVITY_TRANSIENT_STATUSES.has(status)) return true;
return ANTIGRAVITY_TRANSIENT_ERROR_PATTERNS.some(pattern => pattern.test(message || ""));
}
// Hook called by BaseExecutor.tryRetry: derive delay from Retry-After (header → body),
// cap at MAX_RETRY_AFTER_MS, else retry transient Antigravity failures with backoff.
// Return false to veto (fallback URL / final error).
// cap at MAX_RETRY_AFTER_MS, else exponential backoff for 429. Return false to veto (fallback URL).
async computeRetryDelay(response, attempt) {
let bodyText = "";
let errorJson = null;
let retryMs = this.parseRetryHeaders(response.headers);
try {
bodyText = await response.clone().text();
errorJson = bodyText ? JSON.parse(bodyText) : null;
} catch {
// ignore parse errors → fall through to status/message based retry
}
const errorMessage = this.extractErrorMessage(errorJson, bodyText);
if (!retryMs) {
retryMs = this.parseRetryFromErrorMessage(errorMessage);
try {
const errorJson = JSON.parse(await response.clone().text());
retryMs = this.parseRetryFromErrorMessage(errorJson?.error?.message || errorJson?.message || "");
} catch {
// ignore parse errors → fall through to backoff
}
}
if (retryMs) return retryMs <= MAX_RETRY_AFTER_MS ? retryMs : false;
if (!this.isTransientAntigravityError(response.status, errorMessage)) return false;
const cap = response.status === HTTP_STATUS.RATE_LIMITED
? MAX_RETRY_AFTER_MS
: ANTIGRAVITY_TRANSIENT_RETRY_MAX_MS;
return Math.min(1000 * (2 ** attempt), cap); // exponential backoff
if (response.status === HTTP_STATUS.RATE_LIMITED) {
return Math.min(1000 * (2 ** attempt), MAX_RETRY_AFTER_MS); // exponential backoff
}
return false;
}
/**

View File

@@ -3,7 +3,6 @@ import { shouldRefreshCredentials } from "../services/oauthCredentialManager.js"
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { dbg } from "../utils/debugLog.js";
import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE } from "../providers/shared.js";
import { resolveOpenAICompatibleApiType } from "../services/provider.js";
/**
* BaseExecutor - Base class for provider executors
@@ -31,7 +30,7 @@ export class BaseExecutor {
if (this.provider?.startsWith?.("openai-compatible-")) {
const baseUrl = credentials?.providerSpecificData?.baseUrl || OPENAI_COMPAT_BASE;
const normalized = baseUrl.replace(/\/$/, "");
const path = resolveOpenAICompatibleApiType(this.provider, credentials) === "responses" ? "/responses" : "/chat/completions";
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
return `${normalized}${path}`;
}
if (this.provider?.startsWith?.("anthropic-compatible-")) {
@@ -126,8 +125,20 @@ export class BaseExecutor {
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
const url = this.buildUrl(model, stream, urlIndex, credentials);
const transformedBody = this.transformRequest(model, body, stream, credentials);
const headers = this.buildHeaders(credentials, stream, url, model);
// Merge extra body params from provider-specific data (request body takes priority)
let mergedBody = this.transformRequest(model, body, stream, credentials);
const extraBodyParams = credentials?.providerSpecificData?.bodyParams;
if (extraBodyParams && typeof extraBodyParams === "object" && !Array.isArray(extraBodyParams)) {
mergedBody = { ...extraBodyParams, ...mergedBody };
}
// Merge extra header params from provider-specific data (request headers take priority)
let headers = this.buildHeaders(credentials, stream);
const extraHeaderParams = credentials?.providerSpecificData?.headerParams;
if (extraHeaderParams && typeof extraHeaderParams === "object" && !Array.isArray(extraHeaderParams)) {
headers = { ...extraHeaderParams, ...headers };
}
if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0;
@@ -138,7 +149,7 @@ export class BaseExecutor {
const mergedSignal = signal ? AbortSignal.any([signal, connectCtrl.signal]) : connectCtrl.signal;
try {
const bodyStr = JSON.stringify(transformedBody);
const bodyStr = JSON.stringify(mergedBody);
const fetchT0 = Date.now();
dbg("FETCH", `${this.provider.toUpperCase()}${url} | body=${bodyStr.length}B | connectTimeout=${timeoutMs}ms`);
const response = await proxyAwareFetch(url, {
@@ -160,7 +171,7 @@ export class BaseExecutor {
continue;
}
return { response, url, headers, transformedBody };
return { response, url, headers, transformedBody: mergedBody };
} catch (error) {
clearTimeout(connectTimer);
lastError = error;

View File

@@ -18,35 +18,6 @@ export class CodeBuddyExecutor extends DefaultExecutor {
const transformed = super.transformRequest(model, body, stream, credentials);
transformed.stream = true;
// Tencent's content filter flags CLI agent system prompts ("You are Claude
// Code, Anthropic's official CLI...") as prompt injection / sensitive content
// and rejects the whole request. Detect agent system prompts (length catch-all
// + identity-marker regex) and replace them with a neutral one, while leaving
// legitimate user system prompts untouched. content may be a string or typed
// blocks ([{type:"text",text}]) depending on the incoming client format, so
// flatten before matching and preserve the original shape on replacement.
const NEUTRAL_PROMPT = "You are a helpful AI assistant that helps with software engineering tasks.";
const AGENT_PATTERN = /you are claude code|claude.?code.+official.+cli|anthropic.+official.+cli|anxthxropic.+official.+cli|you are (?:cursor|windsurf|cline|aider|continue|copilot|cody)|you are an? (?:ai )?(?:coding |code )?agent|cc_entrypoint\s*=\s*(?:cli|vscode|jetbrains|gui)|claude.?code.+issues|give feedback.+claude.?code|you are .{0,30}(?:powerful )?ai agent|orchestration capabilities|OhMyOpenCode|<agent-identity>|<Role>|<Behavior_Instructions>/i;
const flatten = (content) =>
typeof content === "string"
? content
: Array.isArray(content)
? content.map((b) => (b && typeof b.text === "string" ? b.text : "")).join("\n")
: "";
if (Array.isArray(transformed.messages)) {
transformed.messages = transformed.messages.map((message) => {
if (!message || message.role !== "system") return message;
const text = flatten(message.content);
if (!text) return message;
if (text.length > 2000 || AGENT_PATTERN.test(text)) {
return typeof message.content === "string"
? { ...message, content: NEUTRAL_PROMPT }
: { ...message, content: [{ type: "text", text: NEUTRAL_PROMPT }] };
}
return message;
});
}
// CodeBuddy only surfaces model reasoning when the request carries the CLI's
// OpenAI-style params: reasoning_effort + reasoning_summary:"auto". 9router's
// thinking pipeline sets reasoning_effort only when the client asks, and never
@@ -54,14 +25,10 @@ export class CodeBuddyExecutor extends DefaultExecutor {
const eff = transformed.reasoning_effort;
if (eff === "none" || eff === "off") {
delete transformed.reasoning_effort; // gateway has no "none" — just omit
} else if (eff) {
// Client explicitly asked for reasoning — mirror the CLI's reasoning_summary
// so CodeBuddy surfaces the model's reasoning.
} else {
if (!eff) transformed.reasoning_effort = "medium";
transformed.reasoning_summary = "auto";
}
// No reasoning requested: leave both unset. Forcing reasoning_effort:"medium"
// + reasoning_summary on plain requests makes CodeBuddy trip its content
// filter and return an error (#2071).
return transformed;
}
}

View File

@@ -1,44 +0,0 @@
import { DefaultExecutor } from "./default.js";
/**
* CodeBuddyIntlExecutor — talks to https://www.codebuddy.ai/v2/chat/completions
*
* Same OpenAI-compatible-but-stream-only gateway behavior as codebuddy-cn:
* non-stream requests are rejected, and reasoning is surfaced only when the
* request carries the IDE's OpenAI-style reasoning params. Force stream and
* mirror reasoning_summary exactly like CodeBuddyExecutor.
*/
export class CodeBuddyIntlExecutor extends DefaultExecutor {
constructor() {
super("codebuddy-intl");
}
transformRequest(model, body, stream, credentials) {
const transformed = super.transformRequest(model, body, stream, credentials);
transformed.stream = true;
const eff = transformed.reasoning_effort;
if (eff === "none" || eff === "off") {
delete transformed.reasoning_effort;
} else if (eff) {
transformed.reasoning_summary = "auto";
}
// CodeBuddy rejects plain OpenAI shape (11101 invalid request): needs a
// leading system prompt + user content as typed blocks, not a bare string.
const source = Array.isArray(transformed.messages) ? transformed.messages : [];
transformed.messages = [{ role: "system", content: "You are CodeBuddy Code." }];
for (const message of source) {
if (!message || typeof message !== "object" || ["system", "developer"].includes(message.role)) continue;
if (message.role === "user" && typeof message.content === "string") {
transformed.messages.push({ ...message, content: [{ type: "text", text: message.content }] });
} else {
transformed.messages.push({ ...message });
}
}
return transformed;
}
}
export default CodeBuddyIntlExecutor;

View File

@@ -8,22 +8,13 @@ import {
import { normalizeResponsesInput } from "../translator/formats/responsesApi.js";
import { fetchImageAsBase64 } from "../translator/concerns/image.js";
import { getModelUpstreamId } from "../config/providerModels.js";
import { getThinkingLevels } from "../providers/thinkingLevels.js";
import { DEFAULT_RETRY_CONFIG, HTTP_STATUS, resolveRetryEntry } from "../config/runtimeConfig.js";
import { DEFAULT_RETRY_CONFIG, resolveRetryEntry } from "../config/runtimeConfig.js";
import { dbg } from "../utils/debugLog.js";
import { resolveSessionId } from "../utils/sessionManager.js";
// SSE error patterns inside 200-OK bodies. Some retry same account first; capacity rotates accounts.
const CODEX_SSE_RETRY_PATTERNS = ["server_is_overloaded", "service_unavailable_error"];
const CODEX_SSE_ACCOUNT_FALLBACK_PATTERNS = ["selected model is at capacity", "model_at_capacity"];
const CODEX_SSE_USER_OUTPUT_PATTERNS = [
"event: response.output_text.delta",
"event: response.function_call_arguments.delta",
'"type":"response.output_text.delta"',
'"type":"response.function_call_arguments.delta"',
];
const CODEX_SSE_PEEK_BYTES = 256 * 1024;
const CODEX_MODEL_CAPACITY_MESSAGE = "Selected model is at capacity. Please try a different model.";
// SSE error patterns inside 200-OK body that should trigger retry as if 503
const CODEX_SSE_OVERLOADED_PATTERNS = ["server_is_overloaded", "service_unavailable_error"];
const CODEX_SSE_PEEK_BYTES = 4096;
// Server-generated item id prefixes that Codex /responses cannot resolve when store=false
const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/;
@@ -41,8 +32,7 @@ const CODEX_PASSTHROUGH_TOOL_TYPES = new Set(["custom"]);
// Allowlist of fields accepted by Codex Responses API — anything else is stripped
const RESPONSES_API_ALLOWLIST = new Set([
"model", "input", "instructions", "tools", "tool_choice", "stream", "store",
"reasoning", "service_tier", "include", "prompt_cache_key", "client_metadata",
"text"
"reasoning", "service_tier", "include", "prompt_cache_key", "client_metadata"
]);
// Convert role=system → role=developer in body.input (keeps content in cacheable prefix)
@@ -125,66 +115,6 @@ function resolveCacheSessionId(body, credentials) {
});
}
function normalizeReasoningEffort(model, value) {
const supportedLevels = getThinkingLevels("codex", model);
if (supportedLevels?.includes(value)) return value;
if (value === "ultra" && supportedLevels?.includes("max")) return "max";
if (value === "max" || value === "ultra") return "xhigh";
return value;
}
function findNestedMessage(value, depth = 0) {
if (!value || depth > 6 || typeof value === "string") return null;
if (Array.isArray(value)) {
for (const item of value) {
const found = findNestedMessage(item, depth + 1);
if (found) return found;
}
return null;
}
if (typeof value !== "object") return null;
if (typeof value.message === "string" && value.message.trim()) return value.message;
if (typeof value.error?.message === "string" && value.error.message.trim()) return value.error.message;
if (typeof value.response?.error?.message === "string" && value.response.error.message.trim()) return value.response.error.message;
for (const child of Object.values(value)) {
const found = findNestedMessage(child, depth + 1);
if (found) return found;
}
return null;
}
function extractSseErrorMessage(text, fallback) {
const exact = text?.match(/Selected model is at capacity\. Please try a different model\./i)?.[0];
if (exact) return exact;
for (const line of String(text || "").split(/\r?\n/)) {
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (!data || data === "[DONE]") continue;
try {
const message = findNestedMessage(JSON.parse(data));
if (message) return message;
} catch {
// Ignore non-JSON SSE data lines.
}
}
return fallback || CODEX_MODEL_CAPACITY_MESSAGE;
}
function codexSseErrorResponse(status, message) {
return new Response(JSON.stringify({
error: {
message,
type: status >= 500 ? "server_error" : "invalid_request_error",
code: status === HTTP_STATUS.SERVICE_UNAVAILABLE ? "service_unavailable" : "upstream_error",
}
}), {
status,
headers: { "Content-Type": "application/json" },
});
}
/**
* Codex Executor - handles OpenAI Codex API (Responses API format)
* Automatically injects default instructions if missing
@@ -204,17 +134,10 @@ export class CodexExecutor extends BaseExecutor {
headers["session_id"] = this._currentSessionId || credentials?.connectionId || "default";
// Identify client type to Codex backend (matches official codex CLI)
if (!headers["originator"]) headers["originator"] = "codex_cli_rs";
// Account/workspace binding header — required when multiple Codex accounts
// are configured. OAuth import stores ChatGPT account ID as chatgptAccountId;
// older/custom rows may use workspaceId/accountId. Prefer explicit workspaceId
// but fall back to chatgptAccountId so requests don't cross-bind to the wrong
// OpenAI account and surface as token_invalid after adding another account.
const accountId =
credentials?.providerSpecificData?.workspaceId ||
credentials?.providerSpecificData?.chatgptAccountId ||
credentials?.providerSpecificData?.accountId;
if (typeof accountId === "string" && accountId && !headers["ChatGPT-Account-ID"]) {
headers["ChatGPT-Account-ID"] = accountId;
// Workspace binding header — improves account scope + cache affinity
const workspaceId = credentials?.providerSpecificData?.workspaceId;
if (typeof workspaceId === "string" && workspaceId && !headers["chatgpt-account-id"]) {
headers["chatgpt-account-id"] = workspaceId;
}
return headers;
}
@@ -274,7 +197,7 @@ export class CodexExecutor extends BaseExecutor {
let attempt = 0;
while (true) {
const result = await super.execute(args);
const peek = await this._peekSseTransientError(result.response);
const peek = await this._peekSseOverloaded(result.response);
if (!peek.matched) {
// Replace body with re-assembled stream (prefix bytes already read + rest)
if (peek.replacementBody) {
@@ -286,57 +209,48 @@ export class CodexExecutor extends BaseExecutor {
}
return result;
}
if (peek.accountFallback) {
args.log?.warn?.("RETRY", `CODEX | SSE account fallback "${peek.message}"`);
result.response = codexSseErrorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, peek.message || CODEX_MODEL_CAPACITY_MESSAGE);
return result;
}
if (attempt >= attempts) {
args.log?.warn?.("RETRY", `CODEX | SSE overloaded "${peek.matched}" — retries exhausted (${attempt}/${attempts})`);
result.response = codexSseErrorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, peek.message || peek.matched);
// Out of retries → return with replacement body so client gets the error
if (peek.replacementBody) {
result.response = new Response(peek.replacementBody, {
status: result.response.status,
statusText: result.response.statusText,
headers: result.response.headers,
});
}
return result;
}
attempt++;
args.log?.debug?.("RETRY", `CODEX | SSE "${peek.matched}" retry ${attempt}/${attempts} after ${delayMs / 1000}s`);
dbg("CODEX", `SSE overloaded "${peek.matched}" → retry ${attempt}/${attempts} in ${delayMs}ms`);
try { await result.response.body?.cancel?.(); } catch { /* noop */ }
await new Promise(r => setTimeout(r, delayMs));
}
}
// Peek first N bytes of SSE body to detect upstream transient errors.
// Returns { matched: string|null, message: string|null, accountFallback: boolean, replacementBody: ReadableStream|null }.
// Caller must use replacementBody when no error matched (original body has been read).
async _peekSseTransientError(response) {
if (!response || !response.ok || !response.body) return { matched: null, message: null, accountFallback: false, replacementBody: null };
// Peek first N bytes of SSE body to detect upstream "overloaded" errors.
// Returns { matched: string|null, replacementBody: ReadableStream|null }.
// Caller MUST use replacementBody (original body has been read).
async _peekSseOverloaded(response) {
if (!response || !response.ok || !response.body) return { matched: null, replacementBody: null };
const reader = response.body.getReader();
const decoder = new TextDecoder();
const chunks = [];
let text = "";
let matched = null;
let accountFallback = false;
try {
while (text.length < CODEX_SSE_PEEK_BYTES) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
text += decoder.decode(value, { stream: true });
const lowerText = text.toLowerCase();
const accountHit = CODEX_SSE_ACCOUNT_FALLBACK_PATTERNS.find(p => lowerText.includes(p));
if (accountHit) { matched = accountHit; accountFallback = true; break; }
const retryHit = CODEX_SSE_RETRY_PATTERNS.find(p => lowerText.includes(p));
if (retryHit) { matched = retryHit; break; }
if (CODEX_SSE_USER_OUTPUT_PATTERNS.some(p => lowerText.includes(p))) break;
const hit = CODEX_SSE_OVERLOADED_PATTERNS.find(p => text.includes(p));
if (hit) { matched = hit; break; }
}
} catch (e) {
dbg("CODEX", `peek read error: ${e.message}`);
}
if (matched) {
try { await reader.cancel(); } catch { /* noop */ }
try { reader.releaseLock(); } catch { /* noop */ }
return { matched, message: extractSseErrorMessage(text, matched), accountFallback, replacementBody: null };
}
reader.releaseLock();
// Re-assemble stream: prefix chunks + remaining upstream body
@@ -358,7 +272,7 @@ export class CodexExecutor extends BaseExecutor {
try { upstreamReader?.cancel(reason); } catch { /* noop */ }
},
});
return { matched: null, message: null, accountFallback: false, replacementBody };
return { matched, replacementBody };
}
// Parse Codex usage_limit_reached to extract precise resetsAtMs; fallback to default otherwise
@@ -432,7 +346,7 @@ export class CodexExecutor extends BaseExecutor {
// Extract thinking level from model name suffix
// e.g., gpt-5.3-codex-high → high, gpt-5.3-codex → medium (default)
const effortLevels = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'];
const effortLevels = ['none', 'low', 'medium', 'high', 'xhigh'];
let modelEffort = null;
for (const level of effortLevels) {
if (body.model.endsWith(`-${level}`)) {
@@ -445,11 +359,10 @@ export class CodexExecutor extends BaseExecutor {
// Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (medium)
if (!body.reasoning) {
const effort = normalizeReasoningEffort(body.model, body.reasoning_effort || modelEffort || 'low');
const effort = body.reasoning_effort || modelEffort || 'low';
body.reasoning = { effort, summary: "auto" };
} else {
body.reasoning.effort = normalizeReasoningEffort(body.model, body.reasoning.effort);
if (!body.reasoning.summary) body.reasoning.summary = "auto";
} else if (!body.reasoning.summary) {
body.reasoning.summary = "auto";
}
delete body.reasoning_effort;
@@ -477,9 +390,6 @@ export class CodexExecutor extends BaseExecutor {
delete body.safety_identifier; // Droid CLI sends this but Codex doesn't support it
delete body.previous_response_id; // store=false → backend can't resolve previous resp; avoid 404
if (body.service_tier === "fast") body.service_tier = "priority";
if (body.service_tier && body.service_tier !== "priority") delete body.service_tier;
// Final allowlist filter — strip any unknown field that could trigger upstream "routing_unsupported"
for (const k of Object.keys(body)) {
if (!RESPONSES_API_ALLOWLIST.has(k)) delete body[k];

View File

@@ -1,22 +1,18 @@
import { BaseExecutor } from "./base.js";
import { PROVIDERS, PROVIDER_OAUTH } from "../config/providers.js";
import { PROVIDERS } from "../config/providers.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import {
generateCursorBody,
encodeField,
wrapConnectRPCFrame,
decodeMessage,
parseConnectRPCFrame,
extractTextFromResponse
} from "../utils/cursorProtobuf.js";
import { buildCursorHeaders } from "../utils/cursorChecksum.js";
import { estimateUsage } from "../utils/usageTracking.js";
import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js";
import { chatChunkSse, sseChunk } from "../utils/sse.js";
import { chatChunkSse } from "../utils/sse.js";
import { FORMATS } from "../translator/formats.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import zlib from "zlib";
import crypto from "crypto";
// Detect cloud environment
const isCloudEnv = () => {
@@ -42,130 +38,6 @@ const COMPRESS_FLAG = {
GZIP_TRAILER: 0x03
};
const AGENT_RUN_PATH = "/agent.v1.AgentService/Run";
const PROTOBUF_LEN = 2;
const PROTOBUF_VARINT = 0;
function concatBuffers(...parts) {
const length = parts.reduce((total, part) => total + part.length, 0);
const result = new Uint8Array(length);
let offset = 0;
for (const part of parts) {
result.set(part, offset);
offset += part.length;
}
return result;
}
const agentString = (field, value) => encodeField(field, PROTOBUF_LEN, value);
const agentMessage = (field, value) => encodeField(field, PROTOBUF_LEN, value);
const agentBool = (field, value) => encodeField(field, PROTOBUF_VARINT, value ? 1 : 0);
function textFromContent(content) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter((part) => part?.type === "text" && typeof part.text === "string")
.map((part) => part.text)
.join("\n");
}
function isAgentTextRequest(body) {
// Many compatible clients always attach their built-in tool schemas, even
// for a normal text turn. Cursor's retired ChatService rejects those
// requests; AgentService can still answer the text turn, so ignore schemas
// here. A real tool-call/result conversation is kept on the legacy path
// until its AgentService tool protocol is implemented.
return Array.isArray(body?.messages) && body.messages.every((message) => {
if (message?.tool_calls?.length || message?.role === "tool") return false;
return typeof message?.content === "string"
|| Array.isArray(message?.content) && message.content.every((part) => part?.type === "text");
});
}
function encodeHistoryMessage(message) {
const content = textFromContent(message?.content);
if (!content) return null;
// ConversationHistoryMessage.user / .assistant -> repeated content -> text.
const text = agentString(1, content);
if (message.role === "assistant") {
return agentMessage(2, agentMessage(1, agentMessage(1, text)));
}
return agentMessage(1, agentMessage(1, agentMessage(1, text)));
}
function buildAgentRunFrame(messages, model) {
const system = messages
.filter((message) => message?.role === "system")
.map((message) => textFromContent(message.content))
.filter(Boolean)
.join("\n\n");
const chatMessages = messages.filter((message) => message?.role !== "system");
const currentIndex = [...chatMessages].map((message) => message?.role).lastIndexOf("user");
const current = currentIndex >= 0 ? chatMessages[currentIndex] : chatMessages.at(-1);
const history = chatMessages
.slice(0, currentIndex >= 0 ? currentIndex : -1)
.map(encodeHistoryMessage)
.filter(Boolean);
const userText = textFromContent(current?.content) || "Continue.";
// agent.v1.UserMessageAction.user_message and its optional history.
const userMessage = concatBuffers(
agentString(1, userText),
agentString(2, crypto.randomUUID()),
);
const conversationHistory = history.length
? concatBuffers(...history.map((entry) => agentMessage(1, entry)))
: null;
const userAction = concatBuffers(
agentMessage(1, userMessage),
...(conversationHistory ? [agentMessage(7, conversationHistory)] : []),
);
const conversationAction = agentMessage(1, userAction);
const requestedModel = concatBuffers(agentString(1, model), agentBool(7, true));
const runRequest = concatBuffers(
// An empty ConversationStateStructure starts a fresh local agent session.
agentMessage(1, new Uint8Array()),
agentMessage(2, conversationAction),
...(system ? [agentString(8, system)] : []),
agentMessage(9, requestedModel),
);
// agent.v1.AgentClientMessage.run_request.
return wrapConnectRPCFrame(agentMessage(1, runRequest));
}
function extractAgentString(message, field) {
const value = message?.get(field)?.[0]?.value;
return value ? Buffer.from(value).toString("utf8") : "";
}
function decodeAgentFrames(buffer, onFrame) {
let pending = Buffer.from(buffer || []);
while (pending.length >= 5) {
const flags = pending[0];
const length = pending.readUInt32BE(1);
if (pending.length < 5 + length) break;
let payload = pending.subarray(5, 5 + length);
pending = pending.subarray(5 + length);
if (flags & COMPRESS_FLAG.GZIP) {
payload = zlib.gunzipSync(payload);
}
if (!(flags & COMPRESS_FLAG.TRAILER)) onFrame(payload);
}
return pending;
}
function createRequestContextResponse() {
// AgentService asks every run for client context. 9router has no IDE file
// context, so acknowledge with an empty RequestContext.
const requestContextSuccess = agentMessage(1, new Uint8Array());
const requestContextResult = agentMessage(1, requestContextSuccess);
const execClientMessage = agentMessage(10, requestContextResult);
return wrapConnectRPCFrame(agentMessage(2, execClientMessage));
}
const CURSOR_STREAM_DEBUG = process.env.CURSOR_STREAM_DEBUG === "1";
const debugLog = (...args) => {
if (CURSOR_STREAM_DEBUG) console.log(...args);
@@ -381,304 +253,7 @@ export class CursorExecutor extends BaseExecutor {
});
}
/**
* AgentService (agent.api5.cursor.sh) is HTTP/2-only. Node's fetch/undici speaks
* HTTP/1.1 and fails with HTTPParserError on the h2 preface — use http2 duplex.
*/
openAgentHttp2Stream(url, headers, signal) {
if (!http2) {
throw new Error("HTTP/2 is required for Cursor AgentService (endpoint is h2-only)");
}
const urlObj = new URL(url);
const client = http2.connect(`https://${urlObj.host}`);
const chunkQueue = [];
let waiting = null;
let ended = false;
let streamError = null;
let req = null;
const wake = (result) => {
if (!waiting) return;
const resolve = waiting;
waiting = null;
resolve(result);
};
const fail = (error) => {
if (streamError) return;
streamError = error;
ended = true;
wake(null);
};
const close = () => {
try { req?.destroy(); } catch {}
try { client.close(); } catch {}
};
client.on("error", fail);
req = client.request({
":method": "POST",
":path": urlObj.pathname,
":authority": urlObj.host,
":scheme": "https",
...headers,
});
req.on("error", fail);
req.on("data", (chunk) => {
if (waiting) wake({ value: chunk, done: false });
else chunkQueue.push(chunk);
});
req.on("end", () => {
ended = true;
wake({ value: undefined, done: true });
});
if (signal) {
const onAbort = () => {
fail(new Error("Request aborted"));
close();
};
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
}
const responseHeaders = new Promise((resolve, reject) => {
const onEarlyError = (error) => reject(error);
client.once("error", onEarlyError);
req.once("error", onEarlyError);
req.once("response", (hdrs) => {
client.off("error", onEarlyError);
req.off("error", onEarlyError);
resolve(hdrs);
});
});
return {
responseHeaders,
write(frame) {
if (req && !req.destroyed) req.write(Buffer.from(frame));
},
end() {
try { if (req && !req.destroyed) req.end(); } catch {}
},
close,
async read() {
if (chunkQueue.length) return { value: chunkQueue.shift(), done: false };
if (ended) {
if (streamError) throw streamError;
return { value: undefined, done: true };
}
const result = await new Promise((resolve) => { waiting = resolve; });
if (streamError) throw streamError;
return result || { value: undefined, done: true };
},
};
}
async executeAgent({ model, body, stream, credentials, signal }) {
const agentEndpoint = PROVIDER_OAUTH.cursor?.agentEndpoint;
if (!agentEndpoint) throw new Error("Cursor AgentService endpoint is not configured");
const url = `${agentEndpoint}${AGENT_RUN_PATH}`;
const headers = this.buildHeaders(credentials);
const requestController = new AbortController();
if (signal?.addEventListener) {
signal.addEventListener("abort", () => requestController.abort(signal.reason), { once: true });
}
let session;
try {
session = this.openAgentHttp2Stream(url, headers, requestController.signal);
session.write(buildAgentRunFrame(body.messages || [], model));
} catch (error) {
throw new Error(`Cursor AgentService request failed: ${error.message}`);
}
let responseHeaders;
try {
responseHeaders = await session.responseHeaders;
} catch (error) {
session.close();
throw new Error(`Cursor AgentService request failed: ${error.message}`);
}
const status = Number(responseHeaders[":status"] || 0);
if (status !== 200) {
let errorText = "";
try {
while (true) {
const { done, value } = await session.read();
if (done) break;
errorText += Buffer.from(value).toString("utf8");
}
} catch {}
session.close();
return {
response: new Response(JSON.stringify({
error: { message: `Cursor AgentService ${status}: ${errorText || "request failed"}`, type: "api_error" },
}), { status: status || HTTP_STATUS.SERVER_ERROR, headers: { "Content-Type": "application/json" } }),
url,
headers,
transformedBody: body,
responseFormat: FORMATS.OPENAI,
};
}
// The Claude SSE translator derives Anthropic's message ID by stripping
// `chatcmpl-`. Keep the remaining ID in Anthropic's required `msg_` form
// so strict clients such as Claude Code accept the completed stream.
const responseId = `chatcmpl-msg_${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
let pending = Buffer.alloc(0);
let finished = false;
const consume = async (onEvent) => {
try {
while (!finished) {
const { done, value } = await session.read();
if (done) break;
pending = Buffer.concat([pending, Buffer.from(value)]);
pending = decodeAgentFrames(pending, (payload) => {
// A single read can carry several frames; once the turn is over the
// rest of the batch must not reach the already-closed controller.
if (finished) return;
const serverMessage = decodeMessage(payload);
// agent.v1.AgentServerMessage.interaction_update
if (serverMessage.has(1)) {
const update = decodeMessage(serverMessage.get(1)[0].value);
if (update.has(1)) {
const textDelta = extractAgentString(decodeMessage(update.get(1)[0].value), 1);
if (textDelta) onEvent({ type: "text", value: textDelta });
}
// Cursor's AgentService emits internal reasoning without the
// cryptographic signature required by Anthropic thinking blocks.
// Forwarding it makes strict Anthropic clients (Claude Code)
// discard or wait on an otherwise complete response. Keep the
// reasoning upstream-only and emit the normal answer text.
if (update.has(14)) {
finished = true;
onEvent({ type: "done" });
}
}
// AgentService requests IDE context before producing a response.
// Return an empty context; 9router is not coupled to an editor.
if (serverMessage.has(2)) {
const execRequest = decodeMessage(serverMessage.get(2)[0].value);
if (execRequest.has(10)) {
session.write(createRequestContextResponse());
} else {
// Every other ExecServerMessage variant is an editor-backed tool
// (shell, read, write, …) that 9router cannot service. Fail the
// turn rather than narrating protocol state as assistant text.
debugLog(`[CURSOR AGENT] Unsupported exec request fields: ${[...execRequest.keys()].join(",")}`);
finished = true;
onEvent({ type: "error", value: "Cursor AgentService requested an unsupported IDE tool" });
}
}
});
}
} finally {
try { session.end(); } catch {}
try { session.close(); } catch {}
if (!finished) onEvent({ type: "done" });
}
};
if (stream === false) {
let content = "";
let reasoning = "";
let agentError = null;
await consume((event) => {
if (event.type === "text") content += event.value;
else if (event.type === "thinking") reasoning += event.value;
else if (event.type === "error") agentError = event.value;
});
if (agentError) {
return {
response: new Response(JSON.stringify({ error: { message: agentError, type: "api_error" } }), {
status: HTTP_STATUS.BAD_REQUEST,
headers: { "Content-Type": "application/json" },
}),
url,
headers,
transformedBody: body,
responseFormat: FORMATS.OPENAI,
};
}
return {
response: new Response(JSON.stringify({
id: responseId,
object: "chat.completion",
created,
model,
choices: [{ index: 0, message: { role: "assistant", content: content || null, ...(reasoning ? { reasoning_content: reasoning } : {}) }, finish_reason: "stop" }],
usage: estimateUsage(body, content.length, FORMATS.OPENAI),
}), { headers: { "Content-Type": "application/json" } }),
url,
headers,
transformedBody: body,
responseFormat: FORMATS.OPENAI,
};
}
const encoder = new TextEncoder();
const responseStream = new ReadableStream({
start(controller) {
consume((event) => {
if (event.type === "text") {
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { content: event.value } })));
} else if (event.type === "thinking") {
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { reasoning_content: event.value } })));
} else if (event.type === "error") {
// An SSE error frame, not a content delta: a protocol failure must not
// be rendered to the user as the assistant's reply, and downstream
// usage tracking must not record the turn as a success.
controller.enqueue(encoder.encode(sseChunk({ error: { message: event.value, type: "api_error" } })));
controller.enqueue(encoder.encode(SSE_DONE));
controller.close();
} else if (event.type === "done") {
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: {}, finishReason: "stop" })));
controller.enqueue(encoder.encode(SSE_DONE));
controller.close();
}
}).catch((error) => controller.error(error));
},
cancel() {
requestController.abort();
},
});
return {
response: new Response(responseStream, { headers: SSE_HEADERS }),
url,
headers,
transformedBody: body,
responseFormat: FORMATS.OPENAI,
};
}
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
if (isAgentTextRequest(body)) {
try {
return await this.executeAgent({ model, body, stream, credentials, signal });
} catch (error) {
return {
response: new Response(JSON.stringify({
error: { message: error.message, type: "connection_error", code: "" },
}), { status: HTTP_STATUS.SERVER_ERROR, headers: { "Content-Type": "application/json" } }),
url: `${PROVIDER_OAUTH.cursor?.agentEndpoint || ""}${AGENT_RUN_PATH}`,
headers: {},
transformedBody: body,
};
}
}
const url = this.buildUrl();
const headers = this.buildHeaders(credentials);
const transformedBody = this.transformRequest(model, body, stream, credentials);

View File

@@ -1,9 +1,9 @@
import { BaseExecutor } from "./base.js";
import { PROVIDERS, PROVIDER_OAUTH } from "../config/providers.js";
import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE, selectAnthropicBeta } from "../providers/shared.js";
import { resolveOpenAICompatibleApiType } from "../services/provider.js";
import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE } from "../providers/shared.js";
import { OAUTH_ENDPOINTS, buildKimiHeaders } from "../config/appConstants.js";
import { buildClineHeaders } from "../shared/clineAuth.js";
import { getCachedClaudeHeaders } from "../utils/claudeHeaderCache.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
import { stripUnsupportedParams } from "../translator/concerns/paramSupport.js";
@@ -38,10 +38,24 @@ function applyAuth(headers, desc, credentials) {
// Provider-specific header quirks kept as small hooks (not pure auth).
const HEADER_HOOKS = {
// Stable device_id from OAuth connection (CLIProxyAPI KimiTokenStorage.DeviceID)
kimiHeaders: (h, c) => Object.assign(h, buildKimiHeaders(c?.providerSpecificData?.deviceId)),
kimiHeaders: (h) => Object.assign(h, buildKimiHeaders()),
clineHeaders: (h, c) => Object.assign(h, buildClineHeaders(c.apiKey || c.accessToken)),
kilocodeOrg: (h, c) => { if (c.providerSpecificData?.orgId) h["X-Kilocode-OrganizationID"] = c.providerSpecificData.orgId; },
claudeOverlay: (h) => {
const cached = getCachedClaudeHeaders();
if (!cached) return;
for (const lcKey of Object.keys(cached)) {
const titleKey = lcKey.replace(/(^|-)([a-z])/g, (_, sep, ch) => sep + ch.toUpperCase());
if (lcKey === "anthropic-beta") {
const staticBetaStr = h[titleKey] || h[lcKey] || "";
const flags = new Set(staticBetaStr.split(",").map(f => f.trim()).filter(Boolean));
for (const f of cached[lcKey].split(",").map(f => f.trim()).filter(Boolean)) flags.add(f);
cached[lcKey] = Array.from(flags).join(",");
}
if (titleKey !== lcKey && h[titleKey] !== undefined) delete h[titleKey];
}
Object.assign(h, cached);
},
};
// Config-driven OAuth refresh grants — derived from registry oauth.refresh.
@@ -110,7 +124,7 @@ export class DefaultExecutor extends BaseExecutor {
if (this.provider?.startsWith?.("openai-compatible-")) {
const baseUrl = credentials?.providerSpecificData?.baseUrl || OPENAI_COMPAT_BASE;
const normalized = baseUrl.replace(/\/$/, "");
const path = resolveOpenAICompatibleApiType(this.provider, credentials) === "responses" ? "/responses" : "/chat/completions";
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
return `${normalized}${path}`;
}
if (this.provider?.startsWith?.("anthropic-compatible-")) {
@@ -146,18 +160,14 @@ export class DefaultExecutor extends BaseExecutor {
return BEARER;
}
buildHeaders(credentials, stream = true, url, model) {
buildHeaders(credentials, stream = true) {
const rt = credentials?.runtimeTransport;
const headers = { "Content-Type": "application/json", ...(rt ? rt.headers : this.config.headers) };
const desc = rt?.auth || AUTH_DESCRIPTORS[this.provider] || this.resolveAuthDescriptor();
// Hooks run BEFORE auth so dynamic overlays can't clobber the token.
// Hooks run BEFORE auth so dynamic overlays (claude cached headers) can't clobber the token.
for (const hook of desc.hooks || []) HEADER_HOOKS[hook]?.(headers, credentials);
applyAuth(headers, desc, credentials);
if (this.provider === "claude" && model) {
headers["Anthropic-Beta"] = selectAnthropicBeta(model);
}
// Strip first-party Claude Code identity headers for non-Anthropic anthropic-compatible upstreams
if (this.provider?.startsWith?.("anthropic-compatible-")) {
const baseUrl = credentials?.providerSpecificData?.baseUrl || "";
@@ -211,13 +221,12 @@ export class DefaultExecutor extends BaseExecutor {
const refreshers = {
claude: () => this.refreshFromGrant(credentials, proxyOptions),
codex: () => this.refreshFromGrant(credentials, proxyOptions),
qwen: () => this.refreshWithForm(OAUTH_ENDPOINTS.qwen.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.qwen.clientId }, proxyOptions),
iflow: () => this.refreshIflow(credentials.refreshToken, proxyOptions),
gemini: () => this.refreshFromGrant(credentials, proxyOptions),
kiro: () => this.refreshKiro(credentials.refreshToken, proxyOptions),
cline: () => this.refreshCline(credentials.refreshToken, proxyOptions),
clinepass: () => this.refreshCline(credentials.refreshToken, proxyOptions),
kimi: () => this.refreshKimi(credentials, proxyOptions),
"kimi-coding": () => this.refreshKimi(credentials, proxyOptions),
"kimi-coding": () => this.refreshKimiCoding(credentials.refreshToken, proxyOptions),
kilocode: () => this.refreshKilocode(credentials.refreshToken, proxyOptions)
};
@@ -290,27 +299,19 @@ export class DefaultExecutor extends BaseExecutor {
const data = payload?.data || payload;
const expiresAtIso = data?.expiresAt;
const expiresIn = expiresAtIso ? Math.max(1, Math.floor((new Date(expiresAtIso).getTime() - Date.now()) / 1000)) : undefined;
let accessToken = data?.accessToken;
if (accessToken && !accessToken.startsWith("workos:")) {
accessToken = `workos:${accessToken}`;
}
return { accessToken, refreshToken: data?.refreshToken || refreshToken, expiresIn };
return { accessToken: data?.accessToken, refreshToken: data?.refreshToken || refreshToken, expiresIn };
}
// CLIProxyAPI DeviceFlowClient.RefreshToken — form body + X-Msh-* headers + stable device_id
async refreshKimi(credentials, proxyOptions = null) {
const refreshToken = credentials.refreshToken;
const cfg = PROVIDERS.kimi || PROVIDERS["kimi-coding"];
if (!cfg?.refreshUrl || !cfg?.clientId) return null;
const kimiHeaders = buildKimiHeaders(credentials?.providerSpecificData?.deviceId);
const response = await proxyAwareFetch(cfg.refreshUrl, {
async refreshKimiCoding(refreshToken, proxyOptions = null) {
const kimiHeaders = buildKimiHeaders();
const response = await proxyAwareFetch(PROVIDERS["kimi-coding"].refreshUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
...kimiHeaders
},
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: cfg.clientId })
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: PROVIDERS["kimi-coding"].clientId })
}, proxyOptions);
if (!response.ok) return null;
const tokens = await response.json();

View File

@@ -1,847 +0,0 @@
/**
* DevinCliExecutor — routes completions through the official Devin CLI binary
* via the Agent Client Protocol (ACP) JSON-RPC 2.0 over stdio.
*
* Protocol flow:
* 1. Spawn `devin acp` (default agent = full built-in tools: fs/shell/search).
* Set CLI_DEVIN_AGENT_TYPE=summarizer for a tool-less, text-only mode.
* 2. Send: initialize → session/new (with model + cwd + mcpServers) → session/prompt.
* 3. Receive: session/update notifications (agent_message_chunk = reply text,
* tool_call/tool_call_update = built-in tool invocations, surfaced as text).
* When devin calls a client-tool from the exposed MCP ("Calling mcp_X from
* clientTools"), it is bridged to an OpenAI tool_use and the turn ends.
* 4. Emit deltas as OpenAI-compatible SSE chunks.
* 5. Kill subprocess on _cognition.ai/agent_stopped or error.
*
* Auth: noAuth — the subprocess inherits the parent env and uses credentials
* stored by `devin auth login` (~/.local/share/devin/credentials.toml).
*
* Binary discovery: CLI_DEVIN_BIN env → PATH lookup → platform installer paths.
*/
import { spawn } from "node:child_process";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import { BaseExecutor } from "./base.js";
// ─── Binary discovery ────────────────────────────────────────────────────────
function resolveDevinBin() {
// 1. Explicit override
const envBin = process.env.CLI_DEVIN_BIN?.trim();
if (envBin) return envBin;
const isWin = process.platform === "win32";
const home = os.homedir();
// 2. Known installer / package-manager locations. spawn uses shell:false on
// macOS/Linux, so process.env.PATH alone may miss ~/.local/bin, Homebrew,
// Scoop, etc. when the server runs detached (tray/daemon/launchd) without
// a login shell — probe these explicitly before falling back to PATH.
const candidates = isWin
? [
// Official installer: %LOCALAPPDATA%\devin\cli\bin\devin.exe
path.join(process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "devin", "cli", "bin", "devin.exe"),
path.join(home, ".local", "bin", "devin.exe"),
path.join(home, "scoop", "shims", "devin.exe"),
path.join(process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Programs", "devin", "devin.exe"),
]
: [
path.join(home, ".local", "share", "devin", "bin", "devin"),
path.join(home, ".devin", "bin", "devin"),
path.join(home, ".local", "bin", "devin"), // pipx / user install
"/opt/homebrew/bin/devin", // Homebrew (Apple Silicon)
"/usr/local/bin/devin", // Homebrew (Intel) / manual
"/usr/bin/devin",
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
// 3. Fallback — rely on process.env.PATH
return isWin ? "devin.exe" : "devin";
}
// ─── ACP JSON-RPC helper ────────────────────────────────────────────────────
function rpc(method, params, id) {
const msg = { jsonrpc: "2.0", method, params };
if (id !== undefined) msg.id = id;
return JSON.stringify(msg) + "\n";
}
// ─── Client-tools → MCP bridge ───────────────────────────────────────────────
// devin only invokes built-in + MCP tools, not OpenAI function-calling schemas.
// body.tools are exposed as a stdio MCP server "clientTools" so devin can call
// them. When devin calls one, we emit OpenAI tool_use and end the turn; the
// client executes and returns tool_result on the next request. That next request
// re-spawns with the full history (including tool_calls + tool results) and
// seeds the MCP server with those results so a re-call gets the real data.
// Tool schemas via DEVIN_MCP_TOOLS; prior results via DEVIN_MCP_RESULTS.
const CLIENT_TOOLS_MCP_SCRIPT = `
import readline from "node:readline";
const TOOLS = JSON.parse(process.env.DEVIN_MCP_TOOLS || "[]");
const RESULTS = JSON.parse(process.env.DEVIN_MCP_RESULTS || "{}");
const rl = readline.createInterface({ input: process.stdin });
function send(o){ process.stdout.write(JSON.stringify(o) + "\\n"); }
rl.on("line", (line) => {
let m; try { m = JSON.parse(line); } catch { return; }
if (m.method === "initialize") {
send({ jsonrpc: "2.0", id: m.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "clientTools", version: "1.0" } } });
} else if (m.method === "tools/list") {
send({ jsonrpc: "2.0", id: m.id, result: { tools: TOOLS } });
} else if (m.method === "tools/call") {
const name = m.params?.name || "";
const seeded = RESULTS[name];
const text = seeded !== undefined
? String(seeded)
: "(awaiting client tool_result)";
process.stderr.write("[client-tools] tool_call name=" + name + " seeded=" + (seeded !== undefined) + "\\n");
send({ jsonrpc: "2.0", id: m.id, result: { content: [{ type: "text", text }] } });
}
});
`.trimStart();
function ensureClientToolsScript() {
const scriptPath = path.join(os.tmpdir(), "9router-devin-client-tools.mjs");
// Always rewrite so script upgrades land without a process restart.
fs.writeFileSync(scriptPath, CLIENT_TOOLS_MCP_SCRIPT);
return scriptPath;
}
// Map OpenAI tools ([{type:"function",function:{name,description,parameters}}])
// to MCP tool declarations ([{name,description,inputSchema}]).
// devin only discovers MCP tools whose name carries the `mcp_` prefix, so we
// add it here and strip it back when bridging the call to the client.
const MCP_TOOL_PREFIX = "mcp_";
function toMcpToolName(name) {
return name.startsWith(MCP_TOOL_PREFIX) ? name : MCP_TOOL_PREFIX + name;
}
function fromMcpToolName(name) {
return name.startsWith(MCP_TOOL_PREFIX) ? name.slice(MCP_TOOL_PREFIX.length) : name;
}
function buildClientToolsMcp(tools, resultMap) {
const mcpTools = [];
for (const t of tools) {
if (!t) continue;
const f = t.function || t;
if (!f?.name) continue;
mcpTools.push({
name: toMcpToolName(f.name),
description: f.description || "",
inputSchema: f.parameters || f.input_schema || { type: "object", properties: {} },
});
}
if (!mcpTools.length) return null;
const env = { DEVIN_MCP_TOOLS: JSON.stringify(mcpTools) };
if (resultMap && Object.keys(resultMap).length) {
env.DEVIN_MCP_RESULTS = JSON.stringify(resultMap);
}
return {
command: process.execPath,
args: [ensureClientToolsScript()],
env,
};
}
// Extract tool_result content keyed by MCP tool name (mcp_<original>).
// Walks messages: assistant.tool_calls id→name, role=tool tool_call_id→content.
function extractClientToolResults(messages) {
const idToMcpName = new Map();
const results = {};
for (const m of messages) {
if (m?.role === "assistant" && Array.isArray(m.tool_calls)) {
for (const tc of m.tool_calls) {
const name = tc?.function?.name || tc?.name;
if (tc?.id && name) idToMcpName.set(tc.id, toMcpToolName(name));
}
}
// Claude-style tool_use blocks in content
if (m?.role === "assistant" && Array.isArray(m.content)) {
for (const b of m.content) {
if (b?.type === "tool_use" && b.id && b.name) {
idToMcpName.set(b.id, toMcpToolName(b.name));
}
}
}
if (m?.role === "tool" && m.tool_call_id) {
const mcpName = idToMcpName.get(m.tool_call_id);
if (mcpName) {
results[mcpName] =
typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? "");
}
}
// Claude-style tool_result blocks in user content
if (m?.role === "user" && Array.isArray(m.content)) {
for (const b of m.content) {
if (b?.type === "tool_result" && b.tool_use_id) {
const mcpName = idToMcpName.get(b.tool_use_id);
if (mcpName) {
const c = b.content;
results[mcpName] =
typeof c === "string" ? c : JSON.stringify(c ?? "");
}
}
}
}
}
return results;
}
// Resolve workspace cwd from client request (Codex/CLI env context, body fields).
// Prefer an absolute existing path so agent file tools hit the user's project
// instead of os.tmpdir() (which made relative create/delete inconsistent).
function resolveWorkspaceCwd(body) {
const candidates = [];
const push = (v) => {
if (typeof v === "string" && v.trim()) candidates.push(v.trim());
};
push(body?.cwd);
push(body?.working_directory);
push(body?.workdir);
push(body?.workspace);
push(body?.metadata?.cwd);
push(body?.metadata?.working_directory);
const scanText = (text) => {
if (typeof text !== "string") return;
for (const m of text.matchAll(/<cwd>\s*([^<]+?)\s*<\/cwd>/gi)) push(m[1]);
};
const scanMessages = (msgs) => {
if (!Array.isArray(msgs)) return;
for (const msg of msgs) {
if (!msg) continue;
if (typeof msg.content === "string") scanText(msg.content);
else if (Array.isArray(msg.content)) {
for (const p of msg.content) {
if (typeof p === "string") scanText(p);
else if (p && typeof p === "object") {
scanText(p.text);
scanText(p.input_text);
scanText(p.content);
}
}
}
// Responses API input items
if (typeof msg === "string") scanText(msg);
if (msg.type === "message" && Array.isArray(msg.content)) {
for (const p of msg.content) scanText(p?.text || p?.input_text);
}
}
};
scanMessages(body?.messages);
scanMessages(body?.input);
for (const c of candidates) {
try {
if (path.isAbsolute(c) && fs.existsSync(c) && fs.statSync(c).isDirectory()) {
return c;
}
} catch {
/* ignore */
}
}
return os.tmpdir();
}
// ─── Multi-turn message → single prompt builder ─────────────────────────────
function buildPromptText(messages) {
// Inline the whole conversation so the model has full context, including
// prior tool_calls / tool_results so it can continue after a client round-trip.
const lines = [];
for (const m of messages) {
const role = String(m.role || "user");
let text = "";
if (typeof m.content === "string") {
text = m.content;
} else if (Array.isArray(m.content)) {
for (const p of m.content) {
if (!p || typeof p !== "object") continue;
if (p.type === "text") text += String(p.text || "");
else if (p.type === "tool_use") {
text += `\n[Tool call ${p.name} id=${p.id}]\n${JSON.stringify(p.input ?? {})}\n`;
} else if (p.type === "tool_result") {
const c =
typeof p.content === "string" ? p.content : JSON.stringify(p.content ?? "");
text += `\n[Tool result id=${p.tool_use_id}]\n${c}\n`;
}
}
}
// OpenAI tool_calls on assistant messages
if (role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length) {
const parts = m.tool_calls.map((tc) => {
const name = tc.function?.name || tc.name || "tool";
const args = tc.function?.arguments ?? tc.arguments ?? {};
const argStr = typeof args === "string" ? args : JSON.stringify(args);
return `[Tool call ${name} id=${tc.id}]\n${argStr}`;
});
text = [text, ...parts].filter(Boolean).join("\n\n");
}
// OpenAI role=tool messages
if (role === "tool") {
const c = typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? "");
text = `[Tool result id=${m.tool_call_id || ""}]\n${c}`;
}
if (!text.trim()) continue;
if (role === "system") {
lines.push(`[System]\n${text}`);
} else if (role === "assistant") {
lines.push(`[Assistant]\n${text}`);
} else if (role === "tool") {
lines.push(`[Tool]\n${text}`);
} else {
lines.push(`[User]\n${text}`);
}
}
return lines.join("\n\n") || "(empty)";
}
// ─── DevinCliExecutor ─────────────────────────────────────────────────────────
export class DevinCliExecutor extends BaseExecutor {
constructor() {
super("devin-cli", { id: "devin-cli", baseUrl: "devin://acp/stdio" });
}
buildUrl() {
return "devin://acp/stdio";
}
buildHeaders() {
return {};
}
transformRequest() {
return null;
}
async execute({ model, body, credentials, signal, log }) {
const b = body ?? {};
const messages = Array.isArray(b.messages)
? b.messages
: Array.isArray(b.input)
? b.input
: [];
const promptText = buildPromptText(messages);
const workspaceCwd = resolveWorkspaceCwd(b);
const devinBin = resolveDevinBin();
log?.info?.(
"DEVIN",
`devin acp → model=${model}, bin=${devinBin}, cwd=${workspaceCwd}`
);
// Optional MCP servers via DEVIN_MCP_SERVERS (JSON object, devin config format):
// {"echo":{"command":"/abs/node","args":["/srv/echo.js"],"env":{"K":"V"}}}
// Plus body.tools (OpenAI schema) → exposed as a "clientTools" MCP
// server so devin can invoke client-defined tools (bridged back in Phase 2).
// When any are present, a throwaway XDG_CONFIG_HOME holds devin/config.json so
// the agent auto-connects them (session/new mcpServers alone doesn't spawn
// them — see ACP mcp/connect, still unstable). Cleaned up on finish.
// NOTE: this replaces the user's global devin MCP config for the subprocess.
let mcpConfigDir = null;
const mcpServers = {};
const mcpJson = process.env.DEVIN_MCP_SERVERS?.trim();
if (mcpJson) {
try {
Object.assign(mcpServers, JSON.parse(mcpJson));
} catch (e) {
log?.info?.("DEVIN", `DEVIN_MCP_SERVERS parse failed: ${e.message}`);
}
}
const clientTools = Array.isArray(b.tools) ? b.tools.filter(Boolean) : [];
const clientToolResults = extractClientToolResults(messages);
const clientToolsMcp = buildClientToolsMcp(clientTools, clientToolResults);
const hasClientTools = !!clientToolsMcp;
if (clientToolsMcp) {
mcpServers["clientTools"] = clientToolsMcp;
const seeded = Object.keys(clientToolResults).length;
log?.info?.(
"DEVIN",
`exposing ${clientTools.length} client tool(s) as MCP` +
(seeded ? ` (seeded ${seeded} result(s))` : "")
);
}
if (Object.keys(mcpServers).length) {
try {
mcpConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "devin-mcp-"));
const cfgDev = path.join(mcpConfigDir, "devin");
fs.mkdirSync(cfgDev, { recursive: true });
fs.writeFileSync(
path.join(cfgDev, "config.json"),
JSON.stringify({ mcpServers })
);
log?.info?.("DEVIN", `mcp config written → ${mcpConfigDir}`);
} catch (e) {
log?.info?.("DEVIN", `mcp config write failed: ${e.message}`);
mcpConfigDir = null;
}
}
const cleanupMcp = () => {
if (!mcpConfigDir) return;
try {
fs.rmSync(mcpConfigDir, { recursive: true, force: true });
} catch {
/* ignore */
}
mcpConfigDir = null;
};
const sseStream = new ReadableStream({
start(controller) {
const enc = new TextEncoder();
const emit = (data) => controller.enqueue(enc.encode(data));
// Inherit the parent environment so devin resolves stored CLI credentials
// (~/.local/share/devin/credentials.toml from `devin auth login`). Do NOT
// inject WINDSURF_API_KEY: this provider is noAuth, and a bogus/leaked key
// overrides stored creds and makes devin return -32000 "invalid api key".
const env = { ...process.env };
// Auto-approve tool execution so the agent doesn't block waiting for a
// session/request_permission response we never send (default mode would
// hang the stream on the first shell/exec tool call). Override via env.
// WARNING: bypass lets the agent run shell/modify FS unattended — local only.
env.DEVIN_PERMISSION_MODE = process.env.DEVIN_PERMISSION_MODE || "bypass";
if (mcpConfigDir) env.XDG_CONFIG_HOME = mcpConfigDir;
// Agent type: default (omitted) = full agent with built-in tools
// (fs/shell/search) so the model can actually perform tasks. Override to
// `summarizer` (no tools, text-only) via CLI_DEVIN_AGENT_TYPE for a safer,
// tool-less mode. WARNING: the default agent can run shell commands and
// modify the filesystem on the host running 9router — only expose locally.
const agentType = process.env.CLI_DEVIN_AGENT_TYPE?.trim();
const acpArgs = ["acp"];
if (agentType) acpArgs.push("--agent-type", agentType);
// Spawn in the client workspace cwd (from <cwd> env context) so built-in
// file tools create/delete relative paths in the user's project.
// MCP config still comes from XDG_CONFIG_HOME (throwaway), not project .devin/.
const child = spawn(devinBin, acpArgs, {
env,
cwd: workspaceCwd,
stdio: ["pipe", "pipe", "pipe"],
// On Windows, devin.exe may need shell resolution
shell: process.platform === "win32",
});
let spawnError = null;
let stdinClosed = false;
child.on("error", (err) => {
spawnError = err;
const msg =
err.message.includes("ENOENT") || err.message.includes("not found")
? `Devin CLI not found: ${devinBin}. Install via https://cli.devin.ai or set CLI_DEVIN_BIN env var.`
: `Devin CLI spawn error: ${err.message}`;
emit(
`data: ${JSON.stringify({ error: { message: msg, type: "devin_cli_error", code: "spawn_failed" } })}\n\n`
);
emit("data: [DONE]\n\n");
controller.close();
});
if (signal) {
signal.addEventListener("abort", () => {
if (!child.killed) child.kill("SIGTERM");
});
}
// ── JSON-RPC state machine ──────────────────────────────────────────
let idCounter = 1;
let sessionId = null;
let initDone = false;
let sessionCreated = false;
let promptSent = false;
const responseId = `chatcmpl-devin-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
let roleEmitted = false;
let totalText = "";
let finished = false;
const sendRpc = (method, params) => {
if (stdinClosed || child.stdin.destroyed) return;
const id = idCounter++;
try {
child.stdin.write(rpc(method, params, id));
} catch {
/* ignore write errors after close */
}
return id;
};
// Emit a content delta as an OpenAI-compatible SSE chunk (handles the
// leading role chunk once).
const emitDelta = (delta) => {
if (!roleEmitted) {
emit(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }],
})}\n\n`
);
roleEmitted = true;
}
totalText += delta;
emit(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { content: delta }, finish_reason: null }],
})}\n\n`
);
};
// Emit an OpenAI tool_call delta (function calling). Ends the turn with
// finish_reason "tool_calls" so the client executes and returns tool_result.
let toolUseEmitted = false;
// ACP tool_call is upsert-by-id: the first event has title, a later update
// may only carry rawInput (title omitted). Track pending client-tool calls.
const pendingClientTools = new Map(); // toolCallId → original tool name
const emitToolUse = (toolName, args, toolCallId) => {
const argsStr = typeof args === "string" ? args : JSON.stringify(args ?? {});
if (!roleEmitted) {
emit(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { role: "assistant", content: null }, finish_reason: null }],
})}\n\n`
);
roleEmitted = true;
}
emit(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: toolCallId,
type: "function",
function: { name: toolName, arguments: argsStr },
},
],
},
finish_reason: null,
},
],
})}\n\n`
);
};
const finish = (error, finishReason = "stop") => {
if (finished) return;
finished = true;
if (error) {
emit(
`data: ${JSON.stringify({ error: { message: error, type: "devin_cli_error" } })}\n\n`
);
} else {
// Emit finish chunk
emit(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: {}, finish_reason: finishReason }],
usage: {
prompt_tokens: Math.ceil(promptText.length / 4),
completion_tokens: Math.ceil(totalText.length / 4),
total_tokens: Math.ceil((promptText.length + totalText.length) / 4),
estimated: true,
},
})}\n\n`
);
}
emit("data: [DONE]\n\n");
// Gracefully close stdin → devin will exit
try {
if (!stdinClosed) {
stdinClosed = true;
child.stdin.end();
}
} catch {
/* ignore */
}
// Give it 2s to exit cleanly, then SIGKILL
const killTimer = setTimeout(() => {
if (!child.killed) child.kill("SIGKILL");
}, 2000);
killTimer.unref?.();
controller.close();
cleanupMcp();
};
// ── stdout reader (NDJSON) ──────────────────────────────────────────
let buffer = "";
child.stdout.on("data", (chunk) => {
buffer += chunk.toString("utf8");
let nl;
// Each ACP message is a newline-terminated JSON line
while ((nl = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (!line) continue;
let msg;
try {
msg = JSON.parse(line);
} catch {
continue; // ignore non-JSON lines (banner text, etc.)
}
// ── Initialize response ───────────────────────────────────────
if (!initDone && msg.result !== undefined && !msg.method) {
initDone = true;
// Create session with the client workspace cwd so agent file tools
// resolve relative paths against the project (not /tmp).
// `mcpServers` is required by devin 3000.2.x (must be a sequence);
// omitting it returns -32602 "Invalid params: missing field mcpServers".
sendRpc("session/new", {
cwd: workspaceCwd,
mcpServers: [],
model: model || undefined,
});
continue;
}
// ── session/new response → get sessionId ──────────────────────
if (initDone && !sessionCreated && msg.result !== undefined && !msg.method) {
const res = msg.result || {};
sessionId = res.sessionId || null;
if (!sessionId) {
finish("Devin ACP: session/new returned no sessionId");
return;
}
sessionCreated = true;
// Send the prompt. devin 3000.2.x expects `prompt` (a sequence),
// not `content` — using `content` returns -32602 "missing field prompt".
promptSent = true;
sendRpc("session/prompt", {
sessionId,
prompt: [{ type: "text", text: promptText }],
});
continue;
}
// ── session/prompt response (ack / final result) ────────────
if (sessionCreated && promptSent && msg.result !== undefined && !msg.method) {
// Devin 3000.2.x only resolves session/prompt with the final result
// (stopReason) after streaming completes. Streaming notifications are
// handled below; nothing to do here unless we never streamed.
if (!roleEmitted) {
const res = msg.result || undefined;
const content = extractResultText(res);
if (content) {
totalText = content;
emitDelta(content);
}
const stopReason = (res && res.stopReason) || "";
if (stopReason && stopReason !== "cancelled") {
finish();
return;
}
}
continue;
}
// ── Permission requests → auto-approve the first allow option ──
// Devi asks before running shell/exec tools; as a headless proxy we
// grant once. (DEVIN_PERMISSION_MODE=bypass usually prevents these,
// but some tool kinds still prompt, so handle them here too.)
if (msg.method === "session/request_permission" && msg.id !== undefined) {
const options = msg.params?.options || [];
const allow =
options.find((o) => /allow/i.test(String(o.kind || ""))) || options[0];
if (allow) {
child.stdin.write(
JSON.stringify({
jsonrpc: "2.0",
id: msg.id,
result: { outcome: { outcome: "selected", optionId: allow.optionId } },
}) + "\n"
);
}
continue;
}
// ── Agent stopped notification (devin 3000.2.x stop signal) ───
if (msg.method === "_cognition.ai/agent_stopped" || msg.method === "$/agent_stopped") {
const cause = msg.params?.cause;
if (cause === "error") {
// devin uses errorMessage on this notification (not message/error).
const errText =
msg.params?.errorMessage ||
msg.params?.message ||
msg.params?.error ||
"Devin agent error";
finish(String(errText));
} else {
finish();
}
return;
}
// ── Streaming notifications (session/update) ──────────────────
if (msg.method === "session/update" || msg.method === "$/update") {
const params = msg.params;
if (!params) continue;
// devin 3000.2.x nests the payload under params.update.sessionUpdate;
// older devin used a flat params.type.
const update = params.update || {};
const type = update.sessionUpdate || params.type;
const contentField = update.content !== undefined ? update.content : params.content;
const deltaText =
typeof contentField === "string"
? contentField
: contentField?.text ?? params.delta ?? params.text ?? "";
// ── Client-tool bridge: devin calling a tool from our exposed MCP ──
// ACP title shape: "Calling mcp_<name> from clientTools".
// tool_call is upsert-by-id: title may only appear on the first event,
// rawInput on a later tool_call_update. Track pending ids so we don't
// require both fields on the same notification.
if (
hasClientTools &&
!toolUseEmitted &&
(type === "tool_call" || type === "tool_call_update")
) {
const tcId = update.toolCallId;
if (typeof update.title === "string" && update.title.startsWith("Calling mcp_") && /from clientTools\b/.test(update.title)) {
const nameMatch = update.title.match(/^Calling (mcp_\S+)\b/);
const mcpName = nameMatch ? nameMatch[1] : "";
const origName = fromMcpToolName(mcpName);
if (tcId && origName) pendingClientTools.set(tcId, origName);
}
const origName = tcId ? pendingClientTools.get(tcId) : null;
if (origName && update.rawInput) {
toolUseEmitted = true;
pendingClientTools.delete(tcId);
emitToolUse(origName, update.rawInput, tcId || `call_${Date.now()}`);
finish(null, "tool_calls");
return;
}
continue;
}
if (type === "agent_message_chunk" || type === "message_delta" || type === "text_delta" || type === "content_delta") {
if (deltaText) emitDelta(deltaText);
} else if (type === "agent_thought_chunk") {
// Internal reasoning — not surfaced to the client.
} else if (type === "message_stop" || type === "stop" || type === "done") {
finish();
return;
} else if (type === "error") {
finish(String(params.message || params.error || "Devin ACP error"));
return;
}
continue;
}
// ── Error responses ───────────────────────────────────────────
if (msg.error) {
finish(`Devin ACP error ${msg.error.code}: ${msg.error.message}`);
return;
}
}
});
child.stderr.on("data", (chunk) => {
log?.debug?.("DEVIN", `stderr: ${chunk.toString("utf8").slice(0, 200)}`);
});
child.on("close", (code) => {
if (!finished) {
if (code !== 0 && !spawnError) {
finish(roleEmitted ? undefined : `Devin CLI exited with code ${code}`);
} else {
finish();
}
} else {
cleanupMcp();
}
});
// ── Send initialize ───────────────────────────────────────────────
sendRpc("initialize", {
protocolVersion: "0.3",
clientInfo: { name: "9router", version: "1.0" },
capabilities: {},
});
},
});
return {
response: new Response(sseStream, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
}),
url: "devin://acp/stdio",
headers: {},
transformedBody: {
model,
cwd: workspaceCwd,
clientTools: clientTools.map((t) => t?.function?.name || t?.name).filter(Boolean),
clientToolResults: Object.keys(clientToolResults),
mcpServers: Object.keys(mcpServers),
promptLength: Array.isArray(body?.messages)
? body.messages.length
: Array.isArray(body?.input)
? body.input.length
: 0,
},
};
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
// Extract text from a final ACP session/prompt result object across common shapes.
function extractResultText(result) {
// { message: { content: "..." } }
// { messages: [{ content: "..." }] }
// { content: "..." }
// { text: "..." }
if (typeof result.content === "string") return result.content;
if (typeof result.text === "string") return result.text;
const msg = result.message;
if (msg && typeof msg.content === "string") return msg.content;
const msgs = result.messages;
if (Array.isArray(msgs)) {
return msgs
.filter((m) => m.role === "assistant")
.map((m) => String(m.content || ""))
.join("\n");
}
return "";
}
export default DevinCliExecutor;

View File

@@ -4,13 +4,11 @@ import { OAUTH_ENDPOINTS, GITHUB_COPILOT } from "../config/appConstants.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { openaiToOpenAIResponsesRequest } from "../translator/request/openai-responses.js";
import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js";
import { initState, translateRequest, translateResponse } from "../translator/index.js";
import { FORMATS } from "../translator/formats.js";
import { initState } from "../translator/index.js";
import { parseSSELine, formatSSE } from "../utils/streamHelpers.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { stripUnsupportedParams } from "../translator/concerns/paramSupport.js";
import { SSE_DONE } from "../utils/sseConstants.js";
import { ANTHROPIC_API_VERSION } from "../providers/shared.js";
import crypto from "crypto";
export class GithubExecutor extends BaseExecutor {
@@ -19,16 +17,6 @@ export class GithubExecutor extends BaseExecutor {
this.knownCodexModels = new Set();
}
// Claude models get routed to Copilot's Anthropic-native /v1/messages shim (see
// executeWithMessagesEndpoint below) — the only Copilot endpoint that surfaces
// prompt-cache token counts. gpt/gemini/grok models stay on /chat/completions
// (or /responses). Name-pattern check, not a registry field: Copilot's live model
// catalog (services/copilotModels.js) regularly exposes claude-* variants ahead
// of the static registry (registry/github.js).
isClaudeModel(model) {
return /claude/i.test(model || "");
}
buildUrl(model, stream, urlIndex = 0) {
return this.config.baseUrl;
}
@@ -47,20 +35,47 @@ export class GithubExecutor extends BaseExecutor {
"x-request-id": crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`,
"x-vscode-user-agent-library-version": "electron-fetch",
"X-Initiator": "user",
// Harmless no-op on /chat/completions and /responses; required by /v1/messages.
"anthropic-version": ANTHROPIC_API_VERSION,
"Accept": stream ? "text/event-stream" : "application/json"
};
}
// Sanitize messages for GitHub Copilot /chat/completions endpoint (gpt/gemini/grok models —
// claude models never reach this, see execute() below).
// Sanitize messages for GitHub Copilot /chat/completions endpoint.
// The endpoint only accepts 'text' and 'image_url' content part types.
// Tool-related content (tool_use, tool_result, thinking) must be serialized as text.
sanitizeMessagesForChatCompletions(body) {
if (!body?.messages) return body;
const sanitized = { ...body };
// Handle response_format for Claude models via GitHub
// GitHub's internal translation doesn't respect response_format, so we inject it as a system prompt
// AND prepend a reminder to the last user message for maximum effectiveness
if (body.response_format && body.model?.includes('claude')) {
const responseFormat = body.response_format;
let systemInstruction = '';
if (responseFormat.type === 'json_schema' && responseFormat.json_schema?.schema) {
systemInstruction = 'CRITICAL: You must ONLY output raw JSON. Never use markdown code blocks. Never use backticks. Never wrap JSON in triple backticks. Output ONLY the raw JSON object.';
} else if (responseFormat.type === 'json_object') {
systemInstruction = 'CRITICAL: You must ONLY output raw JSON. Never use markdown code blocks. Never use backticks.';
}
if (systemInstruction) {
// Add to system message
const systemIdx = body.messages.findIndex(m => m.role === 'system');
if (systemIdx >= 0) {
body.messages[systemIdx].content = systemInstruction + '\n\n' + body.messages[systemIdx].content;
} else {
body.messages.unshift({ role: 'system', content: systemInstruction });
}
// Also prepend to the last user message as a reminder
const lastUserIdx = body.messages.map((m, i) => m.role === 'user' ? i : -1).filter(i => i >= 0).pop();
if (lastUserIdx >= 0) {
const userMsg = body.messages[lastUserIdx];
const userContent = typeof userMsg.content === 'string' ? userMsg.content : JSON.stringify(userMsg.content);
userMsg.content = 'Respond with ONLY raw JSON (no markdown, no backticks, no code blocks): ' + userContent;
}
}
}
sanitized.messages = body.messages.map(msg => {
// assistant messages with only tool_calls have content: null — leave as-is
if (!msg.content) return msg;
@@ -123,15 +138,6 @@ export class GithubExecutor extends BaseExecutor {
async execute(options) {
const { model, log } = options;
// Claude models: route to Copilot's Anthropic-native /v1/messages shim — the only
// Copilot endpoint that surfaces prompt-cache token counts for Claude. Detected by
// model NAME (not a registry field): Copilot's live model catalog regularly exposes
// claude-* variants the static registry hasn't caught up with yet (see registry/github.js).
if (this.isClaudeModel(model)) {
log?.debug("GITHUB", `Using /v1/messages route for ${model}`);
return this.executeWithMessagesEndpoint(options);
}
// Only use /responses for models that are explicitly known to need it (e.g. gpt codex models)
// and that the /responses endpoint actually serves (excludes Gemini/Claude, see #1062).
if (this.knownCodexModels.has(model) && this.supportsResponsesEndpoint(model)) {
@@ -139,8 +145,8 @@ export class GithubExecutor extends BaseExecutor {
return this.executeWithResponsesEndpoint(options);
}
// Sanitize messages before sending to /chat/completions (gpt/gemini/grok — the
// endpoint rejects non-text/image_url content parts).
// Sanitize messages before sending to /chat/completions
// This handles Claude models on GitHub Copilot which reject non-text/image_url content types
const sanitizedOptions = {
...options,
body: this.sanitizeMessagesForChatCompletions(options.body)
@@ -245,101 +251,6 @@ export class GithubExecutor extends BaseExecutor {
};
}
// Claude models arrive here OpenAI-shape (chatCore.js targets "openai" for github —
// see the note in execute() above), so we translate to Anthropic-native ourselves.
// This is what makes prepareClaudeRequest() (translator/formats/claude.js) inject
// cache_control — /chat/completions never gets there, so it never sees cache tokens.
async executeWithMessagesEndpoint({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
const url = this.config.messagesUrl;
const headers = this.buildHeaders(credentials, stream);
// Force stream:true upstream regardless of client preference, same as
// executeWithResponsesEndpoint below — chatCore.js's non-streaming handler already
// knows how to buffer an SSE response into a single JSON reply when the client
// asked for stream:false.
const transformedBody = translateRequest(FORMATS.OPENAI, FORMATS.CLAUDE, model, body, true, credentials, "github");
// _toolNameMap is internal bookkeeping (see openai-to-claude.js) — chatCore.js
// normally strips it before dispatch and threads it into the response state to
// restore original tool names; we must do the same here, or Anthropic's strict
// schema rejects the extra field with a 400.
const toolNameMap = transformedBody._toolNameMap;
delete transformedBody._toolNameMap;
log?.debug("GITHUB", "Sending translated request to /v1/messages");
const response = await proxyAwareFetch(url, {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal
}, proxyOptions);
if (!response.ok) {
return { response, url, headers, transformedBody };
}
const state = initState(FORMATS.CLAUDE);
state.model = model;
if (toolNameMap) state.toolNameMap = toolNameMap;
const decoder = new TextDecoder();
let buffer = "";
const emitAll = (controller, chunks) => {
for (const c of chunks) {
controller.enqueue(new TextEncoder().encode(formatSSE(c, "openai")));
}
};
const transformStream = new TransformStream({
async transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const parsed = parseSSELine(trimmed);
if (!parsed) continue;
if (parsed.done && stream === true) {
controller.enqueue(new TextEncoder().encode(SSE_DONE));
continue;
}
emitAll(controller, translateResponse(FORMATS.CLAUDE, FORMATS.OPENAI, parsed, state));
}
},
flush(controller) {
if (buffer.trim()) {
const parsed = parseSSELine(buffer.trim());
if (parsed && !parsed.done) {
emitAll(controller, translateResponse(FORMATS.CLAUDE, FORMATS.OPENAI, parsed, state));
}
}
}
});
if (!response.body) {
return { response: new Response("", { status: response.status, headers: response.headers }), url, headers, transformedBody };
}
const convertedStream = response.body.pipeThrough(transformStream);
return {
response: new Response(convertedStream, {
status: response.status,
statusText: response.statusText,
headers: response.headers
}),
url,
headers,
transformedBody
};
}
async refreshCopilotToken(githubAccessToken, log, proxyOptions = null) {
try {
const response = await proxyAwareFetch("https://api.github.com/copilot_internal/v2/token", {

View File

@@ -1,552 +0,0 @@
import crypto from "node:crypto";
import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js";
import {
refreshProviderCredentials,
shouldRefreshCredentials,
} from "../services/oauthCredentialManager.js";
import { normalizeResponsesInput } from "../translator/formats/responsesApi.js";
import { getModelUpstreamId } from "../config/providerModels.js";
import {
GROK_CLI_CLIENT_IDENTIFIER,
GROK_CLI_VERSION,
supportsGrokCliReasoningEffort,
} from "../config/grokCli.js";
import { MEMORY_CONFIG } from "../config/runtimeConfig.js";
import { resolveSessionId } from "../utils/sessionManager.js";
import { getConsistentMachineId } from "../shared/machineId.js";
// Server-generated item id prefixes that /responses cannot resolve when store=false
const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/;
// Hosted tool types executed server-side by Grok CLI backend
const HOSTED_TOOL_TYPES = new Set([
"web_search",
"x_search",
"web_search_preview",
"file_search",
"image_generation",
"code_interpreter",
"mcp",
"local_shell",
]);
// Fields accepted by cli-chat-proxy Responses API (mirrors Codex allowlist + Grok extras)
const RESPONSES_API_ALLOWLIST = new Set([
"model",
"input",
"instructions",
"tools",
"tool_choice",
"stream",
"store",
"reasoning",
"include",
"temperature",
"top_p",
"max_output_tokens",
"parallel_tool_calls",
"text",
"metadata",
"prompt_cache_key",
]);
const EFFORT_LEVELS = ["low", "medium", "high", "xhigh"];
const GROK_CLI_TURN_STORE_MAX = 5000;
const GROK_CLI_NATIVE_ITEM_ID = /^(?:rs|msg|fc)_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const GROK_CLI_FREEFORM_TOOL_PARAMETERS = {
type: "object",
properties: { input: { type: "string" } },
required: ["input"],
};
// Per-session last turn index so multi-turn headers never go backwards within this process
const sessionTurnStore = new Map();
let requestTurnStore = new WeakMap();
/**
* Count user turns in a Responses `input` array.
* Official CLI sets x-grok-turn-idx to the 1-based conversation turn (≈ user messages).
* HAR: first chat turn → "1".
*/
export function countGrokCliUserTurns(input) {
if (!Array.isArray(input)) return 1;
let n = 0;
for (const item of input) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const type = typeof item.type === "string" ? item.type : "";
// Responses message items (type omitted or "message") with role user
if (item.role === "user" && (!type || type === "message")) n += 1;
}
return Math.max(1, n);
}
/**
* Resolve monotonic turn index for a session.
* Prefers user-message count from the payload (full history clients), but never
* decreases vs the last index observed for the same sessionId in this process.
*/
export function resolveGrokCliTurnIdx(sessionId, input, requestKey = null) {
const fromInput = countGrokCliUserTurns(input);
if (!sessionId) return fromInput;
if (requestKey && requestTurnStore.has(requestKey)) {
return requestTurnStore.get(requestKey);
}
const now = Date.now();
const existing = sessionTurnStore.get(sessionId);
const prev = existing && now - existing.lastUsed <= MEMORY_CONFIG.sessionTtlMs
? existing.turn
: 0;
if (existing) sessionTurnStore.delete(sessionId);
// A new delta-style request advances the turn; retries reuse requestKey.
const turn = prev > 0 ? Math.max(fromInput, prev + (requestKey ? 1 : 0)) : fromInput;
while (sessionTurnStore.size >= GROK_CLI_TURN_STORE_MAX) {
sessionTurnStore.delete(sessionTurnStore.keys().next().value);
}
sessionTurnStore.set(sessionId, { turn, lastUsed: now });
if (requestKey) requestTurnStore.set(requestKey, turn);
return turn;
}
/** Test helper — clear in-memory turn counters */
export function _resetGrokCliTurnStore() {
sessionTurnStore.clear();
requestTurnStore = new WeakMap();
}
export function _getGrokCliTurnStoreSize() {
return sessionTurnStore.size;
}
export function normalizeGrokCliEffort(value) {
const effort = typeof value === "string" ? value.trim().toLowerCase() : "";
if (effort === "max") return "xhigh";
if (EFFORT_LEVELS.includes(effort)) return effort;
return "high";
}
export { supportsGrokCliReasoningEffort } from "../config/grokCli.js";
export function resolveGrokCliSessionId(credentials, body) {
// ponytail: clients without stable thread metadata share one connection session;
// split further when their wire format exposes a durable conversation id.
const explicitSessionBody = {
prompt_cache_key: body?.prompt_cache_key,
session_id: body?.session_id,
conversation_id: body?.conversation_id,
metadata: body?.metadata,
};
return resolveSessionId({
headers: credentials?.rawHeaders,
body: explicitSessionBody,
connectionId: credentials?.connectionId || credentials?.id,
workspaceId: credentials?.providerSpecificData?.workspaceId,
scope: "grok-cli",
});
}
function stringifyGrokCliToolOutput(output) {
if (typeof output === "string") return output;
if (output === undefined) return "";
return JSON.stringify(output);
}
function isNativeGrokCliItemId(id) {
return typeof id === "string" && GROK_CLI_NATIVE_ITEM_ID.test(id);
}
function normalizeGrokCliInputItem(item) {
if (!item || typeof item !== "object" || Array.isArray(item)) return item;
const { internal_chat_message_metadata_passthrough: _metadata, ...clean } = item;
if (item.type === "reasoning") {
if (!isNativeGrokCliItemId(item.id) || typeof item.encrypted_content !== "string") return null;
return clean;
}
if (item.type === "custom_tool_call") {
const callId = item.call_id || item.id;
const name = typeof item.name === "string" ? item.name.trim() : "";
if (!callId || !name) return null;
return {
type: "function_call",
call_id: callId,
name,
arguments: JSON.stringify({ input: stringifyGrokCliToolOutput(item.input ?? item.arguments) }),
};
}
if (item.type === "custom_tool_call_output" || item.type === "function_call_output") {
const callId = item.call_id || item.id;
if (!callId) return null;
return {
type: "function_call_output",
call_id: callId,
output: stringifyGrokCliToolOutput(item.output),
};
}
if (item.type === "function_call") {
const callId = item.call_id || item.id;
const name = typeof item.name === "string" ? item.name.trim() : "";
if (!callId || !name) return null;
return {
type: "function_call",
...(isNativeGrokCliItemId(item.id) ? { id: item.id } : {}),
call_id: callId,
name,
arguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments ?? {}),
...(typeof item.status === "string" ? { status: item.status } : {}),
};
}
return clean;
}
export function normalizeGrokCliInput(body) {
if (!Array.isArray(body?.input)) return body;
const normalized = body.input.map(normalizeGrokCliInputItem).filter(Boolean);
const callIds = new Set(
normalized
.filter((item) => item?.type === "function_call" && item.call_id)
.map((item) => item.call_id)
);
body.input = normalized.filter(
(item) => item?.type !== "function_call_output" || callIds.has(item.call_id)
);
return body;
}
function stripStoredItemReferences(body) {
if (!Array.isArray(body.input)) return;
body.input = body.input.filter((item) => {
if (typeof item === "string" && SERVER_ID_PATTERN.test(item)) return false;
if (item && typeof item === "object" && !Array.isArray(item)) {
if (item.type === "item_reference") return false;
if (
typeof item.id === "string" &&
SERVER_ID_PATTERN.test(item.id) &&
!isNativeGrokCliItemId(item.id)
) delete item.id;
}
return true;
});
}
/**
* Flatten Chat Completions tool shape → Responses flat format.
* Keep hosted tools (web_search / x_search) passthrough.
*/
function normalizeGrokCliTools(body) {
if (!Array.isArray(body.tools) || body.tools.length === 0) {
delete body.tools;
delete body.tool_choice;
return;
}
const validNames = new Set();
const hostedTypes = new Set();
body.tools = body.tools.filter((tool) => {
if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false;
const type = typeof tool.type === "string" ? tool.type : "";
if (type !== "function") {
// Hosted tools: { type: "web_search" } / { type: "x_search" }
if (HOSTED_TOOL_TYPES.has(type)) {
hostedTypes.add(type);
return true;
}
// Nested function shape without type
if (!type && tool.function) {
// fall through to function flatten below
} else if (!type || typeof tool.name === "string") {
// treat as bare function if name present
} else {
return false;
}
}
const isFunction =
type === "function" || type === "" || tool.function || typeof tool.name === "string";
if (!isFunction || HOSTED_TOOL_TYPES.has(type)) {
return HOSTED_TOOL_TYPES.has(type);
}
const fn =
tool.function && typeof tool.function === "object" && !Array.isArray(tool.function)
? tool.function
: null;
const rawName =
typeof tool.name === "string" ? tool.name : typeof fn?.name === "string" ? fn.name : "";
const name = rawName.trim();
if (!name) return false;
const description =
typeof tool.description === "string"
? tool.description
: typeof fn?.description === "string"
? fn.description
: "";
const parameters = type === "custom"
? GROK_CLI_FREEFORM_TOOL_PARAMETERS
: tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters)
? tool.parameters
: fn?.parameters && typeof fn.parameters === "object" && !Array.isArray(fn.parameters)
? fn.parameters
: { type: "object", properties: {} };
for (const k of Object.keys(tool)) delete tool[k];
tool.type = "function";
tool.name = name.slice(0, 128);
if (description) tool.description = description;
tool.parameters = parameters;
validNames.add(tool.name);
return true;
});
if (body.tools.length === 0) {
delete body.tools;
delete body.tool_choice;
return;
}
if (body.tool_choice && typeof body.tool_choice === "object" && !Array.isArray(body.tool_choice)) {
const choiceType = typeof body.tool_choice.type === "string" ? body.tool_choice.type : "";
if (choiceType === "function" || choiceType === "custom") {
const rawName = body.tool_choice.name ?? body.tool_choice.function?.name;
const name = typeof rawName === "string" ? rawName.trim().slice(0, 128) : "";
if (!name || !validNames.has(name)) delete body.tool_choice;
else body.tool_choice = { type: "function", name };
} else if (!hostedTypes.has(choiceType)) {
delete body.tool_choice;
}
}
}
function resolveEffortFromModel(modelId) {
if (!modelId || typeof modelId !== "string") return null;
for (const level of EFFORT_LEVELS) {
if (modelId.endsWith(`-${level}`)) return level;
}
return null;
}
/**
* Grok CLI Executor — OpenAI Responses API on cli-chat-proxy.grok.com
* Auth: OAuth device-code access token (xai-grok-cli).
*/
export class GrokCliExecutor extends BaseExecutor {
constructor() {
super("grok-cli", PROVIDERS["grok-cli"]);
this._currentSessionId = null;
this._currentReqId = null;
this._currentTurnIdx = 1;
this._agentId = null;
}
buildUrl() {
return this.config.baseUrl;
}
async refreshCredentials(credentials, log, proxyOptions = null) {
if (!credentials?.refreshToken) return null;
return refreshProviderCredentials("grok-cli", credentials, log, proxyOptions);
}
needsRefresh(credentials) {
return shouldRefreshCredentials("grok-cli", credentials);
}
buildHeaders(credentials, stream = true) {
const headers = super.buildHeaders(credentials, stream);
// Static fingerprint from registry
const staticHeaders = this.config.headers || {};
for (const [k, v] of Object.entries(staticHeaders)) {
if (v != null && headers[k] === undefined) headers[k] = v;
}
headers["x-grok-client-identifier"] =
this.config.clientIdentifier || headers["x-grok-client-identifier"] || GROK_CLI_CLIENT_IDENTIFIER;
headers["x-grok-client-version"] =
this.config.clientVersion || headers["x-grok-client-version"] || GROK_CLI_VERSION;
const sessionId = this._currentSessionId || credentials?.connectionId || crypto.randomUUID();
const reqId = this._currentReqId || crypto.randomUUID();
headers["x-grok-session-id"] = sessionId;
// CLI uses the same id for conv + session on chat turns
headers["x-grok-conv-id"] = sessionId;
headers["x-grok-req-id"] = reqId;
headers["x-grok-turn-idx"] = String(this._currentTurnIdx || 1);
if (this._agentId) headers["x-grok-agent-id"] = this._agentId;
// Surface model override (CLI always sets this)
if (this._currentModel) headers["x-grok-model-override"] = this._currentModel;
// Identity: mapTokens stores email top-level AND in providerSpecificData;
// fall back either way so OAuth connections always fingerprint like the CLI.
const psd = credentials?.providerSpecificData || {};
const email = psd.email || credentials?.email;
const userId = psd.userId || credentials?.userId || credentials?.providerUserId;
if (email) headers["x-email"] = email;
if (userId) headers["x-userid"] = userId;
return headers;
}
parseError(response, bodyText) {
// 402 personal-team-blocked:spending-limit → surface as payment/quota for fallback
if (response.status === 402 && bodyText) {
try {
const json = JSON.parse(bodyText);
const code = json?.code || "";
const msg = json?.error || json?.message || bodyText;
return {
status: 402,
message: typeof msg === "string" ? msg : bodyText,
code: typeof code === "string" ? code : undefined,
};
} catch {
/* fall through */
}
}
return super.parseError(response, bodyText);
}
transformRequest(model, body, stream, credentials) {
// Session / request ids for headers — stable per client conversation when possible
const requestKey = body;
this._currentSessionId = resolveGrokCliSessionId(credentials, body);
this._currentReqId = crypto.randomUUID();
this._agentId =
credentials?.providerSpecificData?.deviceId ||
credentials?.providerSpecificData?.agentId ||
null;
// Normalize Responses input
const normalized = normalizeResponsesInput(body.input);
if (normalized) body.input = normalized;
// Chat Completions clients arrive with messages[] — translator should have
// converted already, but guard empty input.
if (!body.input || (Array.isArray(body.input) && body.input.length === 0)) {
if (Array.isArray(body.messages) && body.messages.length > 0) {
// Soft fallback: map messages → input messages (string content only)
body.input = body.messages.map((m) => ({
type: "message",
role: m.role || "user",
content: typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""),
}));
delete body.messages;
} else {
body.input = [{ type: "message", role: "user", content: "..." }];
}
}
// Keep role:"system" as-is — official grok-pager HAR sends system, not developer
// (Codex converts system→developer; Grok CLI does not).
normalizeGrokCliInput(body);
stripStoredItemReferences(body);
normalizeGrokCliTools(body);
// Turn index after input is finalized (user-message count, monotonic per session)
this._currentTurnIdx = resolveGrokCliTurnIdx(this._currentSessionId, body.input, requestKey);
body.stream = true;
body.store = false;
// Resolve upstream model id (strip effort suffix virtual models)
let modelEffort = resolveEffortFromModel(body.model || model);
let resolvedModel = body.model || model;
if (modelEffort) {
resolvedModel = resolvedModel.replace(new RegExp(`-${modelEffort}$`), "");
}
resolvedModel = getModelUpstreamId("gcli", resolvedModel) || resolvedModel;
// Also try provider id key
if (resolvedModel === (body.model || model)) {
resolvedModel = getModelUpstreamId("grok-cli", resolvedModel) || resolvedModel;
}
body.model = resolvedModel;
this._currentModel = resolvedModel;
// Reasoning effort priority: explicit > reasoning_effort > model suffix > default high.
// grok-build and Composer reject reasoningEffort but still accept summary/encrypted continuity.
const supportsReasoningEffort = supportsGrokCliReasoningEffort(resolvedModel);
if (!body.reasoning || typeof body.reasoning !== "object") {
body.reasoning = { summary: "concise" };
if (supportsReasoningEffort) {
body.reasoning.effort = normalizeGrokCliEffort(body.reasoning_effort || modelEffort);
}
} else {
if (supportsReasoningEffort) {
body.reasoning.effort = normalizeGrokCliEffort(
body.reasoning.effort || body.reasoning_effort || modelEffort,
);
} else {
delete body.reasoning.effort;
}
if (!body.reasoning.summary) body.reasoning.summary = "concise";
}
delete body.reasoning_effort;
// Encrypted reasoning for multi-turn continuity (CLI always requests this)
if (body.reasoning && body.reasoning.effort !== "none") {
const include = Array.isArray(body.include) ? body.include : [];
if (!include.includes("reasoning.encrypted_content")) {
include.push("reasoning.encrypted_content");
}
body.include = include;
}
// Drop Chat Completions leftovers that Responses rejects
delete body.messages;
delete body.max_tokens;
delete body.max_completion_tokens;
delete body.n;
delete body.seed;
delete body.logprobs;
delete body.top_logprobs;
delete body.frequency_penalty;
delete body.presence_penalty;
delete body.logit_bias;
delete body.user;
delete body.stream_options;
delete body.prompt_cache_retention;
delete body.safety_identifier;
delete body.previous_response_id; // store=false → cannot resolve
for (const k of Object.keys(body)) {
if (!RESPONSES_API_ALLOWLIST.has(k)) delete body[k];
}
return body;
}
async execute(args) {
// Lazy-resolve stable agent id once per process if connection has none
if (!this._agentId && !args.credentials?.providerSpecificData?.deviceId) {
try {
const mid = await getConsistentMachineId("grok-cli-agent");
// Format as UUID-ish for header aesthetics
this._agentId = [
mid.slice(0, 8),
mid.slice(8, 12),
"5" + mid.slice(13, 16),
"a" + mid.slice(17, 20),
mid.slice(0, 12).padEnd(12, "0"),
].join("-");
} catch {
this._agentId = crypto.randomUUID();
}
} else if (args.credentials?.providerSpecificData?.deviceId) {
this._agentId = args.credentials.providerSpecificData.deviceId;
}
return super.execute(args);
}
}
export default GrokCliExecutor;

View File

@@ -5,25 +5,20 @@ import { GithubExecutor } from "./github.js";
import { IFlowExecutor } from "./iflow.js";
import { QoderExecutor } from "./qoder.js";
import { KiroExecutor } from "./kiro.js";
import { KimchiExecutor } from "./kimchi.js";
import { CodexExecutor } from "./codex.js";
import { CursorExecutor } from "./cursor.js";
import { VertexExecutor } from "./vertex.js";
import { QwenExecutor } from "./qwen.js";
import { OpenCodeExecutor } from "./opencode.js";
import { OpenCodeGoExecutor } from "./opencode-go.js";
import { GrokWebExecutor } from "./grok-web.js";
import { GrokCliExecutor } from "./grok-cli.js";
import { PerplexityWebExecutor } from "./perplexity-web.js";
import { OllamaLocalExecutor } from "./ollama-local.js";
import { CommandCodeExecutor } from "./commandcode.js";
import { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js";
import { MimoFreeExecutor } from "./mimo-free.js";
import { CodeBuddyExecutor } from "./codebuddy-cn.js";
import { CodeBuddyIntlExecutor } from "./codebuddy-intl.js";
import TraeExecutor from "./trae.js";
import ZedExecutor from "./zed.js";
import WindsurfExecutor from "./windsurf.js";
import { DefaultExecutor } from "./default.js";
import { DevinCliExecutor } from "./devin-cli.js";
const executors = {
antigravity: new AntigravityExecutor(),
@@ -33,17 +28,15 @@ const executors = {
iflow: new IFlowExecutor(),
qoder: new QoderExecutor(),
kiro: new KiroExecutor(),
kimchi: new KimchiExecutor(),
codex: new CodexExecutor(),
cursor: new CursorExecutor(),
cu: new CursorExecutor(), // Alias for cursor
vertex: new VertexExecutor("vertex"),
"vertex-partner": new VertexExecutor("vertex-partner"),
qwen: new QwenExecutor(),
opencode: new OpenCodeExecutor(),
"opencode-go": new OpenCodeGoExecutor(),
"grok-web": new GrokWebExecutor(),
"grok-cli": new GrokCliExecutor(),
gcli: new GrokCliExecutor(), // Alias
gb: new GrokCliExecutor(), // Alias (Grok Build)
"perplexity-web": new PerplexityWebExecutor(),
"ollama-local": new OllamaLocalExecutor(),
commandcode: new CommandCodeExecutor(),
@@ -51,11 +44,6 @@ const executors = {
"mimo-free": new MimoFreeExecutor(),
mmf: new MimoFreeExecutor(), // Alias for mimo-free
"codebuddy-cn": new CodeBuddyExecutor(),
"codebuddy-intl": new CodeBuddyIntlExecutor(),
trae: new TraeExecutor(),
zed: new ZedExecutor(),
windsurf: new WindsurfExecutor(),
"devin-cli": new DevinCliExecutor(),
};
const defaultCache = new Map();
@@ -78,22 +66,17 @@ export { GithubExecutor } from "./github.js";
export { IFlowExecutor } from "./iflow.js";
export { QoderExecutor } from "./qoder.js";
export { KiroExecutor } from "./kiro.js";
export { KimchiExecutor } from "./kimchi.js";
export { CodexExecutor } from "./codex.js";
export { CursorExecutor } from "./cursor.js";
export { VertexExecutor } from "./vertex.js";
export { DefaultExecutor } from "./default.js";
export { QwenExecutor } from "./qwen.js";
export { OpenCodeExecutor } from "./opencode.js";
export { OpenCodeGoExecutor } from "./opencode-go.js";
export { GrokWebExecutor } from "./grok-web.js";
export { GrokCliExecutor } from "./grok-cli.js";
export { PerplexityWebExecutor } from "./perplexity-web.js";
export { OllamaLocalExecutor } from "./ollama-local.js";
export { CommandCodeExecutor } from "./commandcode.js";
export { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js";
export { MimoFreeExecutor } from "./mimo-free.js";
export { CodeBuddyExecutor } from "./codebuddy-cn.js";
export { CodeBuddyIntlExecutor } from "./codebuddy-intl.js";
export { default as TraeExecutor } from "./trae.js";
export { default as ZedExecutor } from "./zed.js";
export { default as WindsurfExecutor } from "./windsurf.js";
export { DevinCliExecutor } from "./devin-cli.js";

View File

@@ -1,123 +0,0 @@
import { DefaultExecutor } from "./default.js";
import { getCachedKimchiModelMetadata } from "../services/kimchiModels.js";
const TOP_LEVEL_OPENAI_GATEWAY_DROPS = [
"anthropic_version",
"anthropic_beta",
"client_metadata",
"mcp_servers",
"stop_sequences",
"thinking",
"top_k",
];
function systemToText(system) {
if (typeof system === "string") return system;
if (Array.isArray(system)) {
return system
.map((part) => {
if (typeof part === "string") return part;
if (typeof part?.text === "string") return part.text;
return "";
})
.filter(Boolean)
.join("\n");
}
return "";
}
function mergeTopLevelSystem(body) {
if (!body?.system || !Array.isArray(body.messages)) return;
const text = systemToText(body.system).trim();
if (!text) return;
const existing = body.messages.find((msg) => msg?.role === "system");
if (!existing) {
body.messages.unshift({ role: "system", content: text });
return;
}
if (typeof existing.content === "string") {
existing.content = `${text}\n\n${existing.content}`;
} else if (Array.isArray(existing.content)) {
existing.content.unshift({ type: "text", text });
}
}
function stripMessageArtifacts(body) {
if (!Array.isArray(body?.messages)) return;
for (const msg of body.messages) {
if (!msg || typeof msg !== "object") continue;
delete msg.cache_control;
if (!Array.isArray(msg.content)) continue;
msg.content = msg.content.map((part) => {
if (!part || typeof part !== "object") return part;
const { cache_control, signature, ...clean } = part;
return clean;
});
}
}
function stripToolArtifacts(body) {
if (!Array.isArray(body?.tools)) return;
body.tools = body.tools.map((tool) => {
if (!tool || typeof tool !== "object") return tool;
const { cache_control, ...clean } = tool;
return clean;
});
}
// Strip `reasoning_content` echoed by clients on assistant messages — but
// only when it's a real thinking block. `DefaultExecutor.transformRequest`
// runs `injectReasoningContent` first and may inject a 1-char placeholder
// (" ") for upstream validation; the placeholder is small (no token cost
// worth stripping) and stripping it would re-trigger upstream to complain
// about missing reasoning on the next turn. Threshold matches the
// placeholder length with a safety margin.
const REASONING_PLACEHOLDER_MAX_LEN = 8;
export function stripReasoningContent(body) {
if (!Array.isArray(body?.messages)) return;
for (const msg of body.messages) {
if (msg && msg.role === "assistant" && typeof msg.reasoning_content === "string"
&& msg.reasoning_content.length > REASONING_PLACEHOLDER_MAX_LEN) {
delete msg.reasoning_content;
}
}
}
function isAnthropicBackedKimchiModel(model) {
const meta = getCachedKimchiModelMetadata(model);
if (meta?.provider === "anthropic" || meta?.upstreamProvider === "anthropic") return true;
return /(^|[-_/])(?:claude|anthropic)(?:[-_/]|$)/i.test(String(model || ""));
}
export class KimchiExecutor extends DefaultExecutor {
constructor() {
super("kimchi");
}
transformRequest(model, body, stream, credentials) {
const transformed = super.transformRequest(model, body, stream, credentials);
if (!transformed || typeof transformed !== "object") return transformed;
mergeTopLevelSystem(transformed);
for (const key of TOP_LEVEL_OPENAI_GATEWAY_DROPS) {
if (transformed[key] !== undefined) delete transformed[key];
}
delete transformed.system;
if (isAnthropicBackedKimchiModel(model)) {
delete transformed.reasoning_effort;
delete transformed.reasoning;
delete transformed.thinking;
}
stripMessageArtifacts(transformed);
stripToolArtifacts(transformed);
stripReasoningContent(transformed);
return transformed;
}
}
export default KimchiExecutor;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js";
import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
import { ANTHROPIC_API_VERSION } from "../providers/shared.js";
// Models that use /zen/go/v1/messages (Anthropic/Claude format + x-api-key auth)
const MESSAGES_FORMAT_MODELS = new Set([
"minimax-m3",
"minimax-m2.7",
"minimax-m2.5",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.6-plus",
]);
const BASE = "https://opencode.ai/zen/go/v1";
export class OpenCodeGoExecutor extends BaseExecutor {
constructor() {
super("opencode-go", PROVIDERS["opencode-go"]);
}
// buildUrl runs before buildHeaders in BaseExecutor.execute, cache model here
buildUrl(model) {
this._lastModel = model;
return MESSAGES_FORMAT_MODELS.has(model)
? `${BASE}/messages`
: `${BASE}/chat/completions`;
}
buildHeaders(credentials, stream = true) {
const key = credentials?.apiKey || credentials?.accessToken;
const headers = { "Content-Type": "application/json" };
if (MESSAGES_FORMAT_MODELS.has(this._lastModel)) {
headers["x-api-key"] = key;
headers["anthropic-version"] = ANTHROPIC_API_VERSION;
} else {
headers["Authorization"] = `Bearer ${key}`;
}
if (stream) headers["Accept"] = "text/event-stream";
return headers;
}
transformRequest(model, body) {
return injectReasoningContent({ provider: this.provider, model, body });
}
}

View File

@@ -1,43 +1,16 @@
import crypto from "crypto";
import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js";
import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
import { resolveSessionId } from "../utils/sessionManager.js";
const OPENCODE_UA = "opencode";
// Models that use /zen/v1/messages (claude format)
const MESSAGES_MODELS = new Set();
function generateRequestId() {
return `msg_${crypto.randomUUID().replace(/-/g, "")}`;
}
function generateSessionId() {
return `ses_${crypto.randomUUID().replace(/-/g, "")}`;
}
// Normalize any resolved id into opencode's ses_ format (stable per-conversation)
function toOpencodeSession(id) {
const stripped = String(id || "").replace(/^ses_/, "").replace(/-/g, "");
return stripped ? `ses_${stripped}` : null;
}
function resolveOpencodeSession(body, credentials) {
return toOpencodeSession(resolveSessionId({
headers: credentials?.rawHeaders,
body,
connectionId: credentials?.connectionId,
scope: "opencode",
}));
}
export class OpenCodeExecutor extends BaseExecutor {
constructor() {
super("opencode", PROVIDERS.opencode);
this._currentSessionId = null;
}
transformRequest(model, body, stream, credentials) {
this._currentSessionId = resolveOpencodeSession(body, credentials);
transformRequest(model, body) {
return injectReasoningContent({ provider: this.provider, model, body });
}
@@ -48,23 +21,12 @@ export class OpenCodeExecutor extends BaseExecutor {
: `${base}/zen/v1/chat/completions`;
}
buildHeaders(credentials, stream = true) {
const raw = credentials?.rawHeaders || {};
const lower = {};
for (const [k, v] of Object.entries(raw)) lower[k.toLowerCase()] = v;
const downstreamUa = lower["user-agent"] || "";
const isOpencodeDownstream = downstreamUa.toLowerCase().includes("opencode");
buildHeaders() {
return {
"Content-Type": "application/json",
"Authorization": "Bearer public",
"User-Agent": isOpencodeDownstream ? downstreamUa : OPENCODE_UA,
"x-opencode-client": lower["x-opencode-client"] || "desktop",
"x-opencode-session": lower["x-opencode-session"] || this._currentSessionId || generateSessionId(),
"x-opencode-request": lower["x-opencode-request"] || generateRequestId(),
"x-opencode-project": lower["x-opencode-project"] || "global",
"Accept": stream ? "text/event-stream" : "*/*",
"x-opencode-client": "desktop",
"Accept": "text/event-stream"
};
}
}

View File

@@ -32,11 +32,9 @@ import { SSE_DONE } from "../utils/sseConstants.js";
import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js";
import {
QODER_CHAT_URL_ENCODED,
QODER_CHAT_BASE_ALT,
QODER_CHAT_SIG_PATH,
QODER_MODEL_MAP,
} from "../shared/qoder/constants.js";
import { getQoderModelConfig, resolveQoderModels, isQoderPat, resolveQoderCredentials } from "../services/qoderModels.js";
import { getQoderModelConfig, resolveQoderModels } from "../services/qoderModels.js";
/**
* Hoist role:"system" messages out of the messages array (Qoder rejects
@@ -124,6 +122,68 @@ function truncate(s, n) {
return s && s.length > n ? `${s.slice(0, n)}...` : s || "";
}
/**
* Parse Qoder-specific mid-stream error codes into user-friendly messages.
* Qoder embeds errors inside SSE envelope body as JSON: {"code":"112","message":"{...}"}
*
* Known codes:
* 112 → quota/billing exceeded (message contains pricingUrl)
* 113 → model not available for current plan
*
* @param {number} statusVal - Upstream status code from envelope
* @param {string} bodyStr - Raw body string from envelope
* @returns {{ statusCode: number, message: string, errorCode: string|null }}
*/
function parseQoderStreamError(statusVal, bodyStr) {
let code = null;
let innerMessage = "";
try {
const inner = JSON.parse(bodyStr);
code = String(inner.code || "");
innerMessage = inner.message || bodyStr;
// Code 112: quota exceeded — inner message is JSON with pricingUrl
if (code === "112") {
let pricingUrl = "";
try {
const msgObj = JSON.parse(innerMessage);
pricingUrl = msgObj.pricingUrl || "";
} catch { /* innerMessage is not JSON */ }
return {
statusCode: statusVal,
message: pricingUrl
? `Qoder quota exceeded. Upgrade your plan at: ${pricingUrl}`
: "Qoder quota exceeded. Please check your plan limits.",
errorCode: code,
};
}
// Code 113: model not available
if (code === "113") {
return {
statusCode: statusVal,
message: `Qoder model not available for your current plan. ${innerMessage}`,
errorCode: code,
};
}
} catch {
// bodyStr is not valid JSON — fall through to generic message
}
// Generic fallback
const statusLabel = statusVal >= 500 ? "server error"
: statusVal === 429 ? "rate limit exceeded"
: statusVal === 403 ? "permission error"
: statusVal === 401 ? "authentication error"
: "request error";
return {
statusCode: statusVal,
message: `Qoder ${statusLabel}: ${truncate(innerMessage || bodyStr, 200)}`,
errorCode: code,
};
}
/**
* Map the OpenAI-style request body into the exact shape Qoder expects.
*/
@@ -215,52 +275,6 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio
};
}
/**
* Check if a qoder error message indicates a billing/quota block.
* Signatures: code 112 (quota exhausted), code 10605 (queue throttle), pricingUrl field.
*/
function isBillingBlock(inner) {
if (!inner || typeof inner !== "string") return false;
const lowerMsg = inner.toLowerCase();
// Match: {"code":"112",...}, {"code":"10605",...}, or pricingUrl field
return /\"code\"\s*:\s*\"(112|10605)\"/.test(inner) || lowerMsg.includes("pricingurl");
}
/**
* Peek the first SSE frame to detect billing errors before piping.
* Returns { isBilling, statusVal, message, consumed } — `consumed` is every
* byte read so far (including the peeked line) so the caller can re-process
* it and nothing is dropped from the stream.
*/
async function peekFirstQoderFrame(reader, decoder) {
let consumed = "";
while (true) {
const { done, value } = await reader.read();
if (done) return { isBilling: false, consumed, upstreamDone: true };
consumed += decoder.decode(value, { stream: true });
const nl = consumed.indexOf("\n");
if (nl === -1) continue; // need a full line first
const line = consumed.slice(0, nl).replace(/\r$/, "").trim();
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trimStart();
if (data === "[DONE]") return { isBilling: false, consumed };
let envelope;
try { envelope = JSON.parse(data); } catch { return { isBilling: false, consumed }; }
const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200;
const inner = typeof envelope.body === "string" ? envelope.body : "";
if (statusVal !== 200 && isBillingBlock(inner)) {
return { isBilling: true, statusVal, message: inner || `qoder billing block (${statusVal})` };
}
return { isBilling: false, consumed };
}
}
/**
* Wrap the upstream's `{statusCodeValue, body}` SSE envelope into plain
* OpenAI SSE chunks the rest of the chatCore pipeline understands.
@@ -268,42 +282,73 @@ async function peekFirstQoderFrame(reader, decoder) {
* Each upstream line looks like:
* data: {"statusCodeValue":200,"body":"{\"choices\":[{\"delta\":{...}}]}"}
* The inner body is an OpenAI streaming chunk (or "[DONE]"). We unwrap it
* and re-emit as `data: <inner>\n\n`. Errors become a synthetic OpenAI error
* chunk + [DONE].
*
* Critical: Qoder's SSE often keeps the socket open after the terminal
* [DONE]/error frame (agent keepalive). Non-streaming clients drain via
* response.text() which hangs until the socket closes — so on terminal
* events we cancel the upstream reader and close our stream immediately.
*
* NEW: Peek first frame to detect billing blocks (code 112/10605/pricingUrl).
* If detected, return 403 response so chatCore marks connection unavailable
* and triggers combo fallback instead of leaking error text into chat.
* and re-emit as `data: <inner>\n\n`. Errors become `data: [DONE]\n\n` plus
* a synthetic OpenAI error chunk.
*/
async function wrapQoderSSE(response, model) {
async function wrapQoderSSE(response, model, midStreamError = {}) {
if (!response.ok || !response.body) return response;
const decoder = new TextDecoder();
const reader = response.body.getReader();
const encoder = new TextEncoder();
// Peek first frame to detect billing block
const peek = await peekFirstQoderFrame(reader, decoder);
if (peek?.isBilling) {
// Billing block detected — return 403 so chatCore fails this connection
await reader.cancel().catch(() => {});
return new Response(
JSON.stringify({ error: { message: peek.message, code: peek.statusVal } }),
{ status: 403, headers: { "Content-Type": "application/json" } }
);
// Peek at first chunk to detect errors early
const reader = response.body.getReader();
const firstRead = await reader.read();
if (firstRead.done) {
// Empty stream
return new Response("data: [DONE]\n\n", {
status: response.status,
statusText: response.statusText,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
},
});
}
// Normal flow: re-process every byte the peek consumed, then continue.
let buffer = peek.consumed || "";
const upstreamDrained = peek.upstreamDone === true;
const encoder = new TextEncoder();
// Parse first line to check for error
const firstText = decoder.decode(firstRead.value, { stream: true });
const nlIndex = firstText.indexOf("\n");
const firstLine = nlIndex !== -1 ? firstText.slice(0, nlIndex) : firstText;
const trimmed = firstLine.replace(/\r$/, "").trim();
if (trimmed.startsWith("data:")) {
const data = trimmed.slice(5).trimStart();
if (data !== "[DONE]") {
try {
const envelope = JSON.parse(data);
const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200;
if (statusVal !== 200) {
// Error detected - return error Response to trigger failover
const msg = envelope.body || `upstream status ${statusVal}`;
const errorResponse = new Response(
JSON.stringify({
error: {
message: `qoder error ${statusVal}: ${truncate(msg, 500)}`,
type: "upstream_error",
code: String(statusVal)
}
}),
{
status: statusVal >= 400 && statusVal < 600 ? statusVal : 502,
headers: { "Content-Type": "application/json" }
}
);
reader.cancel();
return errorResponse;
}
} catch (e) {
// Not JSON, continue as normal stream
}
}
}
// No error detected - proceed with normal TransformStream
let buffer = "";
let doneEmitted = false;
// Process one already-extracted SSE line (no trailing newline).
const processLine = (line, controller) => {
const trimmed = line.replace(/\r$/, "").trim();
if (!trimmed) return;
@@ -322,15 +367,11 @@ async function wrapQoderSSE(response, model) {
const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200;
const inner = typeof envelope.body === "string" ? envelope.body : "";
if (statusVal !== 200) {
const msg = inner || `upstream status ${statusVal}`;
const errChunk = JSON.stringify({
id: `qoder-error-${Date.now()}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model,
choices: [{ index: 0, delta: { content: `\n[qoder error ${statusVal}: ${truncate(msg, 200)}]` }, finish_reason: "stop" }],
});
controller.enqueue(encoder.encode(`data: ${errChunk}\n\n`));
const parsed = parseQoderStreamError(statusVal, inner);
// Store error in shared state so the caller can return a proper error Response
midStreamError.error = { status: parsed.statusCode, message: parsed.message, errorCode: parsed.errorCode };
// End the stream with [DONE]; the caller (QoderExecutor.execute) will detect
// midStreamError and return a non-2xx Response so the API client gets a real error.
controller.enqueue(encoder.encode(SSE_DONE));
doneEmitted = true;
return;
@@ -341,81 +382,53 @@ async function wrapQoderSSE(response, model) {
doneEmitted = true;
return;
}
// Strip embedded newlines so the SSE frame stays a single event.
const sanitized = inner.replace(/\r?\n/g, "");
controller.enqueue(encoder.encode(`data: ${sanitized}\n\n`));
};
const stream = new ReadableStream({
// Use start()+loop (not pull): a pull that buffers a partial line without
// enqueueing would never be re-invoked, hanging consumers like .text().
async start(controller) {
try {
// Drain whatever the peek already pulled off the socket first.
let nlSeed;
while ((nlSeed = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nlSeed);
buffer = buffer.slice(nlSeed + 1);
processLine(line, controller);
if (doneEmitted) {
await reader.cancel().catch(() => {});
controller.close();
return;
}
}
if (upstreamDrained) {
// Peek hit end-of-stream: flush any trailing partial line.
buffer += decoder.decode();
if (buffer.length > 0) {
processLine(buffer, controller);
buffer = "";
}
}
while (!doneEmitted && !upstreamDrained) {
const { done, value } = await reader.read();
if (done) {
buffer += decoder.decode();
if (buffer.length > 0) {
processLine(buffer, controller);
buffer = "";
}
break;
}
buffer += decoder.decode(value, { stream: true });
let nl;
while ((nl = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nl);
buffer = buffer.slice(nl + 1);
processLine(line, controller);
if (doneEmitted) {
// Terminal frame received — drop upstream keepalive and end.
await reader.cancel().catch(() => {});
controller.close();
return;
}
}
}
} catch {
// fall through to terminal [DONE] + close
} finally {
if (!doneEmitted) {
try {
controller.enqueue(encoder.encode(SSE_DONE));
doneEmitted = true;
} catch { /* already closed */ }
}
try { controller.close(); } catch { /* already closed */ }
await reader.cancel().catch(() => {});
const transform = new TransformStream({
transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true });
let nl;
while ((nl = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nl);
buffer = buffer.slice(nl + 1);
processLine(line, controller);
}
},
cancel() {
return reader.cancel().catch(() => {});
flush(controller) {
buffer += decoder.decode();
if (buffer.length > 0) {
processLine(buffer, controller);
buffer = "";
}
if (!doneEmitted) {
controller.enqueue(encoder.encode(SSE_DONE));
doneEmitted = true;
}
},
});
return new Response(stream, {
// Create a ReadableStream that emits the first chunk + remaining chunks
const combinedStream = new ReadableStream({
start(controller) {
controller.enqueue(firstRead.value);
},
async pull(controller) {
const { done, value } = await reader.read();
if (done) {
controller.close();
} else {
controller.enqueue(value);
}
},
cancel() {
reader.cancel();
}
});
const transformed = combinedStream.pipeThrough(transform);
return new Response(transformed, {
status: response.status,
statusText: response.statusText,
headers: {
@@ -430,13 +443,7 @@ export class QoderExecutor extends BaseExecutor {
super("qoder", PROVIDERS.qoder);
}
buildUrl(credentials) {
// Job-token (jt-...) traffic must hit api2.qoder.sh — api3 rejects jt-
// with "Login expired" (403). Device tokens (dt-...) stay on api3.
const raw = credentials?.apiKey || credentials?.accessToken;
if (typeof raw === "string" && !raw.startsWith("pt-") && (raw.startsWith("jt-") || (credentials?.accessToken || "").startsWith("jt-"))) {
return `${QODER_CHAT_BASE_ALT}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1`;
}
buildUrl() {
return QODER_CHAT_URL_ENCODED;
}
@@ -446,24 +453,8 @@ export class QoderExecutor extends BaseExecutor {
// - COSY headers built from the *encoded* body bytes
// - response stream re-wrapped from {statusCodeValue, body} to OpenAI SSE
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
// PAT (pt-...) → exchange for short-lived job token + resolve userId so
// downstream COSY signing + catalog fetch work. Device tokens (dt-...) and
// job tokens (jt-...) skip this and are used directly.
const rawToken = credentials?.apiKey || credentials?.accessToken;
if (isQoderPat(rawToken)) {
try {
credentials = await resolveQoderCredentials(credentials, proxyOptions, signal);
} catch (err) {
log?.error?.("QODER", `PAT exchange failed: ${err.message}`);
const fakeResp = new Response(
JSON.stringify({ error: { message: `qoder PAT exchange failed: ${err.message}` } }),
{ status: 401, headers: { "Content-Type": "application/json" } },
);
return { response: fakeResp, url: this.buildUrl(credentials), headers: {}, transformedBody: body };
}
}
const url = this.buildUrl();
const url = this.buildUrl(credentials);
const psd = credentials?.providerSpecificData || {};
if (!psd.userId) {
// No user id → no way to sign. Surface a 401 so the dashboard nudges
@@ -557,19 +548,83 @@ export class QoderExecutor extends BaseExecutor {
return { response, url, headers, transformedBody: payload };
}
const wrapped = await wrapQoderSSE(response, `qoder/${qoderKey}`);
return { response: wrapped, url, headers, transformedBody: payload };
const midStreamError = {};
let wrapped = await wrapQoderSSE(response, `qoder/${qoderKey}`, midStreamError);
// If a mid-stream error was detected, return a proper error Response instead of
// a successful one with an error message inside.
if (midStreamError?.error) {
const err = midStreamError.error;
log?.error?.("QODER", `Upstream error ${err.status}: ${err.message}`);
wrapped = new Response(
JSON.stringify({
error: {
message: err.message,
type: "upstream_error",
code: String(err.status),
}
}),
{
status: err.status >= 400 && err.status < 600 ? err.status : 502,
headers: { "Content-Type": "application/json" },
}
);
}
return { response: wrapped, url, headers, transformedBody: payload, midStreamError };
}
// Qoder device tokens don't refresh through OAuth — the upstream returns
// 403 for our flow. Surfacing failure via 401-on-chat is enough; the
// dashboard tells users to re-login when their token expires (~30 days).
async refreshCredentials() {
return null;
// Validate Qoder token by calling the quota endpoint. If it returns 403,
// return a structured unrecoverable error so chatCore bails before sending
// the actual chat request (instead of forwarding a "quota exceeded" success).
async refreshCredentials(credentials, log) {
// Qoder's quota endpoint validates the access token. If it returns 403,
// the token is invalid and the user needs to reconnect.
const oauth = PROVIDERS.qoder?.oauth;
const url = oauth?.quotaUsageUrl || "https://openapi.qoder.sh/api/v2/quota/usage";
const authToken = credentials?.accessToken;
if (!authToken) return null;
try {
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
},
signal: AbortSignal.timeout(10_000),
});
if (res.status === 403) {
const errText = await res.text().catch(() => "");
log?.error?.("TOKEN_REFRESH", `Qoder token invalid (403): ${errText}`);
return {
error: "unrecoverable_refresh_error",
code: "invalid_token",
message: "qoder token invalid; reconnect the account",
};
}
// Token is valid — stamp lastRefreshAt so needsRefresh stays quiet
const psd = { ...credentials.providerSpecificData, lastRefreshAt: new Date().toISOString() };
return {
accessToken: authToken,
expiresIn: 86400,
providerSpecificData: psd,
lastRefreshAt: new Date().toISOString(),
};
} catch (err) {
log?.warn?.("TOKEN_REFRESH", `Qoder refresh check failed: ${err.message}`);
return null; // Network blip — let the request proceed, failover handles 403
}
}
needsRefresh() {
return false;
// 24h cooldown before re-checking token validity
needsRefresh(credentials) {
if (!credentials?.accessToken) return false;
const psd = credentials?.providerSpecificData || {};
if (!psd.lastRefreshAt) return true;
const elapsed = Date.now() - new Date(psd.lastRefreshAt).getTime();
return elapsed > 24 * 60 * 60 * 1000;
}
}
@@ -579,7 +634,7 @@ export default QoderExecutor;
// should import QoderExecutor and use its public methods.
export const __test__ = {
normalizeMessages,
parseQoderStreamError,
wrapQoderSSE,
buildQoderRequestBody,
isBillingBlock,
};

129
open-sse/executors/qwen.js Normal file
View File

@@ -0,0 +1,129 @@
import { DefaultExecutor } from "./default.js";
import { PROVIDERS } from "../config/providers.js";
import { OAUTH_ENDPOINTS } from "../config/appConstants.js";
/** portal.qwen.ai — static fingerprint matching stable Qwen Code release */
const QWEN_USER_AGENT = "QwenCode/0.12.3 (linux; x64)";
const QWEN_STAINLESS = {
os: "Linux",
arch: "x64",
lang: "js",
runtime: "node",
runtimeVersion: "v18.19.1",
packageVersion: "5.11.0",
retryCount: "1"
};
const QWEN_DEFAULT_SYSTEM_MESSAGE = {
role: "system",
content: [{ type: "text", text: "", cache_control: { type: "ephemeral" } }]
};
function ensureQwenSystemMessage(body) {
if (!body || typeof body !== "object") return body;
const next = { ...body };
if (Array.isArray(next.messages)) {
next.messages = [QWEN_DEFAULT_SYSTEM_MESSAGE, ...next.messages];
} else {
next.messages = [QWEN_DEFAULT_SYSTEM_MESSAGE];
}
return next;
}
function isQwenThinkingActive(body) {
const thinking = body?.thinking;
if (thinking === true || body?.enable_thinking === true) return true;
return typeof thinking === "object" && thinking !== null && !Array.isArray(thinking) && thinking.type === "enabled";
}
// Qwen rejects tool_choice="required" or object forms when thinking is active; neutralize to "auto".
function sanitizeQwenThinkingToolChoice(body) {
if (!isQwenThinkingActive(body)) return body;
const tc = body.tool_choice;
const incompatible = tc === "required" || (typeof tc === "object" && tc !== null);
if (!incompatible) return body;
return { ...body, tool_choice: "auto" };
}
function buildQwenUpstreamHeaders(credentials, stream = true) {
const token = credentials?.apiKey || credentials?.accessToken || "";
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"User-Agent": QWEN_USER_AGENT,
"X-DashScope-AuthType": "qwen-oauth",
"X-DashScope-CacheControl": "enable",
"X-DashScope-UserAgent": QWEN_USER_AGENT,
"X-Stainless-Arch": QWEN_STAINLESS.arch,
"X-Stainless-Lang": QWEN_STAINLESS.lang,
"X-Stainless-Os": QWEN_STAINLESS.os,
"X-Stainless-Package-Version": QWEN_STAINLESS.packageVersion,
"X-Stainless-Retry-Count": QWEN_STAINLESS.retryCount,
"X-Stainless-Runtime": QWEN_STAINLESS.runtime,
"X-Stainless-Runtime-Version": QWEN_STAINLESS.runtimeVersion,
Connection: "keep-alive",
"Accept-Language": "*",
"Sec-Fetch-Mode": "cors"
};
headers.Accept = stream ? "text/event-stream" : "application/json";
return headers;
}
export class QwenExecutor extends DefaultExecutor {
constructor() {
super("qwen");
}
// Qwen tokens are bound to a resource_url returned at OAuth time.
// Using portal.qwen.ai when the token is issued for another shard returns 401/403.
buildUrl(model, stream, urlIndex = 0, credentials = null) {
const resourceUrl = credentials?.providerSpecificData?.resourceUrl;
const host = resourceUrl ? resourceUrl.replace(/^https?:\/\//, "").replace(/\/$/, "") : "portal.qwen.ai";
return `https://${host}/v1/chat/completions`;
}
buildHeaders(credentials, stream = true) {
return buildQwenUpstreamHeaders(credentials, stream);
}
transformRequest(model, body, stream, credentials) {
let next = body && typeof body === "object" ? { ...body } : body;
if (stream && next?.messages && !next.stream_options && !next.thinking && !next.enable_thinking && next.stream !== false) {
next.stream_options = { include_usage: true };
}
next = sanitizeQwenThinkingToolChoice(next);
return ensureQwenSystemMessage(next);
}
// Override to capture resource_url from refresh response (required for buildUrl).
async refreshCredentials(credentials, log) {
if (!credentials?.refreshToken) return null;
try {
const response = await fetch(OAUTH_ENDPOINTS.qwen.token, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: credentials.refreshToken,
client_id: PROVIDERS.qwen.clientId
})
});
if (!response.ok) return null;
const tokens = await response.json();
log?.info?.("TOKEN", "qwen refreshed");
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || credentials.refreshToken,
expiresIn: tokens.expires_in,
providerSpecificData: {
...(credentials.providerSpecificData || {}),
...(tokens.resource_url ? { resourceUrl: tokens.resource_url } : {})
}
};
} catch (error) {
log?.error?.("TOKEN", `qwen refresh error: ${error.message}`);
return null;
}
}
}
export default QwenExecutor;

View File

@@ -1,339 +0,0 @@
import { BaseExecutor } from "./base.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { PROVIDERS } from "../config/providers.js";
// Trae executor — SOLO remote agent API.
//
// Flow:
// 1. POST {base}/chat_sessions → { code:0, data:{ chat_session_id, message_id } }
// 2. GET {base}/chat_sessions/{id}/events?reply_to_message_id={message_id}
// → text/event-stream. Assistant text streams in `plan_item` events under
// the `thought` field (cumulative per plan-item id). `token_usage` carries
// usage; `done` ends the turn; `error` carries upstream errors.
//
// Auth: header `Authorization: Cloud-IDE-JWT <jwt>` (RS256, ~14-day lifetime).
// Identity fields for common_params live in credentials.providerSpecificData.
const STREAM_TIMEOUT_MS = parseInt(process.env.TRAE_STREAM_TIMEOUT_MS || "300000", 10);
const TRAE_UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
function flattenQuery(messages) {
const parts = [];
for (const m of messages) {
let content = "";
if (typeof m.content === "string") content = m.content;
else if (Array.isArray(m.content)) {
content = m.content
.map((p) => {
if (typeof p === "string") return p;
if (p && typeof p === "object") return String(p.text ?? "");
return "";
})
.join("");
}
if (m.role === "system") parts.push(`[System]\n${content}`);
else if (m.role === "assistant") parts.push(`[Assistant]\n${content}`);
else parts.push(content);
}
// Trae expects query as a JSON-encoded string of typed content blocks.
return JSON.stringify([{ type: "text", data: { content: parts.join("\n\n") } }]);
}
export default class TraeExecutor extends BaseExecutor {
constructor() {
super("trae", PROVIDERS.trae);
}
base() {
return (this.config.baseUrl || "https://core-normal.trae.ai/api/remote/v1").replace(/\/$/, "");
}
buildHeaders(credentials, stream = true) {
const token = credentials?.accessToken || "";
const psd = credentials?.providerSpecificData || {};
return {
Authorization: `Cloud-IDE-JWT ${token}`,
"Content-Type": "application/json",
"X-Trae-Client-Type": "web",
"X-Preferenced-Language": psd.appLanguage || "en",
"x-user-region": psd.userRegion || "US",
Referer: "https://solo.trae.ai/",
"User-Agent": TRAE_UA,
Accept: stream ? "text/event-stream" : "application/json",
};
}
// SOLO session modes: "code" (model picker) vs "work" (fast auto lane).
resolveMode(model) {
const m = (model || "").trim().toLowerCase();
if (m === "work" || m === "auto-work" || m === "solo-work") {
return { mode: "work", strategy: "auto", modelName: "" };
}
const auto = !m || m === "auto";
return { mode: "code", strategy: auto ? "auto" : "manual", modelName: auto ? "" : model };
}
// common_params is a JSON-encoded string embedded inside initial_message.
commonParams(psd, mode, sessionId) {
const cp = {
language: "en-us",
app_language: psd.appLanguage || "en",
quality: "stable",
app_version: psd.appVersion || "1.0.0.1229",
web_id: psd.webId || "",
user_identity: psd.userIdentity || "Free",
is_freshman: "0",
biz_user_id: psd.bizUserId || "",
user_unique_id: psd.userUniqueId || "",
scope: psd.scope || "marscode-us",
tenant: psd.tenant || "marscode",
region: psd.region || "US-East",
aiRegion: psd.aiRegion || psd.region || "US-East",
is_privacy_mode: 0,
privacy_mode: "off",
solo_chat_mode: mode,
};
if (sessionId) cp.biz_session_id = sessionId;
return JSON.stringify(cp);
}
// POST /chat_sessions — creates a session and submits the first turn.
async createSession(headers, query, model, psd, signal) {
const { mode, strategy, modelName } = this.resolveMode(model);
const body = {
mode,
environment_id: "default",
initial_message: {
chat_session_id: "",
content: [],
query,
model_name: modelName,
agent_type: "solo_agent_remote",
model_selection_strategy: strategy,
common_params: this.commonParams(psd, mode),
},
env: "remote",
auto_create_project: false,
origin: "web",
};
const res = await proxyAwareFetch(`${this.base()}/chat_sessions`, {
method: "POST",
headers,
body: JSON.stringify(body),
signal,
}, null);
const text = await res.text();
if (!res.ok) throw new Error(`[${res.status}] ${text}`);
const json = JSON.parse(text);
if (json?.code !== 0) throw new Error(`Trae create_session: ${JSON.stringify(json)}`);
return { sessionId: json.data.chat_session_id, messageId: json.data.message_id };
}
// GET /events SSE → invoke onEvent(eventType, dataObj) per frame.
// Resolves when `done`/`error` arrives, the stream ends, or timeout fires.
async streamEvents(headers, sessionId, replyTo, onEvent, signal) {
const url = `${this.base()}/chat_sessions/${sessionId}/events?reply_to_message_id=${encodeURIComponent(replyTo)}`;
const ctrl = new AbortController();
if (signal?.aborted) ctrl.abort();
const timer = setTimeout(() => ctrl.abort(new Error("trae stream timeout")), STREAM_TIMEOUT_MS);
const onAbort = () => ctrl.abort();
if (signal) signal.addEventListener("abort", onAbort, { once: true });
try {
const res = await proxyAwareFetch(url, { method: "GET", headers, signal: ctrl.signal }, null);
if (!res.ok || !res.body) throw new Error(`[${res.status}] events stream failed`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let ev = null;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).replace(/\r$/, "");
buf = buf.slice(nl + 1);
if (line.startsWith("event:")) ev = line.slice(6).trim();
else if (line.startsWith("data:")) {
const payload = line.slice(5).trim();
let data;
try { data = JSON.parse(payload); } catch { data = { _raw: payload }; }
if (onEvent(ev, data)) {
await reader.cancel().catch(() => {});
return;
}
} else if (line === "") ev = null;
}
}
} finally {
clearTimeout(timer);
if (signal) signal.removeEventListener("abort", onAbort);
}
}
async execute({ model, body, stream, credentials, signal }) {
const headers = this.buildHeaders(credentials, stream !== false);
const psd = credentials?.providerSpecificData || {};
const query = flattenQuery(body?.messages || []);
const responseId = `chatcmpl-trae-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
const errResponse = (status, message) => new Response(
JSON.stringify({ error: { message, type: "api_error", code: "" } }),
{ status, headers: { "Content-Type": "application/json" } }
);
let session;
try {
session = await this.createSession(headers, query, model, psd, signal);
} catch (err) {
return { response: errResponse(502, err?.message ? String(err.message) : String(err)), url: this.base(), headers, transformedBody: body };
}
// Shared per-turn state: plan_item thoughts (cumulative, longest wins).
const order = [];
const thoughts = {};
let sent = 0;
let usage = null;
let errorEvent = null;
const renderNewText = (data) => {
const pid = data.id;
if (!pid) return "";
if (!(pid in thoughts)) order.push(pid);
const t = data.thought || "";
if (t.length >= (thoughts[pid] || "").length) thoughts[pid] = t;
const full = order.map((i) => thoughts[i]).join("");
const piece = full.slice(sent);
sent = full.length;
return piece;
};
if (stream !== false) {
const enc = new TextEncoder();
const sse = new ReadableStream({
start: async (controller) => {
const emit = (obj) => controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
emit({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
});
try {
await this.streamEvents(headers, session.sessionId, session.messageId, (ev, data) => {
if (ev === "error") { errorEvent = data; return true; }
if (ev === "token_usage") usage = data;
if (ev === "plan_item") {
const piece = renderNewText(data);
if (piece) {
emit({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { content: piece }, finish_reason: null }],
});
}
}
return ev === "done";
}, signal);
if (errorEvent) {
emit({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [],
error: { message: `trae ${errorEvent.code || ""}: ${errorEvent.message || ""}`, type: "api_error" },
});
} else {
emit({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
});
if (usage) {
emit({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [],
usage: {
prompt_tokens: usage.prompt_tokens || 0,
completion_tokens: usage.completion_tokens || 0,
total_tokens: usage.total_tokens || 0,
},
});
}
}
controller.enqueue(enc.encode("data: [DONE]\n\n"));
controller.close();
} catch (err) {
controller.error(err);
}
},
});
return {
response: new Response(sse, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
}),
url: this.base(),
headers,
transformedBody: body,
};
}
// Non-streaming: drive to completion, return chat.completion JSON.
try {
await this.streamEvents(headers, session.sessionId, session.messageId, (ev, data) => {
if (ev === "error") { errorEvent = data; return true; }
if (ev === "token_usage") usage = data;
if (ev === "plan_item") renderNewText(data);
return ev === "done";
}, signal);
} catch (err) {
return { response: errResponse(502, err?.message ? String(err.message) : String(err)), url: this.base(), headers, transformedBody: body };
}
if (errorEvent) {
return { response: errResponse(502, `trae ${errorEvent.code || ""}: ${errorEvent.message || ""}`), url: this.base(), headers, transformedBody: body };
}
const content = order.map((i) => thoughts[i]).join("");
const out = {
id: responseId,
object: "chat.completion",
created,
model,
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
};
if (usage) {
out.usage = {
prompt_tokens: usage.prompt_tokens || 0,
completion_tokens: usage.completion_tokens || 0,
total_tokens: usage.total_tokens || 0,
};
}
return {
response: new Response(JSON.stringify(out), { status: 200, headers: { "Content-Type": "application/json" } }),
url: this.base(),
headers,
transformedBody: body,
};
}
// Refresh hook placeholder — Cloud-IDE-JWT is long-lived (~14d); refresh via
// ExchangeToken (refresh→access) is wired in services/tokenRefresh/providers.js.
async refreshCredentials() {
return null;
}
}

View File

@@ -1,588 +0,0 @@
import { BaseExecutor } from "./base.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { PROVIDERS } from "../config/providers.js";
import { randomUUID } from "node:crypto";
// WindsurfExecutor — Codeium gRPC-web chat.
//
// Wire protocol: gRPC-web over HTTPS (Content-Type: application/grpc-web+proto).
// Service: exa.language_server_pb.LanguageServerService
// Method: GetChatMessage (unary request → streamed CompletionChunk frames)
//
// Auth: credentials.accessToken = Codeium apiKey (sk-ws-... or Firebase-derived)
// — placed in Metadata.api_key protobuf field of every request + Bearer header.
const WS_BASE_URL = "https://server.codeium.com";
const WS_SERVICE = "exa.language_server_pb.LanguageServerService";
const WS_METHOD_CHAT = "GetChatMessage";
const WS_CHAT_URL = `${WS_BASE_URL}/${WS_SERVICE}/${WS_METHOD_CHAT}`;
const WS_IDE_NAME = "windsurf";
const WS_IDE_VERSION = "3.14.0";
const WS_EXT_VERSION = "3.14.0";
const WS_LOCALE = "en-US";
// ─── Model alias map (catalog name → Windsurf wire name) ─────────────────────
const MODEL_ALIAS_MAP = {
// ── Cognition SWE ───────────────────────────────────────────────────────
"swe-1.6-fast": "swe-1-6-fast",
"swe-1.6": "swe-1-6",
"swe-1.5-fast": "swe-1-5-fast",
"swe-1.5": "swe-1-5",
// ── Claude Opus 4.7 — effort-tiered ─────────────────────────────────────
"claude-opus-4.7-max": "claude-opus-4-7-max",
"claude-opus-4.7-xhigh": "claude-opus-4-7-xhigh",
"claude-opus-4.7-high": "claude-opus-4-7-high",
"claude-opus-4.7-medium": "claude-opus-4-7-medium",
"claude-opus-4.7-low": "claude-opus-4-7-low",
"claude-opus-4.7-review": "opus-4-7-review",
// ── Claude Opus/Sonnet 4.6 ──────────────────────────────────────────────
"claude-sonnet-4.6-thinking-1m": "claude-sonnet-4-6-thinking-1m",
"claude-sonnet-4.6-1m": "claude-sonnet-4-6-1m",
"claude-sonnet-4.6-thinking": "claude-sonnet-4-6-thinking",
"claude-sonnet-4.6": "claude-sonnet-4-6",
"claude-opus-4.6-thinking": "claude-opus-4-6-thinking",
"claude-opus-4.6": "claude-opus-4-6",
// ── Claude 4.5 ──────────────────────────────────────────────────────────
"claude-opus-4.5-thinking": "MODEL_CLAUDE_4_5_OPUS_THINKING",
"claude-opus-4.5": "MODEL_CLAUDE_4_5_OPUS",
"claude-sonnet-4.5-thinking": "MODEL_PRIVATE_3",
"claude-sonnet-4.5": "MODEL_PRIVATE_2",
"claude-haiku-4.5": "MODEL_PRIVATE_11",
// ── GPT-5.5 ─────────────────────────────────────────────────────────────
"gpt-5.5-xhigh-fast": "gpt-5-5-xhigh-priority",
"gpt-5.5-high-fast": "gpt-5-5-high-priority",
"gpt-5.5-medium-fast": "gpt-5-5-medium-priority",
"gpt-5.5-low-fast": "gpt-5-5-low-priority",
"gpt-5.5-none-fast": "gpt-5-5-none-priority",
"gpt-5.5-xhigh": "gpt-5-5-xhigh",
"gpt-5.5-high": "gpt-5-5-high",
"gpt-5.5-medium": "gpt-5-5-medium",
"gpt-5.5-low": "gpt-5-5-low",
"gpt-5.5-none": "gpt-5-5-none",
"gpt-5.5-review": "gpt-5-5-review",
"gpt-5.5": "gpt-5-5-medium",
// ── GPT-5.4 ─────────────────────────────────────────────────────────────
"gpt-5.4-xhigh-fast": "gpt-5-4-xhigh-priority",
"gpt-5.4-high-fast": "gpt-5-4-high-priority",
"gpt-5.4-medium-fast": "gpt-5-4-medium-priority",
"gpt-5.4-low-fast": "gpt-5-4-low-priority",
"gpt-5.4-none-fast": "gpt-5-4-none-priority",
"gpt-5.4-xhigh": "gpt-5-4-xhigh",
"gpt-5.4-high": "gpt-5-4-high",
"gpt-5.4-medium": "gpt-5-4-medium",
"gpt-5.4-low": "gpt-5-4-low",
"gpt-5.4-none": "gpt-5-4-none",
"gpt-5.4-mini-xhigh": "gpt-5-4-mini-xhigh",
"gpt-5.4-mini-high": "gpt-5-4-mini-high",
"gpt-5.4-mini-medium": "gpt-5-4-mini-medium",
"gpt-5.4-mini-low": "gpt-5-4-mini-low",
"gpt-5.4": "gpt-5-4-medium",
// ── GPT-5.3-Codex ───────────────────────────────────────────────────────
"gpt-5.3-codex-xhigh-fast": "gpt-5-3-codex-xhigh-priority",
"gpt-5.3-codex-high-fast": "gpt-5-3-codex-high-priority",
"gpt-5.3-codex-medium-fast": "gpt-5-3-codex-medium-priority",
"gpt-5.3-codex-low-fast": "gpt-5-3-codex-low-priority",
"gpt-5.3-codex-xhigh": "gpt-5-3-codex-xhigh",
"gpt-5.3-codex-high": "gpt-5-3-codex-high",
"gpt-5.3-codex-medium": "gpt-5-3-codex-medium",
"gpt-5.3-codex-low": "gpt-5-3-codex-low",
"gpt-5.3-codex": "gpt-5-3-codex-medium",
// ── GPT-5.2 ─────────────────────────────────────────────────────────────
"gpt-5.2-xhigh": "MODEL_GPT_5_2_XHIGH",
"gpt-5.2-high": "MODEL_GPT_5_2_HIGH",
"gpt-5.2-medium": "MODEL_GPT_5_2_MEDIUM",
"gpt-5.2-low": "MODEL_GPT_5_2_LOW",
"gpt-5.2-none": "MODEL_GPT_5_2_NONE",
"gpt-5.2": "MODEL_GPT_5_2_MEDIUM",
// ── GPT-5 ───────────────────────────────────────────────────────────────
"gpt-5": "gpt-5",
// ── GPT-4.1 / 4o ────────────────────────────────────────────────────────
"gpt-4.1": "MODEL_CHAT_GPT_4_1_2025_04_14",
"gpt-4.1-mini": "gpt-4.1-mini",
"gpt-4o": "MODEL_CHAT_GPT_4O_2024_08_06",
// ── Gemini ──────────────────────────────────────────────────────────────
"gemini-3.1-pro-high": "gemini-3-1-pro-high",
"gemini-3.1-pro-low": "gemini-3-1-pro-low",
"gemini-3.1-pro": "gemini-3-1-pro-high",
"gemini-3.0-flash-high": "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH",
"gemini-3.0-flash-medium": "MODEL_GOOGLE_GEMINI_3_0_FLASH_MEDIUM",
"gemini-3.0-flash-low": "MODEL_GOOGLE_GEMINI_3_0_FLASH_LOW",
"gemini-3.0-flash-minimal": "MODEL_GOOGLE_GEMINI_3_0_FLASH_MINIMAL",
"gemini-3.0-flash": "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH",
"gemini-2.5-pro": "MODEL_GOOGLE_GEMINI_2_5_PRO",
// ── Others ──────────────────────────────────────────────────────────────
"deepseek-v4": "deepseek-v4",
"kimi-k2.6": "kimi-k2-6",
"kimi-k2.5": "kimi-k2-5",
"glm-5.1": "glm-5-1",
};
export function resolveWsModelId(model) {
return MODEL_ALIAS_MAP[model] ?? model;
}
// ─── Minimal protobuf encoder ────────────────────────────────────────────────
// Wire types: 0 = varint, 2 = length-delimited.
function encodeVarint(value) {
const bytes = [];
let v = value >>> 0;
while (v > 0x7f) {
bytes.push((v & 0x7f) | 0x80);
v >>>= 7;
}
bytes.push(v & 0x7f);
return new Uint8Array(bytes);
}
function concatBytes(arrays) {
const total = arrays.reduce((n, a) => n + a.length, 0);
const out = new Uint8Array(total);
let off = 0;
for (const a of arrays) {
out.set(a, off);
off += a.length;
}
return out;
}
const TEXT_ENC = new TextEncoder();
const TEXT_DEC = new TextDecoder();
function encodeField(fieldNum, payload) {
const tag = encodeVarint((fieldNum << 3) | 2);
const len = encodeVarint(payload.length);
return concatBytes([tag, len, payload]);
}
function encodeString(fieldNum, value) {
return encodeField(fieldNum, TEXT_ENC.encode(value));
}
function encodeMessage(fieldNum, msg) {
return encodeField(fieldNum, msg);
}
// ─── Protobuf message builders ───────────────────────────────────────────────
function buildMetadata(apiKey, sessionId) {
return concatBytes([
encodeString(1, apiKey),
encodeString(2, WS_IDE_NAME),
encodeString(3, WS_IDE_VERSION),
encodeString(4, WS_EXT_VERSION),
encodeString(5, sessionId),
encodeString(6, WS_LOCALE),
]);
}
function buildModelOrAlias(model) {
return encodeString(1, model);
}
function buildChatMessage(msg) {
const parts = [encodeString(1, msg.role), encodeString(2, msg.content)];
if (msg.toolCallId) parts.push(encodeString(3, msg.toolCallId));
return concatBytes(parts);
}
export function buildGetChatMessageRequest(apiKey, model, messages) {
const sessionId = randomUUID();
const cascadeId = randomUUID();
const parts = [
encodeMessage(1, buildMetadata(apiKey, sessionId)), // metadata
encodeString(2, cascadeId), // cascade_id
encodeMessage(3, buildModelOrAlias(model)), // model_or_alias
];
for (const msg of messages) {
parts.push(encodeMessage(4, buildChatMessage(msg))); // repeated messages
}
return concatBytes(parts);
}
// ─── gRPC-web framing ────────────────────────────────────────────────────────
export function grpcWebFrame(payload) {
const frame = new Uint8Array(5 + payload.length);
frame[0] = 0x00; // no compression
const view = new DataView(frame.buffer);
view.setUint32(1, payload.length, false); // big-endian length
frame.set(payload, 5);
return frame;
}
// ─── Protobuf response decoder ───────────────────────────────────────────────
// CompletionChunk (oneof):
// field 1 → ContentChunk { field 1: string text }
// field 2 → ToolCallChunk (skipped)
// field 3 → DoneChunk { field 1: UsageStats{ field1: prompt, field2: completion } }
// field 4 → ErrorChunk { field 1: string message }
function readVarint(buf, offset) {
let result = 0;
let shift = 0;
while (offset < buf.length) {
const b = buf[offset++];
result |= (b & 0x7f) << shift;
if ((b & 0x80) === 0) break;
shift += 7;
}
return [result >>> 0, offset];
}
function decodeStringField(buf, targetField) {
let offset = 0;
while (offset < buf.length) {
let tag;
[tag, offset] = readVarint(buf, offset);
const fieldNum = tag >>> 3;
const wireType = tag & 0x07;
if (wireType === 2) {
let len;
[len, offset] = readVarint(buf, offset);
const payload = buf.slice(offset, offset + len);
offset += len;
if (fieldNum === targetField) return TEXT_DEC.decode(payload);
} else if (wireType === 0) {
let v;
[v, offset] = readVarint(buf, offset);
} else if (wireType === 1) {
offset += 8;
} else if (wireType === 5) {
offset += 4;
} else {
break;
}
}
return null;
}
function decodeDoneChunk(buf) {
// DoneChunk: field 1 = UsageStats (nested)
// UsageStats: field 1 = prompt_tokens (varint), field 2 = completion_tokens (varint)
let offset = 0;
let usageBytes = null;
while (offset < buf.length) {
let tag;
[tag, offset] = readVarint(buf, offset);
const fieldNum = tag >>> 3;
const wireType = tag & 0x07;
if (wireType === 2) {
let len;
[len, offset] = readVarint(buf, offset);
if (fieldNum === 1) usageBytes = buf.slice(offset, offset + len);
offset += len;
} else if (wireType === 0) {
let v;
[v, offset] = readVarint(buf, offset);
} else {
break;
}
}
if (!usageBytes) return [0, 0];
let promptTokens = 0;
let completionTokens = 0;
offset = 0;
while (offset < usageBytes.length) {
let tag;
[tag, offset] = readVarint(usageBytes, offset);
const fieldNum = tag >>> 3;
const wireType = tag & 0x07;
if (wireType === 0) {
let v;
[v, offset] = readVarint(usageBytes, offset);
if (fieldNum === 1) promptTokens = v;
else if (fieldNum === 2) completionTokens = v;
} else if (wireType === 2) {
let len;
[len, offset] = readVarint(usageBytes, offset);
offset += len;
} else {
break;
}
}
return [promptTokens, completionTokens];
}
export function decodeCompletionChunk(buf) {
let offset = 0;
while (offset < buf.length) {
let tag;
[tag, offset] = readVarint(buf, offset);
const fieldNum = tag >>> 3;
const wireType = tag & 0x07;
if (wireType === 2) {
let len;
[len, offset] = readVarint(buf, offset);
const payload = buf.slice(offset, offset + len);
offset += len;
if (fieldNum === 1) {
const text = decodeStringField(payload, 1);
if (text !== null) return { kind: "content", text };
} else if (fieldNum === 3) {
const usage = decodeDoneChunk(payload);
return { kind: "done", promptTokens: usage[0], completionTokens: usage[1] };
} else if (fieldNum === 4) {
const msg = decodeStringField(payload, 1);
return { kind: "error", message: msg ?? "unknown windsurf error" };
}
// field 2 = ToolCallChunk — not yet handled; skip
} else if (wireType === 0) {
let v;
[v, offset] = readVarint(buf, offset);
} else if (wireType === 1) {
offset += 8;
} else if (wireType === 5) {
offset += 4;
} else {
break;
}
}
return { kind: "unknown" };
}
// ─── OpenAI messages → Windsurf wire ─────────────────────────────────────────
function openAIMessagesToWs(messages) {
const out = [];
for (const m of messages) {
const role = String(m.role || "user");
let content = "";
if (typeof m.content === "string") {
content = m.content;
} else if (Array.isArray(m.content)) {
for (const part of m.content) {
if (part && typeof part === "object" && part.type === "text") {
content += String(part.text || "");
}
}
}
out.push({ role, content, toolCallId: m.tool_call_id });
}
return out;
}
// ─── WindsurfExecutor ────────────────────────────────────────────────────────
export class WindsurfExecutor extends BaseExecutor {
constructor() {
super("windsurf", PROVIDERS.windsurf || { id: "windsurf", baseUrl: WS_CHAT_URL });
}
buildUrl() {
return WS_CHAT_URL;
}
buildHeaders(credentials, stream = true) {
const token = credentials?.accessToken || credentials?.apiKey || "";
return {
"Content-Type": "application/grpc-web+proto",
Accept: "application/grpc-web+proto",
// Codeium apiKey also goes in Metadata.api_key (protobuf field) — see request body.
...(token ? { Authorization: `Bearer ${token}` } : {}),
"User-Agent": `windsurf/${WS_IDE_VERSION}`,
"X-Grpc-Web": "1",
};
}
// Request body is built manually in execute() — requires model + messages.
transformRequest() {
return null;
}
async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders, proxyOptions = null }) {
const apiKey = credentials?.accessToken || credentials?.apiKey || "";
const wsModel = resolveWsModelId(model);
const b = body ?? {};
const rawMessages = Array.isArray(b.messages) ? b.messages : [];
let wsMessages = openAIMessagesToWs(rawMessages);
if (wsMessages.length === 0) {
wsMessages.push({ role: "user", content: "" });
}
const protoPayload = buildGetChatMessageRequest(apiKey, wsModel, wsMessages);
const framedPayload = grpcWebFrame(protoPayload);
const url = this.buildUrl();
const headers = this.buildHeaders(credentials);
if (upstreamExtraHeaders) Object.assign(headers, upstreamExtraHeaders);
log?.debug?.("WS", `Windsurf → ${wsModel} (${wsMessages.length} messages)`);
const upstream = await proxyAwareFetch(url, {
method: "POST",
headers,
body: framedPayload,
signal,
}, proxyOptions);
if (!upstream.ok && upstream.status !== 200) {
return { response: upstream, url, headers, transformedBody: protoPayload };
}
const sseResponse = this.transformToSSE(upstream, model);
return { response: sseResponse, url, headers, transformedBody: protoPayload };
}
// Convert a gRPC-web binary response into an OpenAI-compatible SSE stream.
transformToSSE(upstream, model) {
const responseId = `chatcmpl-ws-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
const executor = this;
const sseStream = new ReadableStream({
async start(controller) {
const enc = new TextEncoder();
let roleEmitted = false;
let totalText = "";
let promptTokens = 0;
let completionTokens = 0;
let hadError = null;
const emit = (data) => controller.enqueue(enc.encode(data));
try {
let pending = new Uint8Array(0);
const reader = upstream.body?.getReader();
const handleFrame = (flag, payload) => {
if (flag === 0x80) {
// Trailer frame — contains grpc-status, grpc-message
const trailer = TEXT_DEC.decode(payload);
const statusMatch = /grpc-status:\s*(\d+)/i.exec(trailer);
if (statusMatch && statusMatch[1] !== "0") {
const msgMatch = /grpc-message:\s*(.+)/i.exec(trailer);
hadError = msgMatch
? decodeURIComponent(msgMatch[1].trim())
: `gRPC status ${statusMatch[1]}`;
}
return;
}
if (flag !== 0x00) return; // skip unknown flags
const chunk = executor.constructor.decodeCompletionChunk
? executor.constructor.decodeCompletionChunk(payload)
: decodeCompletionChunk(payload);
if (chunk.kind === "content" && chunk.text) {
totalText += chunk.text;
if (!roleEmitted) {
emit(`data: ${JSON.stringify({
id: responseId, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }],
})}\n\n`);
roleEmitted = true;
}
emit(`data: ${JSON.stringify({
id: responseId, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { content: chunk.text }, finish_reason: null }],
})}\n\n`);
} else if (chunk.kind === "done") {
promptTokens = chunk.promptTokens;
completionTokens = chunk.completionTokens;
} else if (chunk.kind === "error") {
hadError = chunk.message;
}
};
const drainFrames = () => {
let offset = 0;
while (offset + 5 <= pending.length) {
const flag = pending[offset];
const len =
(pending[offset + 1] << 24) |
(pending[offset + 2] << 16) |
(pending[offset + 3] << 8) |
pending[offset + 4];
if (len < 0 || offset + 5 + len > pending.length) break;
handleFrame(flag, pending.slice(offset + 5, offset + 5 + len));
offset += 5 + len;
}
if (offset > 0) pending = pending.slice(offset);
};
if (reader) {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
pending = pending.length === 0 ? value : concatBytes([pending, value]);
drainFrames();
}
} finally {
reader.releaseLock();
}
}
drainFrames();
if (hadError) {
emit(`data: ${JSON.stringify({
error: { message: hadError, type: "windsurf_error", code: "upstream_error" },
})}\n\n`);
emit("data: [DONE]\n\n");
controller.close();
return;
}
// Unary fallback: nothing streamed but text decoded → emit as one chunk.
if (!roleEmitted && totalText) {
emit(`data: ${JSON.stringify({
id: responseId, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }],
})}\n\n`);
emit(`data: ${JSON.stringify({
id: responseId, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { content: totalText }, finish_reason: null }],
})}\n\n`);
}
const finishPayload = {
id: responseId, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
};
if (promptTokens > 0 || completionTokens > 0) {
finishPayload.usage = {
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
total_tokens: promptTokens + completionTokens,
};
}
emit(`data: ${JSON.stringify(finishPayload)}\n\n`);
emit("data: [DONE]\n\n");
} catch (err) {
const msg = err?.message ? String(err.message) : String(err);
emit(`data: ${JSON.stringify({
error: { message: `Windsurf stream error: ${msg}`, type: "windsurf_error" },
})}\n\n`);
emit("data: [DONE]\n\n");
}
controller.close();
},
});
return new Response(sseStream, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
// apiKey is long-lived (Firebase-derived or Devin ide_token); refresh handled out-of-band.
async refreshCredentials() {
return null;
}
}
export default WindsurfExecutor;

View File

@@ -1,304 +0,0 @@
// ZedHostedExecutor — routes requests to Zed's hosted LLM aggregator
// (cloud.zed.dev/completions), a multi-format proxy fronting
// Anthropic/OpenAI/Google/xAI depending on the requested model.
//
// Wire protocol: POST /completions with an NDJSON/SSE-ish body-per-line
// response stream (`{"event": <provider-shaped-chunk>}` / `{"status": ...}` /
// `[DONE]`), authenticated with a short-lived LLM bearer token exchanged from
// the RSA-decrypted access_token (see open-sse/shared/zedAuth.js). The
// provider-shaped chunk is Claude/Gemini/OpenAI-Responses/xAI(OpenAI-shaped)
// depending on which upstream Zed fronts for the model — translated back to
// OpenAI Chat Completions by reusing the existing translators.
//
// Overrides execute() entirely (does NOT use DefaultExecutor's pipeline) because the Zed wire
// shape (thread envelope, LLM-token exchange, NDJSON status frames) doesn't
// fit the generic transformRequest/buildUrl contract.
import { BaseExecutor } from "./base.js";
import { FORMATS } from "../translator/formats.js";
import { initState } from "../translator/index.js";
import { openaiToClaudeRequest } from "../translator/request/openai-to-claude.js";
import { openaiToGeminiRequest } from "../translator/request/openai-to-gemini.js";
import { openaiToOpenAIResponsesRequest } from "../translator/request/openai-responses.js";
import { claudeToOpenAIResponse } from "../translator/response/claude-to-openai.js";
import { geminiToOpenAIResponse } from "../translator/response/gemini-to-openai.js";
import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js";
import {
ZED_HEADERS,
resolveZedModels,
zedLlmFetch,
} from "../shared/zedAuth.js";
const ZED_PROVIDER = {
anthropic: "Anthropic",
openai: "OpenAi",
google: "Google",
xai: "XAi",
};
function normalizeZedProvider(value, model) {
const raw = String(value || "").toLowerCase();
if (raw === "anthropic") return ZED_PROVIDER.anthropic;
if (raw === "openai" || raw === "open_ai") return ZED_PROVIDER.openai;
if (raw === "google" || raw === "gemini") return ZED_PROVIDER.google;
if (raw === "xai" || raw === "x_ai" || raw === "x-ai") return ZED_PROVIDER.xai;
const m = String(model || "").toLowerCase();
if (m.includes("claude")) return ZED_PROVIDER.anthropic;
if (m.includes("gemini")) return ZED_PROVIDER.google;
if (m.includes("grok") || m.includes("xai")) return ZED_PROVIDER.xai;
return ZED_PROVIDER.openai;
}
function buildProviderRequest(provider, model, body, stream, credentials) {
if (provider === ZED_PROVIDER.anthropic) {
return openaiToClaudeRequest(model, body, true);
}
if (provider === ZED_PROVIDER.google) {
return openaiToGeminiRequest(model, body, true);
}
if (provider === ZED_PROVIDER.openai) {
return openaiToOpenAIResponsesRequest(model, body, true, credentials);
}
// xAI is OpenAI-shaped — forward as-is.
return { ...(body || {}), model, stream: stream !== false };
}
function initProviderState(provider, model) {
if (provider === ZED_PROVIDER.anthropic) return initState(FORMATS.CLAUDE);
if (provider === ZED_PROVIDER.google) return initState(FORMATS.GEMINI);
if (provider === ZED_PROVIDER.openai) return initState(FORMATS.OPENAI_RESPONSES);
const state = initState(FORMATS.OPENAI);
state.model = model;
return state;
}
function convertProviderEvent(provider, event, state) {
if (provider === ZED_PROVIDER.anthropic) return claudeToOpenAIResponse(event, state);
if (provider === ZED_PROVIDER.google) return geminiToOpenAIResponse(event, state);
if (provider === ZED_PROVIDER.openai) return openaiResponsesToOpenAIResponse(event, state);
return event;
}
function createErrorChunk(model, message) {
return {
id: `chatcmpl-zed-error-${Date.now()}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model,
choices: [
{ index: 0, delta: { content: `[Zed error] ${message}` }, finish_reason: "stop" },
],
};
}
function enqueueSseObject(controller, encoder, chunk) {
if (!chunk) return;
const items = Array.isArray(chunk) ? chunk : [chunk];
for (const item of items) {
if (!item) continue;
controller.enqueue(encoder.encode(`data: ${JSON.stringify(item)}\n\n`));
}
}
function unwrapZedLine(line) {
let text = line.replace(/\r$/, "").trim();
if (!text) return null;
if (text.startsWith("data:")) text = text.slice(5).trimStart();
if (text === "[DONE]") return { done: true };
try {
const parsed = JSON.parse(text);
if (parsed && Object.prototype.hasOwnProperty.call(parsed, "event")) {
return { event: parsed.event };
}
if (parsed && Object.prototype.hasOwnProperty.call(parsed, "status")) {
return { status: parsed.status };
}
return { event: parsed };
} catch {
return null;
}
}
function normalizeStatus(status) {
if (!status) return null;
if (typeof status === "string") return { type: status };
if (typeof status === "object") {
const key = Object.keys(status)[0];
if (key && typeof status[key] === "object") return { type: key, ...status[key] };
return status;
}
return null;
}
function wrapZedCompletionStream(response, provider, model) {
if (!response.ok || !response.body) return response;
const decoder = new TextDecoder();
const encoder = new TextEncoder();
const state = initProviderState(provider, model);
let buffer = "";
let done = false;
const finish = (controller) => {
if (done) return;
const finalChunk = convertProviderEvent(provider, null, state);
enqueueSseObject(controller, encoder, finalChunk);
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
done = true;
};
const processLine = (line, controller) => {
if (done) return;
const payload = unwrapZedLine(line);
if (!payload) return;
if (payload.done) {
finish(controller);
return;
}
if (payload.status) {
const status = normalizeStatus(payload.status);
if (status?.type === "failed" || status?.failed) {
const failed = status.failed || status;
const message = String(failed.message || failed.error || failed.code || "request failed");
enqueueSseObject(controller, encoder, createErrorChunk(model, message));
finish(controller);
} else if (status?.type === "stream_ended" || status === "stream_ended") {
finish(controller);
}
return;
}
const converted = convertProviderEvent(provider, payload.event, state);
enqueueSseObject(controller, encoder, converted);
};
const transformed = response.body.pipeThrough(
new TransformStream({
transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true });
let nl;
while ((nl = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nl);
buffer = buffer.slice(nl + 1);
processLine(line, controller);
}
},
flush(controller) {
buffer += decoder.decode();
if (buffer) {
processLine(buffer, controller);
buffer = "";
}
finish(controller);
},
}),
);
return new Response(transformed, {
status: response.status,
statusText: response.statusText,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
},
});
}
class ZedExecutor extends BaseExecutor {
constructor() {
super("zed");
}
async resolveModel(model, credentials, signal, log) {
try {
const catalog = await resolveZedModels(credentials, { config: this.config, signal });
let raw = catalog?.rawById?.get(model) ?? null;
if (!raw) {
const refreshed = await resolveZedModels(credentials, {
config: this.config,
signal,
forceRefresh: true,
});
raw = refreshed?.rawById?.get(model) ?? null;
}
return { raw, provider: normalizeZedProvider(raw?.provider, model) };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log?.warn?.("ZED", `model catalog unavailable, inferring provider for ${model}: ${message}`);
return { raw: null, provider: normalizeZedProvider(null, model) };
}
}
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
const { provider } = await this.resolveModel(model, credentials, signal, log);
const providerRequest = buildProviderRequest(provider, model, body, stream, credentials);
const bodyRecord = body || {};
const payload = {
thread_id: bodyRecord.thread_id || credentials?._clientSessionId,
prompt_id: bodyRecord.prompt_id,
provider,
model,
provider_request: providerRequest,
};
const response = await zedLlmFetch(credentials, "/completions", {
config: this.config,
signal,
fetchOptions: {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/x-ndjson, text/event-stream, */*",
"User-Agent": "9router/zed",
"x-zed-version": this.config?.appVersion?.toString() || "0.200.0",
[ZED_HEADERS.clientSupportsStatus]: "true",
[ZED_HEADERS.clientSupportsStreamEnded]: "true",
},
body: JSON.stringify(payload),
},
});
const wrapped = response.ok ? wrapZedCompletionStream(response, provider, model) : response;
return {
response: wrapped,
url: `${this.config?.llmBaseUrl || "https://cloud.zed.dev"}/completions`,
headers: { "Content-Type": "application/json", Authorization: "Bearer <zed-llm-token>" },
transformedBody: payload,
};
}
parseError(response, bodyText) {
let parsed = null;
try {
parsed = JSON.parse(bodyText || "{}");
} catch {
parsed = null;
}
const errorObj = parsed?.error || undefined;
const code = parsed?.code || errorObj?.code || "";
const rawMessage =
parsed?.message || errorObj?.message || bodyText || response.statusText;
if (code === "trial_blocked") {
return {
status: response.status,
message: `Zed trial access is blocked upstream. The account can list hosted models, but Zed is refusing completions until trial/billing access is enabled or unblocked. Zed says: ${rawMessage}`,
};
}
if (code) {
return { status: response.status, message: `Zed ${code}: ${rawMessage}` };
}
return { status: response.status, message: rawMessage || `Zed upstream error: ${response.status}` };
}
async refreshCredentials() {
// Zed uses a long-lived RSA-decrypted access_token — no OAuth refresh.
return null;
}
needsRefresh() {
return false;
}
}
export default ZedExecutor;

View File

@@ -1,20 +1,19 @@
import { detectFormat, getTargetFormat, resolveTransport } from "../services/provider.js";
import { translateRequest } from "../translator/index.js";
import { applyThinking, extractThinking, stripThinkingSuffix } from "../translator/concerns/thinkingUnified.js";
import { FORMATS } from "../translator/formats.js";
import { normalizeClaudePassthrough, anchorClaudeCache } from "../translator/formats/claude.js";
import { normalizeClaudePassthrough } from "../translator/formats/claude.js";
import { COLORS } from "../utils/stream.js";
import { createStreamController } from "../utils/streamHandler.js";
import { refreshWithRetry } from "../services/tokenRefresh.js";
import { createRequestLogger } from "../utils/requestLogger.js";
import { getModelTargetFormat, getModelSupportedFormats, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
import { getModelTargetFormat, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
import { PROVIDERS } from "../config/providers.js";
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
import { HTTP_STATUS, TOKEN_SAVER_HEADER } from "../config/runtimeConfig.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { handleBypassRequest } from "../utils/bypassHandler.js";
import { trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
import { getExecutor } from "../executors/index.js";
import { supportsGrokCliReasoningEffort } from "../config/grokCli.js";
import { buildRequestDetail, extractRequestConfig } from "./chatCore/requestDetail.js";
import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./chatCore/requestDetail.js";
import { handleForcedSSEToJson } from "./chatCore/sseToJsonHandler.js";
import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js";
import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/streamingHandler.js";
@@ -23,12 +22,10 @@ import { dedupeTools } from "../utils/toolDeduper.js";
import { injectCaveman } from "../rtk/caveman.js";
import { injectPonytail } from "../rtk/ponytail.js";
import { compressMessages, formatRtkLog } from "../rtk/index.js";
import { compressWithHeadroom, formatHeadroomLog, formatHeadroomSizeLog, isHeadroomPhantomSavings } from "../rtk/headroom.js";
import { compressWithPxpipe } from "../rtk/pxpipe.js";
import { compressWithHeadroom, formatHeadroomLog } from "../rtk/headroom.js";
import { getCapabilitiesForModel } from "../providers/capabilities.js";
import { stripUnsupportedModalities } from "../translator/concerns/modality.js";
import { prefetchRemoteImages } from "../translator/concerns/prefetch.js";
import { resolveSessionId } from "../utils/sessionManager.js";
/**
* Core chat handler - shared between SSE and Worker
@@ -37,38 +34,9 @@ import { resolveSessionId } from "../utils/sessionManager.js";
* @param {object} options.credentials - Provider credentials
* @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses")
*/
/**
* Remove translator-internal continuity fields from the outbound upstream
* body. The Responses→Chat request translator stashes reasoning
* `encrypted_content` on assistant messages so a later openai→responses
* round-trip can restore the store=false continuity blob; that stash must
* never reach an upstream provider. Chat-native proxies reject the unknown
* assistant-message field and answer every turn with a literal "400" body
* (observed with multi-turn Codex sessions via OpenAI-compatible nodes).
*/
export function stripContinuityFields(body) {
if (!body || !Array.isArray(body.messages)) return body;
for (const msg of body.messages) {
if (msg && typeof msg === "object") {
delete msg.encrypted_content;
delete msg.reasoning_encrypted_content;
}
}
return body;
}
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking }) {
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, onMidStreamError, clientRawRequest, connectionId, userAgent, apiKey, comboName, fallbackHistory, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, sourceFormatOverride, providerThinking }) {
const { provider, model } = modelInfo;
const requestStartTime = Date.now();
// Stable per-session color so all lines of one CLI conversation share a tag
const sessionSeed = (() => {
try {
return resolveSessionId({ headers: clientRawRequest?.headers, body, connectionId, scope: provider });
} catch {
return connectionId || "";
}
})();
const reqTag = log?.tagForSession ? log.tagForSession(sessionSeed) : (log?.nextTag ? log.nextTag() : "");
const sourceFormat = sourceFormatOverride || detectFormat(body);
@@ -78,20 +46,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
const modelTargetFormat = getModelTargetFormat(alias, model);
// Multi-endpoint providers: pick transport matching sourceFormat → zero translation.
// Per-model guard: only use the transport when the model declares support for that
// sourceFormat — opencode-go models differ in endpoint support (kimi/glm only do
// /chat/completions), so without this guard a claude-format request would wrongly
// route kimi to /messages.
const modelSupportedFormats = getModelSupportedFormats(alias, model);
// Multi-endpoint providers: pick transport matching sourceFormat → zero translation
const runtimeTransport = resolveTransport(provider, sourceFormat);
// Per-model guard: when a model declares supportedFormats, only use the
// sourceFormat-matched transport if that format is declared (opencode-go models
// differ — kimi/glm only do /chat/completions). Undeclared models keep the
// upstream default (use the transport), preserving behavior for glm/deepseek/...
const useTransport = (!modelSupportedFormats || modelSupportedFormats.includes(sourceFormat)) ? runtimeTransport : null;
const targetFormat = modelTargetFormat || useTransport?.format || getTargetFormat(provider, credentials);
if (useTransport && credentials) credentials.runtimeTransport = useTransport;
const targetFormat = modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider);
if (runtimeTransport && credentials) credentials.runtimeTransport = runtimeTransport;
const stripList = getModelStrip(alias, model);
const upstreamModel = getModelUpstreamId(alias, model);
@@ -131,7 +89,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
const acceptHeader = clientRawRequest?.headers?.accept || "";
const clientPrefersJson = acceptHeader.includes("application/json");
const clientPrefersSSE = acceptHeader.includes("text/event-stream");
if (clientPrefersJson && !clientPrefersSSE && body.stream !== true && !providerRequiresStreaming) {
if (clientPrefersJson && !clientPrefersSSE && body.stream !== true) {
stream = false;
}
@@ -163,24 +121,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
let translatedBody;
let toolNameMap;
let customToolNames;
if (passthrough) {
log?.debug?.("PASSTHROUGH", `${clientTool}${provider} | native lossless`);
translatedBody = { ...body, model: stripThinkingSuffix(upstreamModel) };
if (provider === "codex") {
const suffixThinking = {};
applyThinking(sourceFormat, upstreamModel, suffixThinking, provider);
if (suffixThinking.reasoning_effort) {
const reasoning = translatedBody.reasoning;
translatedBody.reasoning = {
...(reasoning && typeof reasoning === "object" && !Array.isArray(reasoning) ? reasoning : {}),
effort: suffixThinking.reasoning_effort,
};
delete translatedBody.reasoning_effort;
}
}
translatedBody = { ...body, model: upstreamModel };
// Normalize newer Cowork/CC beta shapes (adaptive thinking, mid-conversation system) the API rejects
if (clientTool === "claude") normalizeClaudePassthrough(translatedBody, translatedBody.model);
if (clientTool === "claude") normalizeClaudePassthrough(translatedBody, upstreamModel);
} else {
translatedBody = translateRequest(sourceFormat, targetFormat, upstreamModel, body, stream, credentials, provider, reqLogger, stripList, connectionId, clientTool);
if (!translatedBody) {
@@ -189,10 +134,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
}
toolNameMap = translatedBody._toolNameMap;
delete translatedBody._toolNameMap;
customToolNames = translatedBody._customToolNames;
delete translatedBody._customToolNames;
translatedBody.model = stripThinkingSuffix(upstreamModel);
stripContinuityFields(translatedBody);
translatedBody.model = upstreamModel;
}
// Dedupe duplicate built-in tools when equivalent MCP tools are present (Claude clients only).
@@ -208,87 +150,34 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
// Covers both passthrough (source shape) and translated (target shape) flows
const finalFormat = passthrough ? sourceFormat : targetFormat;
// Request line: one correlated summary (fmt + thinking + counts + account)
if (log?.line) {
const clientModel = clientRawRequest?.body?.model || `${provider}/${model}`;
const msgN = translatedBody.messages?.length || translatedBody.input?.length || translatedBody.contents?.length || body.messages?.length || body.input?.length || 0;
const toolN = translatedBody.tools?.length || body.tools?.length || 0;
const fmtStr = passthrough ? `FMT: ${sourceFormat} (passthrough)` : `FMT: ${sourceFormat}${targetFormat}`;
const showThinking = provider !== "grok-cli" || supportsGrokCliReasoningEffort(model);
const think = showThinking ? log.fmtThink?.(extractThinking(translatedBody)) : null;
const acc = credentials?.connectionName || credentials?.connectionId?.slice(0, 8) || "-";
const parts = [
`POST ${clientModel}${provider}/${model}`,
fmtStr,
stream ? "STREAM" : "JSON",
`${msgN} MSG`,
];
if (toolN) parts.push(`${toolN} TOOL`);
if (think) parts.push(`THINK:${think}`);
parts.push(`ACC:${acc}`);
log.line(reqTag, "▶", parts.join(" · "));
}
// TTS models don't support tool messages/function calling
if (getModelType(alias, model) === "tts" && translatedBody.messages) {
translatedBody.messages = translatedBody.messages.filter(msg => msg.role !== "tool");
delete translatedBody.tools;
}
// Per-request opt-out: client can bypass all token savers via header
const tokenSaverEnabled = clientRawRequest?.headers?.[TOKEN_SAVER_HEADER]?.toLowerCase() !== "off";
// RTK: compress tool_result content
const rtkStats = compressMessages(translatedBody, tokenSaverEnabled && rtkEnabled);
const rtkStats = compressMessages(translatedBody, rtkEnabled);
const rtkLine = formatRtkLog(rtkStats);
if (rtkLine) console.log(rtkLine);
// Headroom: optional external proxy compression; fail open if proxy is absent.
const headroomDiagnostics = {};
const headroomStats = await compressWithHeadroom(translatedBody, { enabled: tokenSaverEnabled && headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, diagnostics: headroomDiagnostics });
const headroomStats = await compressWithHeadroom(translatedBody, { enabled: headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages });
const headroomLine = formatHeadroomLog(headroomStats);
const headroomSizeLine = formatHeadroomSizeLog(headroomDiagnostics);
if (headroomLine) {
log?.info?.("HEADROOM", `${headroomLine}${headroomSizeLine ? ` | ${headroomSizeLine}` : ""}`);
if (isHeadroomPhantomSavings(headroomStats, headroomDiagnostics)) {
log?.warn?.("HEADROOM", `reported token delta, but outbound JSON shrank <5%; provider may bill near-original payload | ${formatHeadroomSizeLog(headroomDiagnostics)}`);
}
} else if (tokenSaverEnabled && headroomEnabled) log?.warn?.("HEADROOM", `skipped: ${headroomDiagnostics.reason || "compression unavailable"}${headroomDiagnostics.endpoint ? ` (${headroomDiagnostics.endpoint})` : ""}`);
// Token-saver flags accumulator for the single "⚙" log line below.
const xf = [];
if (headroomLine) log?.info?.("HEADROOM", headroomLine);
// Caveman: inject terse-style system prompt
if (tokenSaverEnabled && cavemanEnabled && cavemanLevel) {
if (cavemanEnabled && cavemanLevel) {
injectCaveman(translatedBody, finalFormat, cavemanLevel);
xf.push(`CAVEMAN:${cavemanLevel}`);
log?.debug?.("CAVEMAN", `${cavemanLevel} | ${finalFormat}`);
}
// Ponytail: inject lazy-senior-dev system prompt
if (tokenSaverEnabled && ponytailEnabled && ponytailLevel) {
if (ponytailEnabled && ponytailLevel) {
injectPonytail(translatedBody, finalFormat, ponytailLevel);
xf.push(`PONYTAIL:${ponytailLevel}`);
log?.debug?.("PONYTAIL", `${ponytailLevel} | ${finalFormat}`);
}
// PXPIPE: image bulky context (Claude-format bodies only), last saver before dispatch
let pxpipeSummary = null;
if (pxpipeEnabled) {
const pxpipeResult = await compressWithPxpipe(translatedBody, {
enabled: true, format: finalFormat, model: upstreamModel,
minChars: pxpipeMinChars, timeoutMs: pxpipeTimeoutMs, transform: pxpipeTransform,
});
pxpipeSummary = pxpipeResult.summary;
if (pxpipeResult.body) translatedBody = pxpipeResult.body;
if (pxpipeSummary?.applied) xf.push(`PXPIPE:${pxpipeSummary.imageCount}img`);
try { onPxpipeEvent?.({ provider, model, ...pxpipeSummary }); } catch { /* stats must not break requests */ }
}
if (xf.length && log?.line) log.line(reqTag, "⚙", xf.join(" · "));
// Pin cache breakpoints to the final body — every saver above can reshape
// system/tools/messages, and a stale anchor costs a full prefix rewrite.
if (passthrough && clientTool === "claude") anchorClaudeCache(translatedBody);
const executor = getExecutor(provider);
trackPendingRequest(model, provider, connectionId, true);
appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => { });
@@ -296,13 +185,43 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
const msgCount = translatedBody.messages?.length || translatedBody.input?.length || translatedBody.contents?.length || translatedBody.request?.contents?.length || 0;
log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`);
// Validate credentials before sending request - try refresh if needed
if (!executor.noAuth && credentials?.accessToken) {
const needsRefresh = executor.needsRefresh?.(credentials) ?? shouldRefreshCredentials(provider, credentials);
if (needsRefresh) {
try {
const newCredentials = await refreshWithRetry(() => executor.refreshCredentials(credentials, log), 3, log);
if (newCredentials?.accessToken || newCredentials?.copilotToken) {
log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed before request`);
Object.assign(credentials, newCredentials);
if (onCredentialsRefreshed) {
try { await onCredentialsRefreshed(newCredentials); } catch (e) { log?.warn?.("TOKEN", `onCredentialsRefreshed failed: ${e.message}`); }
}
} else if (newCredentials?.error === "unrecoverable_refresh_error") {
const msg = newCredentials.message || `${provider} token invalid; reconnect the account`;
log?.warn?.("TOKEN", `${provider.toUpperCase()} | unrecoverable refresh error: ${msg}`);
trackPendingRequest(model, provider, connectionId, false, true);
return createErrorResult(HTTP_STATUS.UNAUTHORIZED, msg);
} else {
log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed before request`);
trackPendingRequest(model, provider, connectionId, false, true);
return createErrorResult(HTTP_STATUS.UNAUTHORIZED, `${provider} token refresh failed. Please reconnect the account.`);
}
} catch (e) {
log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh threw before request: ${e.message}`);
trackPendingRequest(model, provider, connectionId, false, true);
return createErrorResult(HTTP_STATUS.UNAUTHORIZED, `${provider} token refresh failed: ${e.message}`);
}
}
}
const streamController = createStreamController({
onDisconnect: (reason) => {
trackPendingRequest(model, provider, connectionId, false);
if (onDisconnect) onDisconnect(reason);
},
onError: () => trackPendingRequest(model, provider, connectionId, false),
log, provider, model, reqTag
log, provider, model
});
const proxyOptions = {
@@ -339,71 +258,51 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
}
// Execute request
let providerResponse, providerUrl, providerHeaders, finalBody;
// Most executors return their registry format. Cursor AgentService is an
// exception: it is decoded by the executor into OpenAI-compatible output.
let providerResponseFormat = targetFormat;
let providerResponse, providerUrl, providerHeaders, finalBody, midStreamError;
try {
const result = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions });
providerResponse = result.response;
providerUrl = result.url;
providerHeaders = result.headers;
finalBody = result.transformedBody;
providerResponseFormat = result.responseFormat || targetFormat;
midStreamError = result.midStreamError;
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
} catch (error) {
trackPendingRequest(model, provider, connectionId, false, true);
appendRequestLog({ model, provider, connectionId, status: `FAILED ${error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}` }).catch(() => { });
saveRequestDetail(buildRequestDetail({
provider, model, connectionId,
provider, model, connectionId, apiKey, comboName, fallbackHistory,
latency: { ttft: 0, total: Date.now() - requestStartTime },
tokens: { prompt_tokens: 0, completion_tokens: 0 },
request: extractRequestConfig(body, stream),
providerRequest: translatedBody || null,
response: { error: error.message || String(error), status: error.name === "AbortError" ? 499 : 502, thinking: null },
pxpipe: pxpipeSummary,
status: "error"
})).catch(() => { });
saveUsageStats({ provider, model, tokens: { prompt_tokens: 0, completion_tokens: 0 }, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, comboName, fallbackHistory, status: "error", label: "ERROR" });
if (error.name === "AbortError") {
streamController.handleError(error);
return createErrorResult(499, "Request aborted");
}
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
if (log?.errorLine) {
log.errorLine(reqTag, "✗", `ERROR 502 · ${provider}/${model} · ${Date.now() - requestStartTime}ms\n ${errMsg}${error.stack ? `\n ${error.stack}` : ""}`);
}
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg);
}
// Handle 401/403 - try token refresh (skip for noAuth providers)
if (!executor.noAuth && (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || providerResponse.status === HTTP_STATUS.FORBIDDEN)) {
try {
// Mutate credentials after each successful refresh: rotating refresh_token
// providers (xAI/grok-cli) issue a new RT on every refresh; without this,
// refreshWithRetry's 2nd/3rd attempt reuses the already-consumed RT →
// invalid_grant → auth_failed retryable=false.
const newCredentials = await refreshWithRetry(async () => {
const result = await executor.refreshCredentials(credentials, log);
if (result?.refreshToken && result.refreshToken !== credentials.refreshToken) {
if (result.accessToken) credentials.accessToken = result.accessToken;
credentials.refreshToken = result.refreshToken;
}
return result;
}, 3, log);
const newCredentials = await refreshWithRetry(() => executor.refreshCredentials(credentials, log), 3, log);
if (newCredentials?.accessToken || newCredentials?.copilotToken) {
if (log?.line) log.line(reqTag, "🔑", `TOKEN REFRESHED · ${provider}/${model}`);
log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed`);
Object.assign(credentials, newCredentials);
if (onCredentialsRefreshed) {
try { await onCredentialsRefreshed(newCredentials); } catch (e) { log?.warn?.("TOKEN", `onCredentialsRefreshed failed: ${e.message}`); }
}
try {
const retryResult = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions });
if (retryResult.response.ok) {
providerResponse = retryResult.response;
providerUrl = retryResult.url;
providerResponseFormat = retryResult.responseFormat || targetFormat;
}
if (retryResult.response.ok) { providerResponse = retryResult.response; providerUrl = retryResult.url; }
} catch { log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); }
} else {
log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`);
@@ -419,45 +318,42 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
const { statusCode, message, resetsAtMs } = await parseUpstreamError(providerResponse, executor);
appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => { });
saveRequestDetail(buildRequestDetail({
provider, model, connectionId,
provider, model, connectionId, apiKey, comboName, fallbackHistory,
latency: { ttft: 0, total: Date.now() - requestStartTime },
tokens: { prompt_tokens: 0, completion_tokens: 0 },
request: extractRequestConfig(body, stream),
providerRequest: finalBody || translatedBody || null,
response: { error: message, status: statusCode, thinking: null },
pxpipe: pxpipeSummary,
status: "error"
})).catch(() => { });
saveUsageStats({ provider, model, tokens: { prompt_tokens: 0, completion_tokens: 0 }, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, comboName, fallbackHistory, status: "error", label: "ERROR" });
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
if (log?.errorLine) {
const urlStr = providerUrl ? `\n URL: ${providerUrl}` : "";
log.errorLine(reqTag, "✗", `ERROR ${statusCode} · ${provider}/${model} · ${Date.now() - requestStartTime}ms${urlStr}\n ${errMsg}`);
}
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
reqLogger.logError(new Error(message), finalBody || translatedBody);
return createErrorResult(statusCode, errMsg, resetsAtMs);
}
const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, pxpipe: pxpipeSummary, reqTag, log };
const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, comboName, fallbackHistory, clientRawRequest, onRequestSuccess, midStreamError, onMidStreamError };
const appendLog = (extra) => appendRequestLog({ model, provider, connectionId, ...extra }).catch(() => { });
const trackDone = () => trackPendingRequest(model, provider, connectionId, false);
// Provider forced streaming but client wants JSON
if (!clientRequestedStreaming && providerRequiresStreaming) {
const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, customToolNames, trackDone, appendLog });
const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, trackDone, appendLog });
if (result) { streamController.handleComplete(); return result; }
}
// True non-streaming response
if (!stream) {
const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, reqLogger, toolNameMap, customToolNames, trackDone, appendLog });
const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, reqLogger, toolNameMap, trackDone, appendLog });
streamController.handleComplete();
return result;
}
// Streaming response
const { onStreamComplete, streamDetailId } = buildOnStreamComplete({ ...sharedCtx });
return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, userAgent, reqLogger, toolNameMap, customToolNames, streamController, onStreamComplete, streamDetailId });
// Streaming response (midStreamError + onMidStreamError already in sharedCtx)
const { onStreamComplete } = buildOnStreamComplete(sharedCtx);
return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete });
}
export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) {

View File

@@ -1,157 +1,19 @@
import { FORMATS } from "../../translator/formats.js";
import { needsTranslation } from "../../translator/index.js";
import { fromOpenAIFinish } from "../../translator/concerns/finishReason.js";
import { ollamaBodyToOpenAI } from "../../translator/response/ollama-to-openai.js";
import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js";
import { createErrorResult } from "../../utils/error.js";
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js";
import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats, formatDoneLine } from "./requestDetail.js";
import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats } from "./requestDetail.js";
import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
import { decloakToolNames } from "../../utils/claudeCloaking.js";
import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js";
function parseToolArguments(value) {
if (!value) return {};
if (typeof value === "object") return value;
try {
return JSON.parse(value);
} catch {
return {};
}
}
function openAICompletionToClaudeMessage(responseBody) {
if (!responseBody?.choices?.[0]) return responseBody;
const choice = responseBody.choices[0];
const message = choice.message || {};
const content = [];
const reasoning = message.reasoning_content || message.provider_specific_fields?.reasoning_content || "";
if (reasoning) {
content.push({ type: "thinking", thinking: reasoning });
}
if (typeof message.content === "string" && message.content.length > 0) {
content.push({ type: "text", text: message.content });
}
for (const toolCall of message.tool_calls || []) {
const fn = toolCall.function || {};
content.push({
type: "tool_use",
id: toolCall.id || `toolu_${Date.now()}_${content.length}`,
name: fn.name || toolCall.name || "",
input: parseToolArguments(fn.arguments || toolCall.arguments),
});
}
if (content.length === 0) content.push({ type: "text", text: "" });
const usage = responseBody.usage || {};
return {
id: String(responseBody.id || `msg_${Date.now()}`).replace(/^chatcmpl-/, ""),
type: "message",
role: "assistant",
model: responseBody.model || "unknown",
content,
stop_reason: fromOpenAIFinish(choice.finish_reason, FORMATS.CLAUDE),
stop_sequence: null,
usage: {
input_tokens: usage.prompt_tokens || usage.input_tokens || 0,
output_tokens: usage.completion_tokens || usage.output_tokens || 0,
},
};
}
/**
* Convert an OpenAI Chat Completions non-streaming response body into the
* OpenAI Responses API shape. Used when a Responses-format client (e.g. Codex)
* is routed to a Chat Completions upstream and `stream:false` — the streaming
* path already emits Responses events, but the JSON path returned a raw
* `chat.completion` body, so tool_calls were invisible to Responses clients.
*/
function extractCustomToolInput(argumentsValue) {
const argumentsText = typeof argumentsValue === "string" ? argumentsValue : JSON.stringify(argumentsValue || {});
try {
const parsed = JSON.parse(argumentsText);
if (parsed && typeof parsed === "object" && typeof parsed.input === "string") return parsed.input;
} catch { /* raw freeform input */ }
return argumentsText;
}
function openAICompletionToResponses(responseBody, customToolNames = null) {
const choice = responseBody?.choices?.[0];
if (!choice) return responseBody;
const message = choice.message || {};
const output = [];
// Reasoning → a reasoning item (summary text), mirroring the streaming path.
const reasoning = message.reasoning_content || message.reasoning;
if (typeof reasoning === "string" && reasoning.length > 0) {
output.push({
type: RESPONSES_ITEM.REASONING,
summary: [{ type: RESPONSES_ITEM.SUMMARY_TEXT, text: reasoning }],
});
}
// Assistant text → a message item with output_text content.
const text = typeof message.content === "string" ? message.content : "";
if (text.length > 0) {
output.push({
type: RESPONSES_ITEM.MESSAGE,
role: ROLE.ASSISTANT,
content: [{ type: RESPONSES_ITEM.OUTPUT_TEXT, text, annotations: [] }],
});
}
// tool_calls → function_call/custom_tool_call items (Responses-native tool shape).
for (const tc of message.tool_calls || []) {
const fn = tc.function || {};
const custom = customToolNames?.has(fn.name);
output.push({
type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL,
id: `${custom ? "ctc" : "fc"}_${tc.id || ""}`,
call_id: tc.id || "",
name: fn.name || "",
...(custom
? { input: extractCustomToolInput(fn.arguments) }
: { arguments: typeof fn.arguments === "string" ? fn.arguments : JSON.stringify(fn.arguments || {}) }),
});
}
const usage = responseBody.usage || {};
const status = choice.finish_reason === "tool_calls" ? "completed" : (choice.finish_reason === "stop" ? "completed" : (choice.finish_reason || "completed"));
return {
id: `resp_${responseBody.id || ""}`.replace(/^resp_chatcmpl-/, "resp_"),
object: "response",
created_at: responseBody.created || Math.floor(Date.now() / 1000),
model: responseBody.model || "unknown",
status,
background: false,
error: null,
output,
usage: {
input_tokens: usage.prompt_tokens || usage.input_tokens || 0,
output_tokens: usage.completion_tokens || usage.output_tokens || 0,
total_tokens: usage.total_tokens || (usage.prompt_tokens || 0) + (usage.completion_tokens || 0),
},
};
}
/**
* Translate non-streaming response body from provider format → OpenAI format.
*/
export function translateNonStreamingResponse(responseBody, targetFormat, sourceFormat, customToolNames = null) {
if (targetFormat === sourceFormat) return responseBody;
// Provider responded in OpenAI Chat Completions shape but the client speaks
// Responses API — convert so tool_calls/text surface as Responses `output`.
if (targetFormat === FORMATS.OPENAI && sourceFormat === FORMATS.OPENAI_RESPONSES) {
return openAICompletionToResponses(responseBody, customToolNames);
}
if (targetFormat === FORMATS.OPENAI && sourceFormat === FORMATS.CLAUDE) {
return openAICompletionToClaudeMessage(responseBody);
}
if (targetFormat === FORMATS.OPENAI) return responseBody;
export function translateNonStreamingResponse(responseBody, targetFormat, sourceFormat) {
if (targetFormat === sourceFormat || targetFormat === FORMATS.OPENAI) return responseBody;
// Gemini / Antigravity
if (targetFormat === FORMATS.GEMINI || targetFormat === FORMATS.ANTIGRAVITY || targetFormat === FORMATS.GEMINI_CLI || targetFormat === FORMATS.VERTEX) {
@@ -281,7 +143,7 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source
/**
* Handle non-streaming response from provider.
*/
export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, customToolNames, trackDone, appendLog, pxpipe, reqTag, log }) {
export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, comboName, fallbackHistory, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, trackDone, appendLog, midStreamError, onMidStreamError }) {
trackDone();
const contentType = providerResponse.headers.get("content-type") || "";
let responseBody;
@@ -293,6 +155,21 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
appendLog({ status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}` });
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request");
}
// Check for mid-stream errors detected during SSE parsing (e.g. Qoder quota exceeded).
// The upstream returned HTTP 200 but the SSE envelope contained a non-200 statusCodeValue.
// Without this check, the error message would be silently served as normal content with HTTP 200.
if (midStreamError?.error) {
const err = midStreamError.error;
if (typeof onMidStreamError === "function") {
onMidStreamError(err).catch(e => {
console.error("[MidStreamError] Failed to apply cooldown:", e.message);
});
}
appendLog({ status: `FAILED ${err.status || 429}` });
return createErrorResult(err.status || 429, err.message || "Upstream error detected in SSE stream");
}
responseBody = parsed;
} else {
try {
@@ -305,29 +182,18 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
}
reqLogger.logProviderResponse(providerResponse.status, providerResponse.statusText, providerResponse.headers, responseBody);
if (onRequestSuccess) {
Promise.resolve()
.then(onRequestSuccess)
.catch(err => {
console.error("[ChatCore] onRequestSuccess failed:", err?.message || err);
});
}
if (onRequestSuccess) await onRequestSuccess();
// Decloak tool_use names once on raw Claude body, before any translation (INPUT side)
responseBody = decloakToolNames(responseBody, toolNameMap);
const usage = extractUsageFromResponse(responseBody);
appendLog({ tokens: usage, status: "200 OK" });
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, silent: true });
if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency: { total: Date.now() - requestStartTime } }));
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, comboName });
const translatedResponse = needsTranslation(targetFormat, sourceFormat)
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat, customToolNames)
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
: responseBody;
const isClaudeMessageResponse = sourceFormat === FORMATS.CLAUDE && translatedResponse?.type === "message";
// Responses-format translation produces a `object:"response"` body with no
// `choices`; skip the Chat-Completions-specific post-processing below for it.
const isResponsesResponse = sourceFormat === FORMATS.OPENAI_RESPONSES && translatedResponse?.object === "response";
// Fix finish_reason for tool_calls: some providers return non-standard values (e.g. "other")
if (translatedResponse?.choices?.[0]) {
@@ -340,17 +206,13 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
}
// Ensure OpenAI-required fields
if (!isClaudeMessageResponse && !isResponsesResponse) {
if (!translatedResponse.object) translatedResponse.object = "chat.completion";
if (!translatedResponse.created) translatedResponse.created = Math.floor(Date.now() / 1000);
}
if (!translatedResponse.object) translatedResponse.object = "chat.completion";
if (!translatedResponse.created) translatedResponse.created = Math.floor(Date.now() / 1000);
// Strip Azure-specific fields
if (!isClaudeMessageResponse && !isResponsesResponse) {
delete translatedResponse.prompt_filter_results;
if (translatedResponse?.choices) {
for (const choice of translatedResponse.choices) delete choice.content_filter_results;
}
delete translatedResponse.prompt_filter_results;
if (translatedResponse?.choices) {
for (const choice of translatedResponse.choices) delete choice.content_filter_results;
}
if (translatedResponse?.usage) {
@@ -360,7 +222,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
// Strip reasoning_content only when content is non-empty.
// When content is empty (e.g. thinking models that used all tokens for reasoning),
// reasoning_content is the only useful output and must be preserved.
if (!isClaudeMessageResponse && !isResponsesResponse && translatedResponse?.choices) {
if (translatedResponse?.choices) {
for (const choice of translatedResponse.choices) {
if (choice?.message?.reasoning_content && choice.message.content) {
delete choice.message.reasoning_content;
@@ -372,7 +234,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
const totalLatency = Date.now() - requestStartTime;
saveRequestDetail(buildRequestDetail({
provider, model, connectionId,
provider, model, connectionId, apiKey, comboName, fallbackHistory,
latency: { ttft: totalLatency, total: totalLatency },
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
request: extractRequestConfig(body, stream),
@@ -383,7 +245,6 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
thinking: translatedResponse?.choices?.[0]?.message?.reasoning_content || translatedResponse?.reasoning_content || null,
finish_reason: translatedResponse?.choices?.[0]?.finish_reason || "unknown"
},
pxpipe,
status: "success"
}, { endpoint: clientRawRequest?.endpoint || null })).catch(err => {
console.error("[RequestDetail] Failed to save:", err.message);

View File

@@ -1,6 +1,5 @@
import { saveRequestUsage, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
import { COLORS } from "../../utils/stream.js";
import { canonicalizeUsage } from "../../utils/usageTracking.js";
const OPTIONAL_PARAMS = [
"temperature", "top_p", "top_k",
@@ -44,14 +43,12 @@ export function extractUsageFromResponse(responseBody) {
};
}
// Gemini format. Antigravity / gemini-cli wrap the payload in { response: {...} }.
const usageMetadata = responseBody.usageMetadata || responseBody.response?.usageMetadata;
if (usageMetadata) {
// Gemini format
if (responseBody.usageMetadata) {
return {
prompt_tokens: usageMetadata.promptTokenCount || 0,
completion_tokens: usageMetadata.candidatesTokenCount || 0,
cached_tokens: usageMetadata.cachedContentTokenCount || 0,
reasoning_tokens: usageMetadata.thoughtsTokenCount || 0
prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0,
completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0,
reasoning_tokens: responseBody.usageMetadata.thoughtsTokenCount
};
}
@@ -63,6 +60,9 @@ export function buildRequestDetail(base, overrides = {}) {
provider: base.provider || "unknown",
model: base.model || "unknown",
connectionId: base.connectionId || undefined,
apiKey: base.apiKey || undefined,
comboName: base.comboName || null,
fallbackHistory: base.fallbackHistory || null,
timestamp: new Date().toISOString(),
latency: base.latency || { ttft: 0, total: 0 },
tokens: base.tokens || { prompt_tokens: 0, completion_tokens: 0 },
@@ -70,49 +70,42 @@ export function buildRequestDetail(base, overrides = {}) {
providerRequest: base.providerRequest || null,
providerResponse: base.providerResponse || null,
response: base.response || {},
pxpipe: base.pxpipe || undefined,
status: base.status || "success",
...overrides
};
}
// Build the "done" summary: duration, ttft, in/out tokens with cache breakdown
export function formatDoneLine({ usage, latency }) {
const u = usage || {};
const inTok = u.prompt_tokens ?? u.input_tokens ?? 0;
const outTok = u.completion_tokens ?? u.output_tokens ?? 0;
const cacheRead = u.cache_read_input_tokens ?? u.cached_tokens ?? u.prompt_tokens_details?.cached_tokens ?? 0;
const cacheCreate = u.cache_creation_input_tokens ?? 0;
let inStr = `IN ${inTok}`;
if (cacheRead || cacheCreate) {
const parts = [];
if (cacheRead) parts.push(`${cacheRead}`);
if (cacheCreate) parts.push(`+${cacheCreate}`);
inStr += ` (CACHE ${parts.join(" ")})`;
}
const ttftStr = latency?.ttft ? ` · TTFT ${latency.ttft}ms` : "";
return `DONE ${latency?.total ?? 0}ms${ttftStr} · ${inStr} · OUT ${outTok}`;
}
export function saveUsageStats({ provider, model, tokens, connectionId, apiKey, endpoint, label = "USAGE", silent = false }) {
export function saveUsageStats({ provider, model, tokens, connectionId, apiKey, endpoint, comboName, fallbackHistory, status = "ok", label = "USAGE" }) {
if (!tokens || typeof tokens !== "object") return;
const inTokens = tokens.input_tokens ?? tokens.prompt_tokens ?? 0;
const outTokens = tokens.output_tokens ?? tokens.completion_tokens ?? 0;
if (inTokens === 0 && outTokens === 0) return;
if (inTokens === 0 && outTokens === 0 && status !== "error") return;
if (!silent) {
const time = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
const accountSuffix = connectionId ? ` | account=${connectionId.slice(0, 8)}...` : "";
console.log(`${COLORS.green}[${time}] 📊 [${label}] ${provider.toUpperCase()} | in=${inTokens} | out=${outTokens}${accountSuffix}${COLORS.reset}`);
}
// Extract cache/reasoning tokens (unified from different formats)
const cacheRead = tokens.cache_read_input_tokens || tokens.cached_tokens || tokens.prompt_tokens_details?.cached_tokens || 0;
const cacheCreation = tokens.cache_creation_input_tokens || 0;
const reasoning = tokens.reasoning_tokens || 0;
// Canonicalize to one storage convention (prompt_tokens cache-inclusive) so
// cached/cache-creation tokens survive to cost calc + stats. See canonicalizeUsage.
const normalized = canonicalizeUsage(tokens) || {
const time = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
const accountSuffix = connectionId ? ` | account=${connectionId.slice(0, 8)}...` : "";
let msg = `${COLORS.green}[${time}] 📊 [${label}] ${provider?.toUpperCase() || "UNKNOWN"} | in=${inTokens} | out=${outTokens}${accountSuffix}`;
if (tokens.estimated) msg += ` ${COLORS.yellow}(estimated)${COLORS.reset}`;
if (cacheRead) msg += ` | cache_read=${cacheRead}`;
if (cacheCreation) msg += ` | cache_create=${cacheCreation}`;
if (reasoning) msg += ` | reasoning=${reasoning}`;
msg += `${COLORS.reset}`;
console.log(msg);
// Normalize to OpenAI token shape for storage (include all token types)
const normalized = {
prompt_tokens: tokens.prompt_tokens ?? tokens.input_tokens ?? 0,
completion_tokens: tokens.completion_tokens ?? tokens.output_tokens ?? 0
completion_tokens: tokens.completion_tokens ?? tokens.output_tokens ?? 0,
cache_read_input_tokens: cacheRead,
cache_creation_input_tokens: cacheCreation,
reasoning_tokens: reasoning,
};
saveRequestUsage({
@@ -122,6 +115,9 @@ export function saveUsageStats({ provider, model, tokens, connectionId, apiKey,
timestamp: new Date().toISOString(),
connectionId: connectionId || undefined,
apiKey: apiKey || undefined,
endpoint: endpoint || null
endpoint: endpoint || null,
comboName: comboName || undefined,
fallbackHistory: fallbackHistory || undefined,
status: status || "ok",
}).catch(() => {});
}

View File

@@ -3,8 +3,7 @@ import { createErrorResult } from "../../utils/error.js";
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
import { FORMATS } from "../../translator/formats.js";
import { PROVIDERS } from "../../config/providers.js";
import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js";
import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js";
import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js";
// Responses-API providers (e.g. codex) may emit SSE without content-type + use Responses output shape
const isResponsesProvider = (p) => PROVIDERS[p]?.format === FORMATS.OPENAI_RESPONSES;
@@ -35,97 +34,21 @@ function pickAssistantMessageForChatCompletion(output) {
return { msgItem: last, textContent: textFromResponsesMessageItem(last) };
}
/**
* Convert an OpenAI Chat Completions JSON body into the Responses API shape.
* Inlined here (not imported from nonStreamingHandler.js) to avoid a circular
* import. Mirrors openAICompletionToResponses in nonStreamingHandler.js.
*/
function extractCustomToolInput(argumentsValue) {
const argumentsText = typeof argumentsValue === "string" ? argumentsValue : JSON.stringify(argumentsValue || {});
try {
const parsed = JSON.parse(argumentsText);
if (parsed && typeof parsed === "object" && typeof parsed.input === "string") return parsed.input;
} catch { /* raw freeform input */ }
return argumentsText;
}
function chatCompletionToResponses(responseBody, customToolNames = null) {
const choice = responseBody?.choices?.[0];
if (!choice) return responseBody;
const message = choice.message || {};
const output = [];
const reasoning = message.reasoning_content || message.reasoning;
if (typeof reasoning === "string" && reasoning.length > 0) {
output.push({
type: RESPONSES_ITEM.REASONING,
summary: [{ type: RESPONSES_ITEM.SUMMARY_TEXT, text: reasoning }],
});
}
const text = typeof message.content === "string" ? message.content : "";
if (text.length > 0) {
output.push({
type: RESPONSES_ITEM.MESSAGE,
role: ROLE.ASSISTANT,
content: [{ type: RESPONSES_ITEM.OUTPUT_TEXT, text, annotations: [] }],
});
}
for (const tc of message.tool_calls || []) {
const fn = tc.function || {};
const custom = customToolNames?.has(fn.name);
output.push({
type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL,
id: `${custom ? "ctc" : "fc"}_${tc.id || ""}`,
call_id: tc.id || "",
name: fn.name || "",
...(custom
? { input: extractCustomToolInput(fn.arguments) }
: { arguments: typeof fn.arguments === "string" ? fn.arguments : JSON.stringify(fn.arguments || {}) }),
});
}
const usage = responseBody.usage || {};
return {
id: `resp_${responseBody.id || ""}`.replace(/^resp_chatcmpl-/, "resp_"),
object: "response",
created_at: responseBody.created || Math.floor(Date.now() / 1000),
model: responseBody.model || "unknown",
status: "completed",
background: false,
error: null,
output,
usage: {
input_tokens: usage.prompt_tokens || usage.input_tokens || 0,
output_tokens: usage.completion_tokens || usage.output_tokens || 0,
total_tokens: usage.total_tokens || (usage.prompt_tokens || 0) + (usage.completion_tokens || 0),
},
};
}
/**
* Parse OpenAI-style SSE text into a single chat completion JSON.
* Used when provider forces streaming but client wants non-streaming.
*/
export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
const chunks = [];
let streamError = null;
for (const line of String(rawSSE || "").split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
try {
const chunk = JSON.parse(payload);
if (chunk?.error) streamError = chunk.error;
else chunks.push(chunk);
} catch { /* ignore malformed lines */ }
try { chunks.push(JSON.parse(payload)); } catch { /* ignore malformed lines */ }
}
if (streamError) return { error: streamError };
if (chunks.length === 0) return null;
const first = chunks[0];
@@ -179,7 +102,7 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
* Handle case: provider forced streaming but client wants JSON.
* Supports both Codex/Responses API SSE and standard Chat Completions SSE.
*/
export async function handleForcedSSEToJson({ providerResponse, sourceFormat, targetFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, customToolNames, trackDone, appendLog, reqTag, log }) {
export async function handleForcedSSEToJson({ providerResponse, sourceFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, comboName, fallbackHistory, clientRawRequest, onRequestSuccess, trackDone, appendLog }) {
const contentType = providerResponse.headers.get("content-type") || "";
const isSSE = contentType.includes("text/event-stream") || (contentType === "" && isResponsesProvider(provider));
if (!isSSE) return null; // not handled here
@@ -187,16 +110,13 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, ta
trackDone();
const ctx = {
provider, model, connectionId,
provider, model, connectionId, apiKey, comboName, fallbackHistory,
request: extractRequestConfig(body, stream),
providerRequest: finalBody || translatedBody || null
};
// Codex/Responses API SSE path
// Branch on the UPSTREAM format (targetFormat = format we spoke to the provider in),
// not the client format: a Responses-API client behind a chat-native forced-streaming
// provider still receives chat SSE chunks, which must go through the standard path.
const isCodexResponsesApi = isResponsesProvider(provider) || targetFormat === FORMATS.OPENAI_RESPONSES;
const isCodexResponsesApi = isResponsesProvider(provider) || sourceFormat === FORMATS.OPENAI_RESPONSES;
if (isCodexResponsesApi) {
try {
const jsonResponse = await convertResponsesStreamToJson(providerResponse.body);
@@ -204,21 +124,15 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, ta
const usage = jsonResponse.usage || {};
appendLog({ tokens: usage, status: "200 OK" });
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, silent: true });
if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency: { total: Date.now() - requestStartTime } }));
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, comboName });
// Same cache-inclusive total for the recorded detail, so the DB and the
// client-facing usage can never disagree.
const inTokensForLog = (usage.input_tokens || 0)
+ (usage.cache_read_input_tokens || usage.cached_tokens || 0)
+ (usage.cache_creation_input_tokens || 0);
const { msgItem, textContent } = pickAssistantMessageForChatCompletion(jsonResponse.output);
const totalLatency = Date.now() - requestStartTime;
saveRequestDetail(buildRequestDetail({
...ctx,
latency: { ttft: totalLatency, total: totalLatency },
tokens: { prompt_tokens: inTokensForLog, completion_tokens: usage.output_tokens || 0 },
tokens: { prompt_tokens: usage.input_tokens || 0, completion_tokens: usage.output_tokens || 0 },
response: { content: textContent, thinking: null, finish_reason: jsonResponse.status || "unknown" },
status: "success"
}, { endpoint: clientRawRequest?.endpoint || null })).catch(() => {});
@@ -228,21 +142,9 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, ta
return { success: true, response: new Response(JSON.stringify(jsonResponse), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
}
// Build client-format response.
// input_tokens EXCLUDES cached tokens on cache-capable upstreams, so summing
// only input+output under-reports prompt_tokens — measured: 2012 reported
// where the real prompt was ~5344 with 5332 served from cache. Fold the cache
// counters in, and keep them visible in prompt_tokens_details so a client can
// tell a cache hit from a small prompt.
const cacheRead = usage.cache_read_input_tokens || usage.cached_tokens || 0;
const cacheCreate = usage.cache_creation_input_tokens || 0;
const inTokens = (usage.input_tokens || 0) + cacheRead + cacheCreate;
// Build client-format response
const inTokens = usage.input_tokens || 0;
const outTokens = usage.output_tokens || 0;
const cacheDetails = (cacheRead > 0 || cacheCreate > 0)
? { prompt_tokens_details: {
...(cacheRead > 0 ? { cached_tokens: cacheRead } : {}),
...(cacheCreate > 0 ? { cache_creation_tokens: cacheCreate } : {}) } }
: {};
let finalResp;
// Extract tool calls from Responses API output (function_call items)
@@ -277,7 +179,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, ta
created: jsonResponse.created_at || Math.floor(Date.now() / 1000),
model: jsonResponse.model || model,
choices: [{ index: 0, message, finish_reason: finishReason }],
usage: { prompt_tokens: inTokens, completion_tokens: outTokens, total_tokens: inTokens + outTokens, ...cacheDetails }
usage: { prompt_tokens: inTokens, completion_tokens: outTokens, total_tokens: inTokens + outTokens }
};
}
@@ -293,19 +195,12 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, ta
const sseText = await providerResponse.text();
const parsed = parseSSEToOpenAIResponse(sseText, model);
if (!parsed) return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request");
if (parsed.error) {
return createErrorResult(
HTTP_STATUS.BAD_GATEWAY,
parsed.error.message || "Upstream SSE stream failed"
);
}
if (onRequestSuccess) await onRequestSuccess();
const usage = parsed.usage || {};
appendLog({ tokens: usage, status: "200 OK" });
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, silent: true });
if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency: { total: Date.now() - requestStartTime } }));
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, comboName });
const totalLatency = Date.now() - requestStartTime;
saveRequestDetail(buildRequestDetail({
@@ -320,15 +215,6 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, ta
status: "success"
}, { endpoint: clientRawRequest?.endpoint || null })).catch(() => {});
// Re-attach usage explicitly. This handler already HAS the correct usage — it is
// the same object written to the usage DB, and for a cached Claude request that DB
// row reads cache_read_input_tokens: 11022 — yet the client was observed receiving
// no usage field at all (verified 2026-08-04 with a fingerprinted payload matched
// on both sides). Whatever drops it between assembly and serialisation, the client
// must not be left unable to account for its own token spend: a caller cannot tell
// a 90%-cached request from a cheap one without this.
if (usage && Object.keys(usage).length > 0) parsed.usage = usage;
// Strip reasoning_content only when content is non-empty.
// When content is empty (e.g. thinking models that used all tokens for reasoning),
// reasoning_content is the only useful output and must be preserved.
@@ -341,17 +227,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, ta
}
}
// A Responses-format client (e.g. Codex) forced this provider to stream,
// but wants JSON back. parseSSEToOpenAIResponse yields a Chat Completions
// body; convert it to the Responses `output` shape so tool_calls are not
// lost on the non-streaming return path. Inlined (not imported from
// nonStreamingHandler.js) to avoid a circular import: nonStreamingHandler
// already imports parseSSEToOpenAIResponse from this module.
const finalBody = sourceFormat === FORMATS.OPENAI_RESPONSES
? chatCompletionToResponses(parsed, customToolNames)
: parsed;
return { success: true, response: new Response(JSON.stringify(finalBody), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
return { success: true, response: new Response(JSON.stringify(parsed), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
} catch (err) {
console.error("[ChatCore] Chat Completions SSE→JSON failed:", err);
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON");

View File

@@ -5,7 +5,7 @@ import { pipeWithDisconnect } from "../../utils/streamHandler.js";
import { PROVIDERS } from "../../config/providers.js";
import { STREAM_STALL_TIMEOUT_MS } from "../../config/runtimeConfig.js";
import { buildAbortedResponsesTerminalBytes } from "../../utils/responsesStreamHelpers.js";
import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js";
import { buildRequestDetail, extractRequestConfig } from "./requestDetail.js";
import { saveRequestDetail } from "@/lib/usageDb.js";
import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.js";
@@ -22,7 +22,7 @@ const CODEX_SOURCE_TO_TARGET = {
/**
* Determine which SSE transform stream to use based on provider/format.
*/
function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey }) {
function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, endpoint, comboName }) {
const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli");
// Responses-API providers (e.g. codex) emit Responses SSE → translate into client format
const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES;
@@ -30,56 +30,23 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent,
if (needsCodexTranslation) {
const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI;
return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames);
return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, endpoint, comboName);
}
if (needsTranslation(targetFormat, sourceFormat)) {
return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames);
return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, endpoint, comboName);
}
return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey);
return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey, endpoint, comboName);
}
/**
* Handle streaming response — pipe provider SSE through transform stream to client.
*/
export async function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, customToolNames, streamController, onStreamComplete, streamDetailId, pxpipe, reqTag, log }) {
if (onRequestSuccess) {
Promise.resolve()
.then(onRequestSuccess)
.catch(err => {
console.error("[ChatCore] onRequestSuccess failed:", err?.message || err);
});
}
export function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, comboName, fallbackHistory, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete }) {
if (onRequestSuccess) onRequestSuccess();
// When upstream returns HTML/text instead of SSE (e.g. Cloudflare 5xx error
// page), piping it through the SSE transform stream causes Next.js
// "failed to pipe response" and crashes the chat router. Read the body,
// pull a short human-readable message from the <title>, sanitize it, and
// return a clean JSON error instead. The message is stripped of HTML tags
// and clamped so untrusted upstream text never reaches the client verbatim
// (the UI may render error.message as HTML).
const upstreamContentType = (providerResponse.headers.get('content-type') || '').toLowerCase();
if (upstreamContentType && !upstreamContentType.includes('text/event-stream') && !upstreamContentType.includes('application/json')) {
const bodyText = await providerResponse.text().catch(() => '');
const titleMatch = bodyText.match(/<title>([^<]+)<\/title>/i);
const sanitizedTitle = (titleMatch?.[1] || '').replace(/<[^>]*>/g, '').replace(/[\r\n]+/g, ' ').trim().slice(0, 160);
const shortMsg = sanitizedTitle
|| (bodyText.length < 200 ? bodyText.replace(/<[^>]*>/g, '').trim().slice(0, 160) : `Upstream returned non-SSE response (${upstreamContentType})`);
const status = providerResponse.status || 502;
if (log?.errorLine) log.errorLine(reqTag, "✗", `BLOCKED ${status} · ${provider}/${model} · non-SSE (${upstreamContentType})\n ${shortMsg}`);
else console.warn(`[STREAM] ${provider} | ${model} | blocked pipe: ${shortMsg} [${status}]`);
streamController?.handleError?.(new Error(`upstream non-SSE: ${status}`));
return {
success: false,
response: new Response(JSON.stringify({ error: { message: `[${status}]: ${shortMsg}` } }), {
status,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
}),
};
}
const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey });
const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, endpoint: clientRawRequest?.endpoint, comboName });
// Responses passthrough: synthesize response.failed + [DONE] if the stream aborts/stalls before a terminal event
const isResponsesPassthrough = sourceFormat === FORMATS.OPENAI_RESPONSES && targetFormat === FORMATS.OPENAI_RESPONSES;
@@ -87,15 +54,15 @@ export async function handleStreamingResponse({ providerResponse, provider, mode
const stallTimeoutMs = PROVIDERS[provider]?.stallTimeoutMs || STREAM_STALL_TIMEOUT_MS;
const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController, onAbortTerminal, stallTimeoutMs);
const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
saveRequestDetail(buildRequestDetail({
provider, model, connectionId,
provider, model, connectionId, apiKey, comboName, fallbackHistory,
latency: { ttft: 0, total: Date.now() - requestStartTime },
tokens: { prompt_tokens: 0, completion_tokens: 0 },
request: extractRequestConfig(body, stream),
providerRequest: finalBody || translatedBody || null,
providerResponse: "[Streaming - raw response not captured]",
response: { content: "[Streaming in progress...]", thinking: null, type: "streaming" },
pxpipe,
status: "success"
}, { id: streamDetailId })).catch(err => {
console.error("[RequestDetail] Failed to save streaming request:", err.message);
@@ -109,8 +76,10 @@ export async function handleStreamingResponse({ providerResponse, provider, mode
/**
* Build onStreamComplete callback for streaming usage tracking.
* @param {object} options.midStreamError - Shared state object from executor (filled during streaming)
* @param {function} options.onMidStreamError - Callback to invoke when mid-stream error is detected
*/
export function buildOnStreamComplete({ provider, model, connectionId, apiKey, requestStartTime, body, stream, finalBody, translatedBody, clientRawRequest, pxpipe, reqTag, log }) {
export function buildOnStreamComplete({ provider, model, connectionId, apiKey, comboName, fallbackHistory, requestStartTime, body, stream, finalBody, translatedBody, clientRawRequest, midStreamError, onMidStreamError }) {
const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const onStreamComplete = (contentObj, usage, ttftAt) => {
@@ -121,23 +90,27 @@ export function buildOnStreamComplete({ provider, model, connectionId, apiKey, r
const safeContent = contentObj?.content || "[Empty streaming response]";
const safeThinking = contentObj?.thinking || null;
// Check if a mid-stream error was detected during streaming (e.g. Qoder quota exceeded)
// The stream already returned HTTP 200, so the pre-stream error path in chatCore didn't fire.
// Apply cooldown here so the account gets locked for subsequent requests.
if (midStreamError?.error && typeof onMidStreamError === "function") {
onMidStreamError(midStreamError.error).catch(err => {
console.error("[MidStreamError] Failed to apply cooldown:", err.message);
});
}
saveRequestDetail(buildRequestDetail({
provider, model, connectionId,
provider, model, connectionId, apiKey, comboName, fallbackHistory,
latency,
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
request: extractRequestConfig(body, stream),
providerRequest: finalBody || translatedBody || null,
providerResponse: safeContent,
response: { content: safeContent, thinking: safeThinking, type: "streaming" },
pxpipe,
status: "success"
status: midStreamError?.error ? "error" : "success"
}, { id: streamDetailId })).catch(err => {
console.error("[RequestDetail] Failed to update streaming content:", err.message);
});
// Persist stream usage to DB (no console line; the "📊 done" line below is authoritative)
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, label: "STREAM USAGE", silent: true });
if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency }));
};
return { onStreamComplete, streamDetailId };

View File

@@ -2,7 +2,6 @@
import createOpenAIEmbeddingAdapter from "./openai.js";
import gemini from "./gemini.js";
import openaiCompatNode from "./openaiCompatNode.js";
import selfhostedEmbedding from "./selfhostedEmbedding.js";
const OPENAI_COMPAT_PROVIDERS = [
"openai", "openrouter", "mistral", "voyage-ai", "fireworks",
@@ -14,12 +13,6 @@ const ADAPTERS = {
...Object.fromEntries(OPENAI_COMPAT_PROVIDERS.map((id) => [id, createOpenAIEmbeddingAdapter(id)])),
gemini,
google_ai_studio: gemini,
// Self-hosted reads creds.providerSpecificData.baseUrl (one provider, many
// servers) — but via its OWN adapter, not openaiCompatNode: that one falls back
// to api.openai.com when no baseUrl is set, which under a provider called
// "Self-hosted Embedding" means silently shipping the input and API key to
// OpenAI. selfhostedEmbedding refuses instead.
"selfhosted-embedding": selfhostedEmbedding,
};
export function getEmbeddingAdapter(provider) {

View File

@@ -1,46 +0,0 @@
// Self-hosted embeddings — like openaiCompatNode, but the baseUrl is REQUIRED.
//
// openaiCompatNode falls back to https://api.openai.com/v1 when a connection
// carries no providerSpecificData.baseUrl. For a custom NODE that default is
// defensible: the node was created by pointing at some OpenAI-compatible URL, and
// OpenAI is the archetype. For a provider whose entire purpose is "my own
// server", it is actively harmful — a connection saved without a baseUrl sends
// the INPUT TEXT and the API KEY to OpenAI, silently, under a provider named
// "Self-hosted Embedding".
//
// Observed exactly that with a placeholder connection (2026-08-04):
//
// [selfhosted-embedding/embedding] [401]: Incorrect API key provided: abc.
// You can find your API key at https://platform.openai.com/account/api-keys.
//
// The key "abc" was typed as a throwaway for a LOCAL server and left the network.
// A self-hosted provider must never have a cloud fallback, so this one refuses
// instead: no baseUrl means a configuration error, reported as such.
import createOpenAIEmbeddingAdapter from "./openai.js";
const baseAdapter = createOpenAIEmbeddingAdapter("openai");
export class MissingBaseUrlError extends Error {
constructor() {
super(
"Self-hosted Embedding needs an endpoint: set this connection's baseUrl to " +
"the OpenAI base URL of your server, e.g. http://host:8080/v1 (note the /v1 — " +
"\"/embeddings\" is appended to it). Refusing to fall back to api.openai.com, " +
"which would send your input and API key to OpenAI."
);
this.name = "MissingBaseUrlError";
this.isConfigError = true;
}
}
export default {
...baseAdapter,
buildUrl: (_model, creds) => {
const rawBaseUrl = creds?.providerSpecificData?.baseUrl;
if (!rawBaseUrl || !String(rawBaseUrl).trim()) throw new MissingBaseUrlError();
// Accept either the OpenAI base or a full embeddings URL, so a value pasted
// from a curl example works as well as one typed from the help text.
const baseUrl = String(rawBaseUrl).trim().replace(/\/$/, "").replace(/\/embeddings$/, "");
return `${baseUrl}/embeddings`;
},
};

View File

@@ -1,5 +1,5 @@
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
import { HTTP_STATUS, FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { getExecutor } from "../executors/index.js";
import { refreshWithRetry } from "../services/tokenRefresh.js";
import { getEmbeddingAdapter } from "./embeddingProviders/index.js";
@@ -38,24 +38,13 @@ export async function handleEmbeddingsCore({
}
const ctx = { input };
// buildUrl/buildHeaders/buildBody were called bare. An adapter that rejects a
// misconfigured connection — selfhosted-embedding throws when no baseUrl is set
// rather than silently falling back to api.openai.com — would have escaped this
// function uncaught, surfacing as a 500 or a request that never settles. A
// configuration mistake is a 400 with the reason in it.
let url, headers, requestBody;
try {
url = adapter.buildUrl(model, credentials, ctx);
headers = adapter.buildHeaders(credentials, ctx);
requestBody = adapter.buildBody(model, {
input,
encoding_format: body.encoding_format || "float",
dimensions: body.dimensions,
});
} catch (error) {
log?.debug?.("EMBEDDINGS", `Request build failed: ${error.message}`);
return createErrorResult(HTTP_STATUS.BAD_REQUEST, `[${provider}/${model}] ${error.message}`);
}
const url = adapter.buildUrl(model, credentials, ctx);
const headers = adapter.buildHeaders(credentials, ctx);
const requestBody = adapter.buildBody(model, {
input,
encoding_format: body.encoding_format || "float",
dimensions: body.dimensions,
});
log?.debug?.("EMBEDDINGS", `${provider.toUpperCase()} | ${model} | input_type=${Array.isArray(input) ? `array[${input.length}]` : "string"}`);
@@ -65,9 +54,6 @@ export async function handleEmbeddingsCore({
method: "POST",
headers,
body: JSON.stringify(requestBody),
...(typeof AbortSignal?.timeout === "function"
? { signal: AbortSignal.timeout(FETCH_CONNECT_TIMEOUT_MS) }
: {}),
});
} catch (error) {
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
@@ -130,7 +116,6 @@ export async function handleEmbeddingsCore({
return {
success: true,
usage: normalized.usage || null,
response: new Response(JSON.stringify(normalized), {
headers: {
"Content-Type": "application/json",

View File

@@ -49,10 +49,7 @@ function truncate(text, max) {
}
function parseJinaTitle(text) {
const source = String(text || "");
const metadataTitle = source.match(/^\s*Title:\s*(.+)$/mi);
if (metadataTitle) return metadataTitle[1].trim();
const m = source.match(/^\s*#\s+(.+)$/m);
const m = String(text || "").match(/^\s*#\s+(.+)$/m);
return m ? m[1].trim() : null;
}
@@ -154,14 +151,11 @@ async function runFirecrawl({ url, fmt, timeoutMs, apiKey, maxCharacters, costPe
}
async function runJina({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }) {
const target = `https://r.jina.ai/${encodeURIComponent(url)}`;
const upstreamStart = Date.now();
const r = await tryFetch("https://r.jina.ai/", {
method: "POST",
headers: {
"content-type": "application/json",
...(apiKey ? { authorization: `Bearer ${apiKey}` } : {})
},
body: JSON.stringify({ url })
const r = await tryFetch(target, {
method: "GET",
headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {}
}, timeoutMs);
if (!r.ok) {

View File

@@ -1,6 +1,7 @@
/**
* Search Provider Request Builders
*
* Ported from OmniRoute open-sse/handlers/search.ts (lines 223-610).
* Builds HTTP request `{ url, init }` for 10 search providers.
*
* @typedef {Object} SearchProviderConfig
@@ -29,8 +30,6 @@
* @property {Record<string,unknown>} [providerSpecificData]
*/
import { assertPublicUrl } from "../../../src/shared/utils/ssrfGuard.js";
// ── Helpers ─────────────────────────────────────────────────────────────
/**
@@ -65,31 +64,12 @@ export function getProviderSetting(params, key) {
/**
* Resolve base URL with optional override from providerOptions.baseUrl.
*
* The override is client-controlled and therefore SSRF-hardened: only public
* http(s) URLs are accepted (internal/private/loopback/metadata addresses are
* rejected via assertPublicUrl). The provider's own configured baseUrl is
* trusted as-is (admin-controlled).
*
* @param {SearchProviderConfig} config
* @param {SearchRequestParams} params
* @returns {string}
*/
export function resolveBaseUrl(config, params) {
const override = getProviderSetting(params, "baseUrl");
if (override) {
// SSRF guard: client-supplied base URLs must be public http(s) only.
let parsed;
try {
parsed = new URL(override);
} catch {
throw new Error(`Invalid baseUrl: ${override}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`Invalid baseUrl protocol: ${parsed.protocol}`);
}
assertPublicUrl(override);
}
return (override || config.baseUrl).replace(/\/+$/, "");
}

View File

@@ -273,53 +273,6 @@ const CHAT_SEARCH_CONFIG = {
const tokens = data?.usage?.total_tokens || 0;
return { text, citations, tokens };
}
},
"perplexity-agent": {
endpoint: () => searchEndpoint("perplexity-agent"),
buildBody: (query, model) => ({
model,
input: query,
tools: [{ type: "web_search" }]
}),
buildHeaders: (token) => ({
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
}),
extractAnswer: (data) => {
const output = Array.isArray(data?.output) ? data.output : [];
let text = "";
const citations = [];
for (const item of output) {
const parts = Array.isArray(item?.content) ? item.content : [];
for (const p of parts) {
if (typeof p?.text === "string") text += p.text;
const anns = Array.isArray(p?.annotations) ? p.annotations : [];
for (const a of anns) {
const c = normalizeCitation(a?.url ? a : a?.url_citation);
if (c) citations.push(c);
}
}
const results = Array.isArray(item?.results) ? item.results : [];
for (const r of results) {
const url = r?.url || r?.link;
if (!url) continue;
citations.push({
url,
title: r?.title || "",
snippet: r?.snippet || ""
});
}
}
if (!citations.length && Array.isArray(data?.citations)) {
for (const c of data.citations) {
const n = normalizeCitation(c);
if (n) citations.push(n);
}
}
const tokens = data?.usage?.total_tokens || 0;
return { text, citations, tokens };
}
}
};

View File

@@ -1,6 +1,7 @@
/**
* Search Response Normalizers
*
* Ported from OmniRoute open-sse/handlers/search.ts.
* Each normalizer maps a provider-specific response into the unified SearchResult shape.
*/

View File

@@ -170,17 +170,9 @@ export async function handleSttCore({ provider, model, formData, credentials, st
const file = formData.get("file");
if (!file) return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: file");
let cfg = sttConfig;
const cfg = sttConfig;
if (!cfg) return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Provider '${provider}' does not support STT`);
// Per-connection endpoint override. Registry entries carry a fixed baseUrl,
// which is right for a named cloud service but useless for a self-hosted one
// whose address only the operator knows. Opt-in: absent unless the connection
// sets it, so cloud providers are untouched. Mirrors the custom embedding
// providers, which already resolve baseUrl the same way.
const overrideUrl = credentials?.providerSpecificData?.baseUrl;
if (overrideUrl) cfg = { ...cfg, baseUrl: String(overrideUrl).replace(/\/+$/, "") };
const token = cfg.authType === "none" ? null : (credentials?.apiKey || credentials?.accessToken);
if (cfg.authType !== "none" && !token) {
return createErrorResult(HTTP_STATUS.UNAUTHORIZED, `No credentials for STT provider: ${provider}`);

View File

@@ -48,16 +48,16 @@ function createTtsResponse(base64Audio, format, responseFormat) {
*
* @returns {Promise<{success, response, status?, error?}>}
*/
export async function handleTtsCore({ provider, model, input, credentials, responseFormat = "mp3", language, style }) {
export async function handleTtsCore({ provider, model, input, credentials, responseFormat = "mp3", language }) {
if (!input?.trim()) {
return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: input");
}
try {
// Special-case adapters (google-tts, edge-tts, local-device, elevenlabs, openai, openrouter, gemini, xiaomi-mimo)
// Special-case adapters (google-tts, edge-tts, local-device, elevenlabs, openai, openrouter, gemini)
const adapter = getTtsAdapter(provider);
if (adapter) {
const result = await adapter.synthesize(input.trim(), model, credentials, responseFormat, { language, style });
const result = await adapter.synthesize(input.trim(), model, credentials, responseFormat, { language });
// Adapter may return a full {success, response} (legacy) or {base64, format}
if (result.success !== undefined) return result;
return createTtsResponse(result.base64, result.format, responseFormat);

View File

@@ -1,19 +1,11 @@
// Gemini TTS — generateContent with AUDIO modality returns PCM L16, wrap as WAV
import { Buffer } from "node:buffer";
import { PROVIDER_MEDIA, PROVIDER_MODELS } from "../../providers/index.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const TTS_CFG = PROVIDER_MEDIA["gemini"]?.ttsConfig || {};
const TTS_BASE = TTS_CFG.baseUrl;
const FALLBACK_MODEL = "gemini-3.1-flash-tts-preview";
const KNOWN_MODELS = [
...(TTS_CFG.models || []),
...(PROVIDER_MODELS["gemini-tts-models"] || []),
...(PROVIDER_MODELS.gemini || []).filter((m) => (m.kind || m.type) === "tts"),
]
.map((m) => m?.id)
.filter(Boolean)
.filter((id, index, list) => list.indexOf(id) === index);
const DEFAULT_MODEL = KNOWN_MODELS[0] || FALLBACK_MODEL;
const KNOWN_MODELS = (TTS_CFG.models || []).map((m) => m.id);
const DEFAULT_MODEL = KNOWN_MODELS[0];
const DEFAULT_VOICE = "Kore";
// Parse "model/voice" — if input doesn't match a known TTS model, treat it as voice with default model

View File

@@ -51,25 +51,6 @@ async function huggingface({ baseUrl, apiKey, text, modelId }) {
return responseToBase64(res, "wav");
}
// Fish Audio: model travels in an HTTP header, the voice is a reference_id, returns binary
async function fishAudio({ baseUrl, apiKey, text, modelId, voiceId }) {
const res = await fetch(baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
"model": modelId || "s2.1-pro-free",
},
body: JSON.stringify({
text,
format: "mp3",
...(voiceId ? { reference_id: voiceId } : {}),
}),
});
if (!res.ok) await throwUpstreamError(res);
return responseToBase64(res, "mp3");
}
// Inworld: Basic auth, JSON { audioContent }
async function inworld({ baseUrl, apiKey, text, modelId, voiceId }) {
const res = await fetch(baseUrl, {
@@ -185,5 +166,4 @@ export const FORMAT_HANDLERS = {
tortoise,
openai: openaiCompat,
"minimax-tts": minimaxTts,
"fish-audio": fishAudio,
};

View File

@@ -6,8 +6,6 @@ import elevenlabs, { fetchElevenLabsVoices } from "./elevenlabs.js";
import openai from "./openai.js";
import openrouter from "./openrouter.js";
import gemini, { fetchGeminiVoices } from "./gemini.js";
import xiaomiMimo from "./xiaomi-mimo.js";
import selfhostedTts from "./selfhostedTts.js";
import { FORMAT_HANDLERS } from "./genericFormats.js";
import { parseModelVoice } from "./_base.js";
@@ -20,8 +18,6 @@ const SPECIAL_ADAPTERS = {
openai,
openrouter,
gemini,
"xiaomi-mimo": xiaomiMimo,
"selfhosted-tts": selfhostedTts,
};
export function getTtsAdapter(provider) {

View File

@@ -1,69 +0,0 @@
// Self-hosted OpenAI-compatible TTS — POST {baseUrl}/v1/audio/speech.
//
// A SPECIAL_ADAPTER rather than a genericFormats handler on purpose: the generic
// dispatcher resolves baseUrl from the static registry entry
// (`synthesizeViaConfig` reads `cfg.baseUrl`) and never looks at the connection,
// which is exactly the limitation this provider exists to lift.
import { Buffer } from "node:buffer";
const DEFAULT_BASE_URL = "http://localhost:8880";
const DEFAULT_MODEL = "kokoro";
const DEFAULT_VOICE = "af_heart";
export default {
async synthesize(text, model, credentials, responseFormat = "mp3") {
// Accept either providerSpecificData.baseUrl (how the custom embedding and
// STT providers carry it) or a bare credentials.baseUrl (how the OpenAI TTS
// adapter does), so a connection configured either way works.
const raw = credentials?.providerSpecificData?.baseUrl || credentials?.baseUrl || DEFAULT_BASE_URL;
// Tolerate a baseUrl given as the full endpoint or with a trailing /v1 —
// both are natural things to paste, and silently double-appending the path
// would 404 with nothing pointing at the cause.
const base = String(raw)
.replace(/\/+$/, "")
.replace(/\/v1\/audio\/speech$/, "")
.replace(/\/v1$/, "");
// The provider prefix is already stripped by getModelInfo, so `model` here is
// "kokoro" or "kokoro/af_heart" — NOT "selfhosted-tts/...".
//
// A bare value is the MODEL, not the voice. The OpenAI adapter reads a bare
// value as a voice, which is right for a service whose model is fixed
// ("tts-1") and whose voice varies — but wrong here, where the model is the
// variable part. Treating it as a voice sent voice="kokoro" upstream and
// Kokoro answered 400, so `selfhosted-tts/kokoro` — the obvious way to
// address this provider — was the one form that did not work (verified
// against a live Kokoro through 9router, 2026-08-03).
let ttsModel = DEFAULT_MODEL;
let voice = DEFAULT_VOICE;
if (model) {
const parts = String(model).split("/").filter(Boolean);
if (parts.length >= 2) {
ttsModel = parts[0];
voice = parts.slice(1).join("/");
} else if (parts.length === 1) {
ttsModel = parts[0];
}
}
const res = await fetch(`${base}/v1/audio/speech`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(credentials?.apiKey ? { Authorization: `Bearer ${credentials.apiKey}` } : {}),
},
body: JSON.stringify({
model: ttsModel,
voice,
input: text,
response_format: responseFormat,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error?.message || `Self-hosted TTS failed: ${res.status}`);
}
const buf = await res.arrayBuffer();
return { base64: Buffer.from(buf).toString("base64"), format: responseFormat };
},
};

View File

@@ -1,65 +0,0 @@
// Xiaomi MiMo TTS — via OpenAI-compatible chat completions (non-streaming).
// Docs: https://mimo.mi.com/docs/zh-CN/quick-start/usage-guide/audio/speech-synthesis-v2.5
// Message contract: target text in `role: assistant` content, style/voice
// instructions in `role: user` content. Voice is selected via the top-level
// `audio.voice` field (NOT embedded in the model name).
import { parseModelVoice } from "./_base.js";
const DEFAULT_MODEL = "mimo-v2.5-tts";
const DEFAULT_VOICE = "mimo_default";
export default {
synthesize(text, model, credentials, responseFormat, { style, language } = {}) {
if (!credentials?.apiKey) throw new Error("xiaomi-mimo API key required");
return synthesizeMiMo(text, model, credentials.apiKey, style, language);
},
};
export async function synthesizeMiMo(text, model, apiKey, style, language) {
const { modelId, voiceId } = parseModelVoice(model, DEFAULT_MODEL, DEFAULT_VOICE, [DEFAULT_MODEL]);
// Language and style are soft instructions → prepend as a role:user message.
// MiMo auto-detects the spoken language of the text; the hint only nudges it
// (e.g. "Speak in English.") and is independent of the chosen voice.
const instructions = [];
if (language) instructions.push(`Speak in ${language}.`);
if (style) instructions.push(style);
const messages = [{ role: "assistant", content: text }];
if (instructions.length) messages.unshift({ role: "user", content: instructions.join(" ") });
const res = await fetch("https://api.xiaomimimo.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: modelId,
stream: false,
messages,
audio: {
format: "wav",
voice: voiceId || DEFAULT_VOICE,
},
}),
});
const rawText = await res.text();
let data = {};
if (rawText) {
try { data = JSON.parse(rawText); } catch { data = {}; }
}
if (!res.ok) {
throw new Error(data?.error?.message || rawText || `MiMo TTS error (${res.status})`);
}
const audio = data?.choices?.[0]?.message?.audio?.data;
if (!audio) throw new Error(data?.error?.message || "MiMo TTS returned no audio");
return {
base64: audio,
format: data?.choices?.[0]?.message?.audio?.format || "wav",
};
}

View File

@@ -1,166 +0,0 @@
import { createErrorResult } from "../utils/error.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { refreshTokenByProvider } from "../services/tokenRefresh.js";
import { PROVIDER_MEDIA } from "../providers/index.js";
// Upstream fetch deadline for video job submission/polling (the job itself is
// async upstream — this only bounds the HTTP round-trip, not video rendering).
const VIDEO_FETCH_TIMEOUT_MS = Number(process.env.VIDEO_FETCH_TIMEOUT_MS || 120000);
// POST /videos/* creates a billable upstream job. A network error after the
// request left the socket may still have created the job, so creation is NEVER
// auto-retried (the only re-send is the auth retry after a 401/403 refresh,
// which upstream rejects before job creation).
export const VIDEO_ACTIONS = new Set(["generations", "edits", "extensions"]);
export function getVideoConfig(provider) {
return PROVIDER_MEDIA[provider]?.videoConfig || null;
}
/** Strip bearer tokens / obvious secrets from text destined for clients or logs. */
export function sanitizeSecrets(text, credentials = null) {
if (!text) return text;
let out = String(text).replace(/Bearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]");
for (const key of ["accessToken", "refreshToken", "apiKey"]) {
const secret = credentials?.[key];
if (typeof secret === "string" && secret.length >= 8) {
out = out.split(secret).join("[redacted]");
}
}
return out;
}
function buildUpstreamUrl(config, action, requestId) {
const base = config.baseUrl.replace(/\/$/, "");
return requestId ? `${base}/${encodeURIComponent(requestId)}` : `${base}/${action}`;
}
function buildHeaders({ token, contentType, idempotencyKey }) {
const headers = { Accept: "application/json" };
if (token) headers.Authorization = `Bearer ${token}`;
if (contentType) headers["Content-Type"] = contentType;
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
return headers;
}
function combineSignals(signal, timeoutMs) {
const timeoutSignal = typeof AbortSignal?.timeout === "function" ? AbortSignal.timeout(timeoutMs) : null;
if (signal && timeoutSignal && typeof AbortSignal.any === "function") {
return AbortSignal.any([signal, timeoutSignal]);
}
return signal || timeoutSignal || undefined;
}
/**
* Transparent proxy for async video jobs (xAI Grok Imagine shape).
*
* - Forwards the raw body byte-for-byte (JSON or multipart) — no reshaping.
* - Passes upstream JSON (request_id, status, video.url, error) back verbatim.
* - 401/403 with a refresh token: refresh ONCE, retry ONCE. No other retry.
* - Upstream error text is sanitized before it reaches the client.
*
* @param {object} options
* @param {string} options.provider - Provider id (must have registry videoConfig)
* @param {"generations"|"edits"|"extensions"|null} options.action - Creation action (POST)
* @param {string|null} [options.requestId] - Poll target (GET /videos/{id})
* @param {Buffer|string|null} [options.rawBody] - Exact body to forward
* @param {string|null} [options.contentType] - Original Content-Type header
* @param {string|null} [options.idempotencyKey] - Forwarded Idempotency-Key
* @param {object} options.credentials - { accessToken?, apiKey?, refreshToken?, authType? }
* @param {AbortSignal} [options.signal] - Client cancellation signal
* @param {number} [options.timeoutMs]
* @param {object} [options.log]
* @param {function} [options.onCredentialsRefreshed]
* @returns {Promise<{ success: boolean, response: Response, status?: number, error?: string }>}
*/
export async function handleVideoProxyCore({
provider,
action = null,
requestId = null,
rawBody = null,
contentType = null,
idempotencyKey = null,
credentials,
signal,
timeoutMs = VIDEO_FETCH_TIMEOUT_MS,
log,
onCredentialsRefreshed,
}) {
const config = getVideoConfig(provider);
if (!config) {
return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Provider '${provider}' does not support video generation`);
}
if (!requestId && !VIDEO_ACTIONS.has(action)) {
return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Unknown video action: ${action}`);
}
const method = requestId ? "GET" : "POST";
const url = buildUpstreamUrl(config, action, requestId);
const fetchSignal = combineSignals(signal, timeoutMs);
const doFetch = (token) =>
fetch(url, {
method,
headers: buildHeaders({ token, contentType: method === "POST" ? contentType : null, idempotencyKey: method === "POST" ? idempotencyKey : null }),
body: method === "POST" ? rawBody : undefined,
signal: fetchSignal,
});
let upstream;
try {
upstream = await doFetch(credentials?.accessToken || credentials?.apiKey);
} catch (error) {
if (error?.name === "AbortError" || error?.name === "TimeoutError") {
return createErrorResult(HTTP_STATUS.REQUEST_TIMEOUT, `[${provider}] video ${method} aborted: ${error.message}`);
}
// Never re-send a creation POST on network error — the job may already exist upstream.
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, sanitizeSecrets(`[${provider}] video upstream fetch failed: ${error.message}`, credentials));
}
// 401/403 → refresh once → retry once (OAuth accounts only; API keys can't refresh)
if (
(upstream.status === HTTP_STATUS.UNAUTHORIZED || upstream.status === HTTP_STATUS.FORBIDDEN) &&
credentials?.refreshToken
) {
let refreshed = null;
try {
refreshed = await refreshTokenByProvider(provider, credentials, log);
} catch (error) {
log?.warn?.("TOKEN", `${provider} | video refresh error: ${sanitizeSecrets(error.message, credentials)}`);
}
if (refreshed?.accessToken) {
log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed for video ${method}`);
Object.assign(credentials, refreshed);
if (onCredentialsRefreshed) await onCredentialsRefreshed(refreshed);
try {
await upstream.body?.cancel?.();
} catch { /* noop */ }
try {
upstream = await doFetch(credentials.accessToken || credentials.apiKey);
} catch (error) {
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, sanitizeSecrets(`[${provider}] video retry after refresh failed: ${error.message}`, credentials));
}
} else {
log?.warn?.("TOKEN", `${provider.toUpperCase()} | video refresh failed — account needs re-auth`);
}
}
const bodyText = await upstream.text().catch(() => "");
if (!upstream.ok) {
const message = sanitizeSecrets(bodyText || `HTTP ${upstream.status}`, credentials);
return createErrorResult(upstream.status, `[${provider}] ${message.slice(0, 2000)}`);
}
// Success: pass the upstream JSON through untouched (request_id / status / video.url).
return {
success: true,
response: new Response(bodyText, {
status: upstream.status,
headers: {
"Content-Type": upstream.headers.get("content-type") || "application/json",
"Access-Control-Allow-Origin": "*",
},
}),
};
}

View File

@@ -47,6 +47,7 @@ export {
refreshAccessToken,
refreshClaudeOAuthToken,
refreshGoogleToken,
refreshQwenToken,
refreshCodexToken,
refreshIflowToken,
refreshGitHubToken,

View File

@@ -71,25 +71,12 @@ export function capabilitiesFromServiceKind(kind) {
* otherwise mis-match. Only declare deltas vs DEFAULT.
*/
export const MODEL_CAPABILITIES = {
// Claude Opus 5, 4.6/4.7/4.8, and Kiro Sonnet 5 have 1M context + adaptive thinking (override generic claude pattern)
"claude-opus-5": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-5-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-5-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-5-thinking-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
// Claude 4.6/4.7 have 1M context + adaptive thinking (override generic claude pattern)
"claude-opus-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-4.7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-4-7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-4.8": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-4-6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-4-8": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-4.8-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-4-8-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-sonnet-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-sonnet-4-6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-sonnet-5": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-sonnet-5-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-sonnet-5-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-sonnet-5-thinking-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-sonnet-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 64000 },
"claude-sonnet-4-6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 64000 },
// Gemini image-gen / OpenAI image / xai image variants
"gpt-image-1": { imageOutput: true, tools: false },
@@ -100,58 +87,12 @@ export const MODEL_CAPABILITIES = {
// Qwen plain coder/text (no vision) — registry "vision-model" / "coder-model" aliases
"vision-model": { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 },
"coder-model": { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 },
// Kimi flagship + coding (platform + Kimi Code ids) — vision/video native
"kimi-k3": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 131072 },
"k3": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 131072 },
"kimi-for-coding": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 },
"kimi-for-coding-highspeed": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 },
"kimi-k2.7-code": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 },
"kimi-k2.7-code-highspeed": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 },
};
const KIRO_GPT_5_6_CAPABILITIES = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 };
// Codex OAuth (ChatGPT backend) — per-model context window reported by upstream
// (lower than OpenAI API's 1.05M). Sol differs from Terra/Luna. #2720
const CODEX_GPT_56_SOL_CAPS = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 372000, maxOutput: 128000 };
const CODEX_GPT_56_DEFAULT_CAPS = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 };
/**
* Provider-specific capability overrides. Keyed by provider alias/id.
*/
export const PROVIDER_CAPABILITIES = {
// NVIDIA NIM is OpenAI-compatible → rejects MiniMax/GLM native `thinking` field.
// Force openai reasoning_effort format for its reasoning models. #issue
"nvidia": {
"minimaxai/minimax-m2.7": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 131072 },
"minimaxai/minimax-m3": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 512000, maxOutput: 131072 },
"z-ai/glm-5.2": { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 128000 },
"deepseek-ai/deepseek-v4-pro": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 65536 },
"deepseek-ai/deepseek-v4-flash": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 65536 },
},
"codex": {
"gpt-5.6-sol": CODEX_GPT_56_SOL_CAPS,
"gpt-5.6-sol-review": CODEX_GPT_56_SOL_CAPS,
"gpt-5.6-terra": CODEX_GPT_56_DEFAULT_CAPS,
"gpt-5.6-terra-review": CODEX_GPT_56_DEFAULT_CAPS,
"gpt-5.6-luna": CODEX_GPT_56_DEFAULT_CAPS,
"gpt-5.6-luna-review": CODEX_GPT_56_DEFAULT_CAPS,
},
"kiro": {
"gpt-5.6-sol": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-terra": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-luna": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-sol-thinking": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-terra-thinking": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-luna-thinking": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-sol-agentic": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-terra-agentic": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-luna-agentic": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-sol-thinking-agentic": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-terra-thinking-agentic": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-luna-thinking-agentic": KIRO_GPT_5_6_CAPABILITIES,
},
// CodeBuddy.cn — authoritative per-model metadata from the gateway's model
// config (contextWindow=maxInputTokens, maxOutput=maxOutputTokens, vision=
// supportsImages). Every model reasons via OpenAI-style reasoning_effort
@@ -174,11 +115,6 @@ export const PROVIDER_CAPABILITIES = {
"deepseek-v4-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 50000 },
"deepseek-v3-2-volc": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 96000, maxOutput: 32000 },
},
// Poolside Laguna — OpenAI-compatible, all reasoning-capable (32K max output).
"poolside": {
"laguna-s-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 },
"laguna-xs-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 },
},
};
/**
@@ -189,7 +125,6 @@ export const PROVIDER_CAPABILITIES = {
*/
export const PATTERN_CAPABILITIES = [
// ── Claude (4.6+ = adaptive thinking; older/haiku = budget) ──────
{ pattern: "*claude*opus-5*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 } },
{ pattern: "*claude*opus-4.6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
{ pattern: "*claude*opus-4.7*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
{ pattern: "*claude*opus-4.8*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
@@ -205,7 +140,6 @@ export const PATTERN_CAPABILITIES = [
// ── Gemini (all 2.0+ multimodal + google_search grounding, 1M ctx) ─
{ pattern: "*gemini*image*", caps: { vision: true, imageOutput: true, contextWindow: 1048576 } },
{ pattern: "*gemini-3.7*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65536 } },
{ pattern: "*gemini-3*pro*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65535 } },
{ pattern: "*gemini-3*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65536 } },
{ pattern: "*gemini-2.5*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-budget", thinkingRange: { min: 0, max: 24576 }, contextWindow: 1048576, maxOutput: 65536 } },
@@ -234,29 +168,21 @@ export const PATTERN_CAPABILITIES = [
// ── Grok (vision + Live Search) ──────────────────────────────────
{ pattern: "*grok*image*", caps: { imageOutput: true } },
{ pattern: "*grok-code*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 256000 } },
// Grok 4.5 (Grok CLI / Grok Build): 500k context per cli-chat-proxy /v1/models
{ pattern: "*grok-4.5*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 500000, maxOutput: 64000 } },
{ pattern: "*grok-4*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 256000 } },
{ pattern: "*grok-3*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 131072 } },
{ pattern: "*grok*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 256000 } },
// ── Qwen (3.5+ = native vision/video; coder & max = text-only; QwQ = thinking-only) ─
// ── Qwen (enable_thinking + thinking_budget; QwQ = thinking-only) ─
{ pattern: "*qwen*vl*", caps: { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 262144 } },
{ pattern: "*qwen*omni*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 262144, maxOutput: 65536 } },
{ pattern: "*qwen*coder*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 } },
{ pattern: "*qwen*max*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
{ pattern: "*qwen3.5*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
{ pattern: "*qwen3.6*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
{ pattern: "*qwen3.7*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
{ pattern: "*qwen*max*", caps: { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
{ pattern: "*qwen*plus*", caps: { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
{ pattern: "*qwen*235b*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 262144 } },
{ pattern: "*qwen*coder*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 } },
{ pattern: "*qwq*", caps: { reasoning: true, thinkingFormat: "qwen", thinkingCanDisable: false, contextWindow: 131072 } },
{ pattern: "*qwen*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 262144 } },
// ── Kimi (enabled→reasoning_effort; K2.7-code cannot disable) ─────
{ pattern: "*kimi*k3*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 131072 } },
{ pattern: "*kimi*for-coding*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 } },
{ pattern: "*kimi*k2.7*code*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 } },
{ pattern: "*kimi*k2.7*code*", caps: { vision: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 262144 } },
{ pattern: "*kimi*k2*", caps: { vision: true, reasoning: true, thinkingFormat: "kimi", contextWindow: 262144, maxOutput: 262144 } },
{ pattern: "*kimi*", caps: { reasoning: true, thinkingFormat: "kimi", contextWindow: 262144 } },
@@ -280,7 +206,7 @@ export const PATTERN_CAPABILITIES = [
{ pattern: "*minimax*", caps: { reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 131072 } },
// ── Xiaomi MiMo (vision, 1M / 262K ctx) ──────────────────────────
{ pattern: "*mimo*v2.5*", caps: { vision: true, audioInput: true, videoInput: true, contextWindow: 1048576, maxOutput: 131072 } },
{ pattern: "*mimo*v2.5*", caps: { vision: true, contextWindow: 1048576, maxOutput: 131072 } },
{ pattern: "*mimo*omni*", caps: { vision: true, audioInput: true, contextWindow: 262144, maxOutput: 131072 } },
{ pattern: "*mimo*", caps: { vision: true, contextWindow: 262144, maxOutput: 131072 } },
@@ -302,13 +228,6 @@ export const PATTERN_CAPABILITIES = [
{ pattern: "*pplx*", caps: { search: true, contextWindow: 128000 } },
{ pattern: "*perplexity*", caps: { search: true, contextWindow: 128000 } },
// ── Poolside Laguna (resellers: openrouter/nvidia/kilocode/vercel/...) ──
// Free tiers cap S 2.1 well below the paid 1M window → match the free suffix
// (":free" or "-free", depending on reseller) before the plain id.
{ pattern: "*laguna-s-2.1*free*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } },
{ pattern: "*laguna-s-2.1*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 } },
{ pattern: "*laguna*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } },
// ── Others ───────────────────────────────────────────────────────
{ pattern: "*hunyuan*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } },
{ pattern: "hy3*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } },
@@ -328,17 +247,13 @@ export const PATTERN_CAPABILITIES = [
export function getCapabilitiesForModel(provider, model) {
if (!model) return { ...DEFAULT_CAPABILITIES };
// Canonical exact lookup strips vendor prefix: "anthropic/claude-opus-4.7" -> "claude-opus-4.7".
const baseModel = model.includes("/") ? model.split("/").pop() : model;
// 1. Provider-specific override
if (provider) {
const providerCaps = PROVIDER_CAPABILITIES[provider];
if (providerCaps?.[model]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[model] };
if (providerCaps?.[baseModel]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[baseModel] };
if (provider && PROVIDER_CAPABILITIES[provider]?.[model]) {
return { ...DEFAULT_CAPABILITIES, ...PROVIDER_CAPABILITIES[provider][model] };
}
// 2. Canonical exact
// 2. Canonical exact (strip vendor prefix: "anthropic/claude-opus-4.7" -> "claude-opus-4.7")
const baseModel = model.includes("/") ? model.split("/").pop() : model;
if (MODEL_CAPABILITIES[baseModel]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[baseModel] };
if (MODEL_CAPABILITIES[model]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[model] };

View File

@@ -1,14 +1,5 @@
import { deriveModelName } from "./namePatterns.js";
// Normalize version separators in a model id: hyphen between two digits becomes a dot.
// Registry ids use dots for versions ("claude-sonnet-4.5") but clients (CLIs, aliases)
// often send them with dashes ("claude-sonnet-4-5"). Only digit-digit hyphens are
// touched, so word/suffix hyphens stay intact ("-thinking", "-agentic", "qwen3-coder-next").
export function normalizeModelId(modelId) {
if (typeof modelId !== "string") return modelId;
return modelId.replace(/(\d)-(\d)/g, "$1.$2");
}
// Model defaults centralized (was scattered as `m.kind || "llm"`, `quotaFamily || "normal"`, etc.)
export const MODEL_DEFAULTS = {
kind: "llm",
@@ -38,11 +29,3 @@ export function modelStrip(model) {
export function modelTargetFormat(model) {
return model?.targetFormat || MODEL_DEFAULTS.targetFormat;
}
// Per-model declared upstream formats (e.g. ["openai", "claude"]). Guards the
// sourceFormat-matched transport for multi-endpoint providers whose models differ
// in endpoint support (opencode-go: kimi/glm only do /chat/completions, minimax/qwen
// also do /messages, deepseek also does /responses).
export function modelSupportedFormats(model) {
return model?.supportedFormats || null;
}

View File

@@ -28,7 +28,6 @@ export const MODEL_PRICING = {
"claude-sonnet-4.6": { input: 3.00, output: 15.00, cached: 0.30, reasoning: 22.50, cache_creation: 3.00 },
"claude-opus-4-5-thinking": { input: 5.00, output: 25.00, cached: 0.50, reasoning: 37.50, cache_creation: 5.00 },
"claude-opus-4-6-thinking": { input: 5.00, output: 25.00, cached: 0.50, reasoning: 37.50, cache_creation: 5.00 },
"claude-fable-5": { input: 10.00, output: 50.00, cached: 1.00, reasoning: 50.00, cache_creation: 12.50 },
// === OpenAI / GPT ===
"gpt-3.5-turbo": { input: 0.50, output: 1.50, cached: 0.25, reasoning: 2.25, cache_creation: 0.50 },
@@ -37,37 +36,27 @@ export const MODEL_PRICING = {
"gpt-4o": { input: 2.50, output: 10.00, cached: 1.25, reasoning: 15.00, cache_creation: 2.50 },
"gpt-4o-mini": { input: 0.15, output: 0.60, cached: 0.075, reasoning: 0.90, cache_creation: 0.15 },
"gpt-4.1": { input: 2.50, output: 10.00, cached: 1.25, reasoning: 15.00, cache_creation: 2.50 },
"gpt-5": { input: 1.25, output: 10.00, cached: 0.625, reasoning: 10.00, cache_creation: 1.25 },
"gpt-5-mini": { input: 0.25, output: 2.00, cached: 0.125, reasoning: 2.00, cache_creation: 0.25 },
"gpt-5-codex": { input: 1.25, output: 10.00, cached: 0.625, reasoning: 10.00, cache_creation: 1.25 },
"gpt-5.1": { input: 1.25, output: 10.00, cached: 0.625, reasoning: 10.00, cache_creation: 1.25 },
"gpt-5.1-codex": { input: 1.25, output: 10.00, cached: 0.625, reasoning: 10.00, cache_creation: 1.25 },
"gpt-5": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 },
"gpt-5-mini": { input: 0.75, output: 3.00, cached: 0.375, reasoning: 4.50, cache_creation: 0.75 },
"gpt-5-codex": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 },
"gpt-5.1": { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 },
"gpt-5.1-codex": { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 },
"gpt-5.1-codex-mini": { input: 1.50, output: 6.00, cached: 0.75, reasoning: 9.00, cache_creation: 1.50 },
"gpt-5.1-codex-mini-high": { input: 2.00, output: 8.00, cached: 1.00, reasoning: 12.00, cache_creation: 2.00 },
"gpt-5.1-codex-max": { input: 8.00, output: 32.00, cached: 4.00, reasoning: 48.00, cache_creation: 8.00 },
"gpt-5.2": { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 },
"gpt-5.2-codex": { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 },
"gpt-5.3-codex": { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 },
"gpt-5.2": { input: 5.00, output: 20.00, cached: 2.50, reasoning: 30.00, cache_creation: 5.00 },
"gpt-5.2-codex": { input: 5.00, output: 20.00, cached: 2.50, reasoning: 30.00, cache_creation: 5.00 },
"gpt-5.3-codex": { input: 6.00, output: 24.00, cached: 3.00, reasoning: 36.00, cache_creation: 6.00 },
"gpt-5.3-codex-xhigh": { input: 10.00, output: 40.00, cached: 5.00, reasoning: 60.00, cache_creation: 10.00 },
"gpt-5.3-codex-high": { input: 8.00, output: 32.00, cached: 4.00, reasoning: 48.00, cache_creation: 8.00 },
"gpt-5.3-codex-low": { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 },
"gpt-5.3-codex-none": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 },
"gpt-5.3-codex-spark": { input: 3.00, output: 12.00, cached: 0.30, reasoning: 12.00, cache_creation: 3.00 },
"gpt-5.6": { input: 2.50, output: 15.00, cached: 0.25, reasoning: 15.00, cache_creation: 2.50 },
"gpt-5.6-luna": { input: 1.00, output: 6.00, cached: 0.10, reasoning: 6.00, cache_creation: 1.00 },
"gpt-5.6-terra": { input: 2.50, output: 15.00, cached: 0.25, reasoning: 15.00, cache_creation: 2.50 },
"gpt-5.6-sol": { input: 5.00, output: 30.00, cached: 0.50, reasoning: 30.00, cache_creation: 5.00 },
"o1": { input: 15.00, output: 60.00, cached: 7.50, reasoning: 90.00, cache_creation: 15.00 },
"o1-mini": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 },
// === Gemini ===
"gemini-3.7-flash": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.7-flash-high": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.7-flash-medium": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.7-flash-low": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.6-flash": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.6-flash-high": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.6-flash-medium": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.6-flash-low": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.5-flash-lite": { input: 0.30, output: 2.50, cached: 0.03, reasoning: 3.75, cache_creation: 0.375 },
"gemini-3.5-flash-high": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 },
"gemini-3-flash-preview": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 },
"gemini-3-flash-preview": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 },
"gemini-3-pro-preview": { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 },
"gemini-3.1-pro-low": { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 },
"gemini-3.1-pro-high": { input: 4.00, output: 18.00, cached: 0.50, reasoning: 27.00, cache_creation: 4.00 },
@@ -85,18 +74,10 @@ export const MODEL_PRICING = {
"qwen3-coder-flash": { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 },
// === Kimi ===
// Official platform.kimi.ai: cache-hit / cache-miss / output per 1M tokens
"kimi-k3": { input: 3.00, output: 15.00, cached: 0.30, reasoning: 15.00, cache_creation: 3.00 },
"k3": { input: 3.00, output: 15.00, cached: 0.30, reasoning: 15.00, cache_creation: 3.00 },
"kimi-k2.7-code": { input: 0.95, output: 4.00, cached: 0.19, reasoning: 4.00, cache_creation: 0.95 },
"kimi-k2.7-code-highspeed": { input: 1.90, output: 8.00, cached: 0.38, reasoning: 8.00, cache_creation: 1.90 },
"kimi-for-coding": { input: 0.95, output: 4.00, cached: 0.19, reasoning: 4.00, cache_creation: 0.95 },
"kimi-for-coding-highspeed": { input: 1.90, output: 8.00, cached: 0.38, reasoning: 8.00, cache_creation: 1.90 },
"kimi-k2": { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 },
"kimi-k2-thinking": { input: 1.50, output: 6.00, cached: 0.75, reasoning: 9.00, cache_creation: 1.50 },
"kimi-k2.5": { input: 1.20, output: 4.80, cached: 0.60, reasoning: 7.20, cache_creation: 1.20 },
"kimi-k2.5-thinking": { input: 1.80, output: 7.20, cached: 0.90, reasoning: 10.80, cache_creation: 1.80 },
"kimi-k2.6": { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 },
"kimi-latest": { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 },
// === DeepSeek ===
@@ -141,126 +122,10 @@ export const MODEL_PRICING = {
* Keyed by provider alias (cc, cx, gc, gh, ...) or provider id (openai, anthropic, ...).
*/
export const PROVIDER_PRICING = {
// GitHub Copilot (gh) — explicit override, matches canonical gpt-5.3-codex rate
// GitHub Copilot (gh) — gpt-5.3-codex has different rate than canonical
gh: {
"gpt-5.3-codex": { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 },
},
// TokenRouter — exact rates from https://api.tokenrouter.com/api/pricing ($1/1M tokens).
// Ratio→USD: input = model_ratio×2, output = model_ratio×completion_ratio×2.
// These override the canonical MODEL_PRICING/PATTERN_PRICING, whose rates often
// differ from TokenRouter's reseller pricing.
tokenrouter: {
"MiniMax-M3": { input: 0.3, output: 1.2, cached: 0.06, reasoning: 1.2 },
"anthropic/claude-fable-5": { input: 10, output: 50, cached: 1.0, cache_creation: 12.5, reasoning: 50 },
"anthropic/claude-haiku-4.5": { input: 1.0, output: 5.0, cached: 0.1, cache_creation: 1.25, reasoning: 5.0 },
"anthropic/claude-opus-4.5": { input: 5.0, output: 25.0, cached: 0.5, cache_creation: 6.25, reasoning: 25.0 },
"anthropic/claude-opus-4.6": { input: 5.0, output: 25.0, cached: 0.5, cache_creation: 6.25, reasoning: 25.0 },
"anthropic/claude-opus-4.7": { input: 5.0, output: 25.0, cached: 0.5, cache_creation: 6.25, reasoning: 25.0 },
"anthropic/claude-opus-4.7-fast": { input: 30, output: 150, cached: 3.0, reasoning: 150 },
"anthropic/claude-opus-4.8": { input: 5.0, output: 25.0, cached: 0.5, cache_creation: 6.25, reasoning: 25.0 },
"anthropic/claude-opus-4.8-fast": { input: 10, output: 50, cached: 1.0, cache_creation: 12.5, reasoning: 50 },
"anthropic/claude-opus-5": { input: 5.0, output: 25.0, cached: 0.5, cache_creation: 6.25, reasoning: 25.0 },
"anthropic/claude-opus-5-fast": { input: 10, output: 50, cached: 1.0, cache_creation: 12.5, reasoning: 50 },
"anthropic/claude-sonnet-4": { input: 3.0, output: 15.0, cached: 0.3, cache_creation: 3.75, reasoning: 15.0 },
"anthropic/claude-sonnet-4.5": { input: 3.0, output: 15.0, cached: 0.3, cache_creation: 3.75, reasoning: 15.0 },
"anthropic/claude-sonnet-4.6": { input: 3.0, output: 15.0, cached: 0.3, cache_creation: 3.75, reasoning: 15.0 },
"anthropic/claude-sonnet-5": { input: 2, output: 10, cached: 0.2, reasoning: 10 },
"claude-opus-4-8-m-aws": { input: 5.0, output: 25.0, cached: 0.5, cache_creation: 6.25, reasoning: 25.0 },
"deepseek/deepseek-v3.2": { input: 0.26, output: 0.38, cached: 0.13, reasoning: 0.38 },
"deepseek/deepseek-v4-flash": { input: 0.14, output: 0.28, cached: 0.0028, reasoning: 0.28 },
"deepseek/deepseek-v4-flash-0731": { input: 0.14, output: 0.28, cached: 0.0028, reasoning: 0.28 },
"deepseek/deepseek-v4-pro": { input: 0.435, output: 0.87, cached: 0.003625, reasoning: 0.87 },
"ex/gpt-5.4": { input: 2.5, output: 15.0, cached: 0.25, reasoning: 15.0 },
"google/gemini-2.5-flash-image": { input: 0.3, output: 2.5, reasoning: 2.5 },
"google/gemini-3-flash-preview": { input: 0.5, output: 3.0, cached: 0.05, cache_creation: 0.08333, reasoning: 3.0 },
"google/gemini-3-pro-image-preview": { input: 2, output: 12, reasoning: 12 },
"google/gemini-3.1-flash-image-preview": { input: 0.5, output: 3.0, reasoning: 3.0 },
"google/gemini-3.1-flash-lite-image": { input: 0.25, output: 1.5, reasoning: 1.5 },
"google/gemini-3.1-pro-preview": { input: 2, output: 12, cached: 0.2, cache_creation: 0.375, reasoning: 12 },
"google/gemini-3.5-flash": { input: 1.5, output: 9.0, cached: 0.15, cache_creation: 0.08333, reasoning: 9.0 },
"google/gemini-3.5-flash-lite": { input: 0.3, output: 2.5, cached: 0.03, cache_creation: 0.08333, reasoning: 2.5 },
"google/gemini-3.6-flash": { input: 1.5, output: 7.5, cached: 0.15, cache_creation: 0.08333, reasoning: 7.5 },
"google/gemini-embedding-2": { input: 1.0, output: 6.0, cached: 0.1, reasoning: 6.0 },
"google/gemma-4-26b-a4b-it": { input: 0.06, output: 0.33, reasoning: 0.33 },
"kling-3.0-turbo": { input: 2.1, output: 2.1, reasoning: 2.1 },
"microsoft/mai-image-2.5": { input: 5.0, output: 47.0, reasoning: 47.0 },
"minimax/minimax-m2-her": { input: 0.3, output: 1.2, cached: 0.03, reasoning: 1.2 },
"minimax/minimax-m2.1": { input: 0.3, output: 1.2, cached: 0.03, reasoning: 1.2 },
"minimax/minimax-m2.1-highspeed": { input: 0.6, output: 2.4, cached: 0.06, reasoning: 2.4 },
"minimax/minimax-m2.5": { input: 0.3, output: 1.2, cached: 0.03, reasoning: 1.2 },
"minimax/minimax-m2.7": { input: 0.3, output: 1.2, cached: 0.06, reasoning: 1.2 },
"minimax/minimax-m2.7-highspeed": { input: 0.6, output: 2.4, cached: 0.06, reasoning: 2.4 },
"miromind/mirothinker-1-7-deepresearch": { input: 4, output: 25.0, reasoning: 25.0 },
"miromind/mirothinker-1-7-deepresearch-mini": { input: 1.25, output: 10.0, reasoning: 10.0 },
"mistralai/devstral-2512": { input: 0.4, output: 2.0, cached: 0.04, reasoning: 2.0 },
"mistralai/mistral-medium-3-5": { input: 1.5, output: 7.5, reasoning: 7.5 },
"mistralai/mistral-small-2603": { input: 0.15, output: 0.6, cached: 0.015, reasoning: 0.6 },
"mistralai/voxtral-small-24b-2507": { input: 0.1, output: 0.3, cached: 0.01, reasoning: 0.3 },
"moonshotai/kimi-k2.5": { input: 0.6, output: 3.0, cached: 0.1, reasoning: 3.0 },
"moonshotai/kimi-k2.6": { input: 0.95, output: 4.0, cached: 0.16, reasoning: 4.0 },
"moonshotai/kimi-k2.7-code": { input: 0.9286, output: 3.8571, cached: 0.1857, reasoning: 3.8571 },
"moonshotai/kimi-k3": { input: 3.0, output: 15.0, cached: 0.3, reasoning: 15.0 },
"nvidia/nemotron-3-super-120b-a12b": { input: 0.3, output: 0.9, cached: 0.1, reasoning: 0.9 },
"openai/gpt-4o-mini": { input: 0.15, output: 0.6, cached: 0.075, reasoning: 0.6 },
"openai/gpt-5": { input: 1.25, output: 10.0, cached: 0.125, reasoning: 10.0 },
"openai/gpt-5-image": { input: 10, output: 40, cached: 2.5, reasoning: 40 },
"openai/gpt-5-image-mini": { input: 2.5, output: 8.0, cached: 0.25, reasoning: 8.0 },
"openai/gpt-5-mini": { input: 0.25, output: 2.0, cached: 0.025, reasoning: 2.0 },
"openai/gpt-5.2": { input: 1.75, output: 14.0, cached: 0.175, reasoning: 14.0 },
"openai/gpt-5.3-codex": { input: 1.75, output: 14.0, cached: 0.175, reasoning: 14.0 },
"openai/gpt-5.4": { input: 2.5, output: 15.0, cached: 0.25, reasoning: 15.0 },
"openai/gpt-5.4-image-2": { input: 8, output: 30.0, cached: 2.0, reasoning: 30.0 },
"openai/gpt-5.4-mini": { input: 0.75, output: 4.5, cached: 0.075, reasoning: 4.5 },
"openai/gpt-5.4-nano": { input: 0.2, output: 1.25, cached: 0.02, reasoning: 1.25 },
"openai/gpt-5.4-pro": { input: 30, output: 180, reasoning: 180 },
"openai/gpt-5.5": { input: 5.0, output: 30.0, cached: 0.5, reasoning: 30.0 },
"openai/gpt-5.5-pro": { input: 30, output: 180, reasoning: 180 },
"openai/gpt-5.6-luna": { input: 0.2, output: 1.2, cached: 0.02, cache_creation: 0.25, reasoning: 1.2 },
"openai/gpt-5.6-sol": { input: 5.0, output: 30.0, cached: 0.5, cache_creation: 6.25, reasoning: 30.0 },
"openai/gpt-5.6-terra": { input: 2, output: 12, cached: 0.2, cache_creation: 2.5, reasoning: 12 },
"openai/gpt-audio": { input: 2.5, output: 10.0, reasoning: 10.0 },
"openai/gpt-audio-mini": { input: 0.6, output: 2.4, reasoning: 2.4 },
"openai/gpt-oss-120b": { input: 0.039, output: 0.18, reasoning: 0.18 },
"qwen/qwen3-coder-next": { input: 0.12, output: 0.75, cached: 0.06, reasoning: 0.75 },
"qwen/qwen3.5-122b-a10b": { input: 0.26, output: 2.08, reasoning: 2.08 },
"qwen/qwen3.5-35b-a3b": { input: 0.1625, output: 1.3, reasoning: 1.3 },
"qwen/qwen3.5-397b-a17b": { input: 0.39, output: 2.34, reasoning: 2.34 },
"qwen/qwen3.5-9b": { input: 0.1, output: 0.15, reasoning: 0.15 },
"qwen/qwen3.5-flash": { input: 0.1048, output: 0.4194, reasoning: 0.4194 },
"qwen/qwen3.5-plus-02-15": { input: 0.26, output: 1.56, reasoning: 1.56 },
"qwen/qwen3.6-plus": { input: 0.54, output: 3.21, reasoning: 3.21 },
"qwen/qwen3.7-max": { input: 1.25, output: 3.75, cached: 0.25, reasoning: 3.75 },
"qwen/qwen3.7-plus": { input: 0.4, output: 1.6, cached: 0.08, reasoning: 1.6 },
"qwen/qwen3.8-max": { input: 2, output: 6, cached: 0.25, cache_creation: 2.5, reasoning: 6 },
"qwen3.5-omni-plus": { input: 1.0, output: 5.7143, reasoning: 5.7143 },
"qwen3.6-flash": { input: 0.171, output: 1.029, cached: 0.017, cache_creation: 0.214, reasoning: 1.029 },
"sakana/fugu-ultra": { input: 5.0, output: 30.0, cached: 0.5, reasoning: 30.0 },
"seed-2-0-code-preview-260328": { input: 1.0, output: 6.0, cached: 0.2, cache_creation: 0.008333, reasoning: 6.0 },
"seed-2-0-lite-260428": { input: 0.5, output: 4.0, cached: 0.1, cache_creation: 0.008333, reasoning: 4.0 },
"seed-2-0-mini-260428": { input: 0.2, output: 0.8, cached: 0.04, cache_creation: 0.00833, reasoning: 0.8 },
"seed-2-0-pro-260328": { input: 1.0, output: 6.0, cached: 0.2, cache_creation: 0.008333, reasoning: 6.0 },
"stepfun/step-3.5-flash": { input: 0.1, output: 0.3, cached: 0.02, reasoning: 0.3 },
"stepfun/step-3.7-flash": { input: 0.2, output: 1.15, cached: 0.04, reasoning: 1.15 },
"tencent/hy3-preview": { input: 0.066, output: 0.26, cached: 0.029, reasoning: 0.26 },
"x-ai/grok-4.1-fast": { input: 0.2, output: 0.5, cached: 0.05, reasoning: 0.5 },
"x-ai/grok-4.20-beta": { input: 2, output: 6, cached: 0.2, reasoning: 6 },
"x-ai/grok-4.3": { input: 1.25, output: 2.5, cached: 0.2, reasoning: 2.5 },
"x-ai/grok-4.5": { input: 2, output: 6, cached: 0.5, reasoning: 6 },
"x-ai/grok-build-0.1": { input: 1.0, output: 2.0, cached: 0.2, reasoning: 2.0 },
"xiaomi/mimo-v2-flash": { input: 0.1, output: 0.3, cached: 0.01, reasoning: 0.3 },
"xiaomi/mimo-v2-omni": { input: 0.4, output: 2.0, cached: 0.08, reasoning: 2.0 },
"xiaomi/mimo-v2-pro": { input: 1.0, output: 3.0, cached: 0.2, reasoning: 3.0 },
"xiaomi/mimo-v2.5": { input: 0.4, output: 2.0, cached: 0.08, reasoning: 2.0 },
"xiaomi/mimo-v2.5-pro": { input: 1.0, output: 3.0, cached: 0.2, reasoning: 3.0 },
"z-ai/glm-4.5-air": { input: 0.13, output: 0.85, cached: 0.025, reasoning: 0.85 },
"z-ai/glm-4.6": { input: 0.6, output: 2.2, cached: 0.11, reasoning: 2.2 },
"z-ai/glm-4.6v": { input: 0.3, output: 0.9, reasoning: 0.9 },
"z-ai/glm-4.7": { input: 0.6, output: 2.2, cached: 0.11, reasoning: 2.2 },
"z-ai/glm-5": { input: 1.0, output: 3.2, cached: 0.2, reasoning: 3.2 },
"z-ai/glm-5-turbo": { input: 1.2, output: 4.0, cached: 0.24, reasoning: 4.0 },
"z-ai/glm-5.1": { input: 1.05, output: 3.5, cached: 0.525, reasoning: 3.5 },
"z-ai/glm-5.2": { input: 1.4, output: 4.4, cached: 0.26, reasoning: 4.4 },
},
};
/**
@@ -275,11 +140,11 @@ export const PATTERN_PRICING = [
{ pattern: "*-codex-max", pricing: { input: 8.00, output: 32.00, cached: 4.00, reasoning: 48.00, cache_creation: 8.00 } },
{ pattern: "*-codex-mini-*", pricing: { input: 1.50, output: 6.00, cached: 0.75, reasoning: 9.00, cache_creation: 1.50 } },
{ pattern: "*-codex-mini", pricing: { input: 1.50, output: 6.00, cached: 0.75, reasoning: 9.00, cache_creation: 1.50 } },
{ pattern: "*-codex-low", pricing: { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 } },
{ pattern: "*-codex-none", pricing: { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 } },
{ pattern: "*-codex-low", pricing: { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 } },
{ pattern: "*-codex-none", pricing: { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 } },
{ pattern: "*-codex-spark", pricing: { input: 3.00, output: 12.00, cached: 0.30, reasoning: 12.00, cache_creation: 3.00 } },
{ pattern: "codex-*", pricing: { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 } },
{ pattern: "*-codex", pricing: { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 } },
{ pattern: "codex-*", pricing: { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 } },
{ pattern: "*-codex", pricing: { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 } },
// --- Claude ---
{ pattern: "claude-opus-*", pricing: { input: 5.00, output: 25.00, cached: 0.50, reasoning: 25.00, cache_creation: 6.25 } },
@@ -296,12 +161,11 @@ export const PATTERN_PRICING = [
{ pattern: "gemini-*", pricing: { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 } },
// --- GPT (specific first, generic last) ---
{ pattern: "gpt-5.6-*", pricing: { input: 2.50, output: 15.00, cached: 0.25, reasoning: 15.00, cache_creation: 2.50 } },
{ pattern: "gpt-5.3-*", pricing: { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 } },
{ pattern: "gpt-5.2-*", pricing: { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 } },
{ pattern: "gpt-5.1-*", pricing: { input: 1.25, output: 10.00, cached: 0.625, reasoning: 10.00, cache_creation: 1.25 } },
{ pattern: "gpt-5-*", pricing: { input: 1.25, output: 10.00, cached: 0.625, reasoning: 10.00, cache_creation: 1.25 } },
{ pattern: "gpt-5*", pricing: { input: 1.25, output: 10.00, cached: 0.625, reasoning: 10.00, cache_creation: 1.25 } },
{ pattern: "gpt-5.3-*", pricing: { input: 6.00, output: 24.00, cached: 3.00, reasoning: 36.00, cache_creation: 6.00 } },
{ pattern: "gpt-5.2-*", pricing: { input: 5.00, output: 20.00, cached: 2.50, reasoning: 30.00, cache_creation: 5.00 } },
{ pattern: "gpt-5.1-*", pricing: { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 } },
{ pattern: "gpt-5-*", pricing: { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 } },
{ pattern: "gpt-5*", pricing: { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 } },
{ pattern: "gpt-4o-*", pricing: { input: 0.15, output: 0.60, cached: 0.075, reasoning: 0.90, cache_creation: 0.15 } },
{ pattern: "gpt-4o", pricing: { input: 2.50, output: 10.00, cached: 1.25, reasoning: 15.00, cache_creation: 2.50 } },
{ pattern: "gpt-4*", pricing: { input: 2.50, output: 10.00, cached: 1.25, reasoning: 15.00, cache_creation: 2.50 } },
@@ -319,7 +183,6 @@ export const PATTERN_PRICING = [
// --- Kimi ---
{ pattern: "kimi-*-thinking", pricing: { input: 1.80, output: 7.20, cached: 0.90, reasoning: 10.80, cache_creation: 1.80 } },
{ pattern: "kimi-k3*", pricing: { input: 3.00, output: 15.00, cached: 0.30, reasoning: 15.00, cache_creation: 3.00 } },
{ pattern: "kimi-k2*", pricing: { input: 1.20, output: 4.80, cached: 0.60, reasoning: 7.20, cache_creation: 1.20 } },
{ pattern: "kimi-*", pricing: { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 } },
@@ -416,10 +279,7 @@ export function calculateCostFromTokens(tokens, pricing) {
const inputTokens = tokens.prompt_tokens || tokens.input_tokens || 0;
const cachedTokens = tokens.cached_tokens || tokens.cache_read_input_tokens || 0;
const cacheCreationTokens = tokens.cache_creation_input_tokens || 0;
// prompt_tokens is cache-inclusive (see canonicalizeUsage): cached + cache_creation
// are subsets, so subtract both to avoid charging them at the full input rate.
const nonCachedInput = Math.max(0, inputTokens - cachedTokens - cacheCreationTokens);
const nonCachedInput = Math.max(0, inputTokens - cachedTokens);
cost += nonCachedInput * (pricing.input / 1000000);
@@ -435,6 +295,7 @@ export function calculateCostFromTokens(tokens, pricing) {
cost += reasoningTokens * ((pricing.reasoning || pricing.output) / 1000000);
}
const cacheCreationTokens = tokens.cache_creation_input_tokens || 0;
if (cacheCreationTokens > 0) {
cost += cacheCreationTokens * ((pricing.cache_creation || pricing.input) / 1000000);
}

View File

@@ -3,20 +3,19 @@ export default {
priority: 10,
alias: "alicode-intl",
display: {
name: "Alibaba Coding",
name: "Alibaba Intl",
icon: "cloud",
color: "#FF6A00",
textIcon: "ALi",
website: "https://www.alibabacloud.com/product/coding",
website: "https://modelstudio.console.alibabacloud.com",
notice: {
apiKeyUrl: "https://www.alibabacloud.com/product/coding",
apiKeyUrl: "https://modelstudio.console.alibabacloud.com/?apiKey=1",
},
},
category: "apikey",
transport: {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions",
headers: {},
quirks: { preserveCacheControl: true },
},
models: [
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },

View File

@@ -16,7 +16,6 @@ export default {
transport: {
baseUrl: "https://coding.dashscope.aliyuncs.com/v1/chat/completions",
headers: {},
quirks: { preserveCacheControl: true },
},
models: [
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },

View File

@@ -1,32 +0,0 @@
// Model Studio Intl — standard DashScope API keys (sk-...), NOT Coding Plan keys.
// Sibling of alicode-intl (Coding Plan). Two key types use two different hosts.
export default {
id: "alims-intl",
priority: 11,
alias: "alims-intl",
display: {
name: "Alibaba Studio",
icon: "cloud",
color: "#FF6A00",
textIcon: "ALi",
website: "https://modelstudio.console.alibabacloud.com",
notice: {
apiKeyUrl: "https://modelstudio.console.alibabacloud.com/?apiKey=1",
},
},
category: "apikey",
transport: {
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
headers: {},
quirks: { preserveCacheControl: true },
},
models: [
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "glm-5", name: "GLM 5" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "glm-4.7", name: "GLM 4.7" },
],
};

View File

@@ -1,35 +0,0 @@
// Token Plan — credit subscription keys on token-plan.<region>.maas.aliyuncs.com.
// Fourth Alibaba key type: Coding Plan (alicode/alicode-intl) and Model Studio
// (alims-intl) both reject these keys, and they reject Model Studio keys back.
// Singapore is the only region that serves the plan; eu-central-1 answers
// IllegalEndpoint. The Anthropic surface (/apps/anthropic/v1/messages) is not
// authorized for this plan, so OpenAI-compatible mode is the only transport.
export default {
id: "alitp-intl",
priority: 11,
alias: "alitp-intl",
display: {
name: "Alibaba Token Plan",
icon: "cloud",
color: "#FF6A00",
textIcon: "ATP",
website: "https://www.alibabacloud.com/campaign/ai-landing-page-token",
notice: {
apiKeyUrl: "https://modelstudio.console.alibabacloud.com/?apiKey=1",
},
},
category: "apikey",
transport: {
baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
headers: {},
quirks: { preserveCacheControl: true },
},
models: [
{ id: "qwen3.8-max-preview", name: "Qwen3.8 Max Preview" },
{ id: "qwen3.7-max", name: "Qwen3.7 Max" },
{ id: "qwen3.7-plus", name: "Qwen3.7 Plus" },
{ id: "qwen3.6-flash", name: "Qwen3.6 Flash" },
{ id: "glm-5.2", name: "GLM 5.2" },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
],
};

View File

@@ -1,3 +1,5 @@
import { CLAUDE_API_HEADERS } from "../shared.js";
export default {
id: "anthropic",
priority: 30,
@@ -17,7 +19,7 @@ export default {
baseUrl: "https://api.anthropic.com/v1/messages",
format: "claude",
headers: {
"anthropic-version": "2023-06-01",
"Anthropic-Version": "2023-06-01",
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
},
},

View File

@@ -1,4 +1,5 @@
import { ANTIGRAVITY_IDE_BASE_URL, ANTIGRAVITY_IDE_USER_AGENT, ANTIGRAVITY_OAUTH_CLIENT } from "../shared.js";
import { platform, arch } from "os";
import { ANTIGRAVITY_OAUTH_CLIENT } from "../shared.js";
export default {
id: "antigravity",
@@ -19,24 +20,23 @@ export default {
category: "oauth",
serviceKinds: ["llm", "image"],
transport: {
baseUrls: [ANTIGRAVITY_IDE_BASE_URL],
baseUrls: [
"https://daily-cloudcode-pa.googleapis.com",
"https://daily-cloudcode-pa.sandbox.googleapis.com",
],
format: "antigravity",
headers: {
"User-Agent": ANTIGRAVITY_IDE_USER_AGENT,
"User-Agent": "antigravity/1.107.0 darwin/arm64",
},
retry: {
"429": {
attempts: 3,
},
"500": {
attempts: 3,
},
"503": {
attempts: 3,
},
},
usage: {
// Discovery (quota/project) on PROD; daily host rejects these.
quotaApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels",
loadProjectApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
tokenUrl: "https://oauth2.googleapis.com/token",
@@ -45,13 +45,6 @@ export default {
clientSecret: "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",
},
models: [
{ id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)", upstreamModelId: "gemini-3.7-flash-tiered(high)" },
{ id: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)", upstreamModelId: "gemini-3.7-flash-tiered(medium)" },
{ id: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash (Low)", upstreamModelId: "gemini-3.7-flash-tiered(low)" },
{ id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", upstreamModelId: "gemini-3.6-flash-tiered(high)" },
{ id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", upstreamModelId: "gemini-3.6-flash-tiered(medium)" },
{ id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)", upstreamModelId: "gemini-3.6-flash-tiered(low)" },
{ id: "gemini-3.5-flash-high", name: "Gemini 3.5 Flash (High)" },
{ id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)" },
{ id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium)" },
{ id: "gemini-3.5-flash-extra-low", name: "Gemini 3.5 Flash (Low)" },
@@ -75,11 +68,12 @@ export default {
"https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs",
],
apiEndpoint: "https://daily-cloudcode-pa.googleapis.com",
apiEndpoint: "https://cloudcode-pa.googleapis.com",
apiVersion: "v1internal",
loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
onboardUserEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
loadCodeAssistUserAgent: ANTIGRAVITY_IDE_USER_AGENT,
loadCodeAssistUserAgent: "google-api-nodejs-client/9.15.1",
loadCodeAssistApiClient: "google-cloud-sdk vscode_cloudshelleditor/0.1",
refreshLeadMs: 300000,
},
features: {

View File

@@ -1,36 +0,0 @@
export default {
id: "api-airforce",
alias: "af",
aliases: [
"airforce",
],
uiAlias: "af",
display: {
name: "API.airforce",
icon: "flight",
color: "#0EA5E9",
textIcon: "AF",
website: "https://api.airforce",
notice: {
apiKeyUrl: "https://api.airforce",
},
},
category: "freeTier",
authType: "apikey",
authModes: [
"apikey",
],
transport: {
baseUrl: "https://api.airforce/v1/chat/completions",
validateUrl: "https://api.airforce/v1/models",
headers: {
"HTTP-Referer": "https://endpoint-proxy.local",
"X-Title": "Endpoint Proxy",
},
},
models: [
{ id: "anthropic/claude-3.7-sonnet", name: "Claude 3.7 Sonnet (Free)", contextLength: 200000 },
{ id: "moonshot/kimi-k2.6", name: "Kimi K2.6 (Free)", contextLength: 262144 },
{ id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash (Free)", contextLength: 1048576 },
],
};

View File

@@ -1,33 +0,0 @@
export default {
id: "baidu",
alias: "qianfan",
aliases: ["qianfan", "ernie", "baidu-qianfan"],
uiAlias: "qianfan",
category: "apikey",
authType: "apikey",
authModes: ["apikey"],
display: {
name: "Baidu Qianfan",
icon: "search",
color: "#2932E1",
textIcon: "BD",
website: "https://cloud.baidu.com/product/qianfan.html",
notice: {
apiKeyUrl:
"https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application",
},
},
transport: {
baseUrl: "https://qianfan.baidubce.com/v2/chat/completions",
validateUrl: "https://qianfan.baidubce.com/v2/models",
},
models: [
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", contextLength: 1048576 },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", contextLength: 1048576 },
{ id: "glm-5.2", name: "GLM 5.2", contextLength: 512000 },
{ id: "glm-5.1", name: "GLM 5.1", contextLength: 198000 },
{ id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 262144 },
{ id: "qwen3.5-397b-a17b", name: "Qwen 3.5 397B A17B", contextLength: 262144 },
{ id: "qwen3.5-27b", name: "Qwen 3.5 27B", contextLength: 262144 },
],
};

View File

@@ -1,47 +0,0 @@
export default {
id: "bazaarlink",
alias: "bzl",
aliases: ["bazaar-link"],
uiAlias: "bzl",
category: "freeTier",
authType: "apikey",
authModes: ["apikey"],
display: {
name: "Bazaarlink",
icon: "storefront",
color: "#DC2626",
textIcon: "BZ",
website: "https://bazaarlink.ai",
notice: { apiKeyUrl: "https://bazaarlink.ai" },
},
transport: {
baseUrl: "https://bazaarlink.ai/api/v1/chat/completions",
validateUrl: "https://bazaarlink.ai/api/v1/models",
},
models: [
{ id: "auto:free", name: "Auto Free (Zero Cost)" },
{ id: "claude-opus-4.7", name: "Claude Opus 4.7", contextLength: 1000000 },
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", contextLength: 1000000 },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5", contextLength: 200000 },
{ id: "gpt-5.5", name: "GPT-5.5", contextLength: 1050000 },
{ id: "gpt-5.4", name: "GPT-5.4", contextLength: 1050000 },
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini", contextLength: 400000 },
{ id: "gpt-5.4-nano", name: "GPT-5.4 Nano", contextLength: 400000 },
{ id: "grok-4.3", name: "Grok 4.3", contextLength: 1000000 },
{ id: "grok-4.20", name: "Grok 4.20", contextLength: 2000000 },
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro", contextLength: 1048576 },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash", contextLength: 1048576 },
{ id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite", contextLength: 1048576 },
{ id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 262144 },
{ id: "kimi-k2.5", name: "Kimi K2.5", contextLength: 262144 },
{ id: "glm-5.1", name: "GLM 5.1", contextLength: 204800 },
{ id: "glm-5", name: "GLM 5", contextLength: 204800 },
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", contextLength: 1050000 },
{ id: "mimo-v2.5", name: "MiMo-V2.5", contextLength: 1050000 },
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576 },
{ id: "minimax-m2.7", name: "MiniMax M2.7", contextLength: 204800 },
{ id: "minimax-m2.5", name: "MiniMax M2.5", contextLength: 204800 },
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus", contextLength: 1000000 },
{ id: "nemotron-3-super-120b-a12b", name: "Nemotron 3 Super", contextLength: 1000000 },
],
};

View File

@@ -17,25 +17,27 @@ export default {
},
},
category: "apikey",
serviceKinds: ["llm"],
thinkingConfig: {
options: ["auto", "none", "low", "medium", "high", "xhigh"],
defaultMode: "auto",
},
transport: {
baseUrl: "https://api.blackbox.ai/v1/chat/completions",
baseUrl: "https://api.blackbox.ai/chat/completions",
thinkingFormat: "openai",
},
models: [
{ id: "claude-fable-5", name: "Claude Fable 5", upstreamModelId: "blackboxai/anthropic/claude-fable-5" },
{ id: "claude-opus-4.8", name: "Claude Opus 4.8", upstreamModelId: "blackboxai/anthropic/claude-opus-4.8" },
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", upstreamModelId: "blackboxai/anthropic/claude-sonnet-4.6" },
{ id: "gpt-5.5", name: "GPT-5.5", upstreamModelId: "blackboxai/openai/gpt-5.5" },
{ id: "gpt-5.4-pro", name: "GPT-5.4 Pro", upstreamModelId: "blackboxai/openai/gpt-5.4-pro" },
{ id: "gpt-5.4", name: "GPT-5.4", upstreamModelId: "blackboxai/openai/gpt-5.4" },
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex", upstreamModelId: "blackboxai/openai/gpt-5.3-codex" },
{ id: "gpt-5.4-nano", name: "GPT-5.4 Nano", upstreamModelId: "blackboxai/openai/gpt-5.4-nano" },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", upstreamModelId: "blackboxai/deepseek/deepseek-v4-flash" },
{ id: "grok-4.3", name: "Grok 4.3", upstreamModelId: "blackboxai/x-ai/grok-4.3" },
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "gpt-4o-mini", name: "GPT-4o mini" },
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
{ id: "claude-opus-4.6", name: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Legacy)" },
{ id: "claude-opus-4-6", name: "Claude Opus 4.6 (Legacy)" },
{ id: "deepseek-chat", name: "DeepSeek Chat" },
{ id: "deepseek-v3-671b", name: "DeepSeek V3 671B" },
{ id: "deepseek-r1", name: "DeepSeek R1" },
{ id: "o1", name: "OpenAI o1" },
{ id: "o3-mini", name: "OpenAI o3-mini" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "qwen3-max", name: "Qwen3 Max" },
{ id: "qwen3-vl-plus", name: "Qwen3 VL Plus" },
],
};

View File

@@ -1,38 +0,0 @@
export default {
id: "bluesminds",
alias: "bm",
aliases: ["blue-sminds"],
uiAlias: "bm",
hidden: true,
display: {
name: "BluesMinds",
icon: "psychology",
color: "#2563EB",
textIcon: "BM",
website: "https://bluesminds.com",
notice: { apiKeyUrl: "https://bluesminds.com" },
},
category: "apikey",
authType: "apikey",
authModes: ["apikey"],
transport: {
baseUrl: "https://api.bluesminds.com/v1/chat/completions",
validateUrl: "https://api.bluesminds.com/v1/models",
},
models: [
{ id: "gpt-4.1", name: "GPT-4.1", contextLength: 1048576 },
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", contextLength: 1048576 },
{ id: "gpt-4.1-nano", name: "GPT-4.1 Nano", contextLength: 1048576 },
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", contextLength: 200000 },
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5", contextLength: 200000 },
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", contextLength: 1048576 },
{ id: "gemini-2.0-flash-exp", name: "Gemini 2.0 Flash (Exp)", contextLength: 1048576 },
{ id: "qwen-turbo", name: "Qwen Turbo", contextLength: 1000000 },
{ id: "kimi-k2", name: "Kimi K2", contextLength: 262144 },
{ id: "kimi-k2-thinking", name: "Kimi K2 Thinking", contextLength: 262144 },
{ id: "glm-4.7", name: "GLM 4.7", contextLength: 204800 },
{ id: "minimax-m2.5", name: "MiniMax M2.5", contextLength: 204800 },
{ id: "claude-opus-4-5", name: "Claude Opus 4.5 (VIP)", contextLength: 200000 },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (VIP)", contextLength: 1048576 },
],
};

View File

@@ -49,6 +49,9 @@ export default {
header: "Authorization",
scheme: "bearer",
},
hooks: [
"claudeOverlay",
],
},
usage: {
oauthUrl: "https://api.anthropic.com/api/oauth/usage",
@@ -57,9 +60,12 @@ export default {
},
},
models: [
{ id: "claude-opus-5", name: "Claude Opus 5" },
{ id: "claude-fable-5", name: "Claude Fable 5" },
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
{ id: "claude-opus-4-7", name: "Claude Opus 4.7" },
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ id: "claude-opus-4-5-20251101", name: "Claude 4.5 Opus" },
{ id: "claude-sonnet-4-5-20250929", name: "Claude 4.5 Sonnet" },
{ id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" },
],
oauth: {

View File

@@ -1,57 +0,0 @@
export default {
id: "clinepass",
priority: 85,
alias: "clinepass",
uiAlias: "clinepass",
display: {
name: "ClinePass",
icon: "vpn_key",
color: "#5B9BD5",
textIcon: "CP",
website: "https://cline.bot",
notice: {
signupUrl: "https://app.cline.bot",
},
},
category: "oauth",
authModes: ["oauth", "apikey"],
hasOAuth: true,
transport: {
baseUrl: "https://api.cline.bot/api/v1/chat/completions",
headers: {
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline",
},
auth: {
combined: true,
header: "Authorization",
scheme: "bearer",
hooks: [
"clineHeaders",
],
},
},
models: [
{ id: "cline-pass/glm-5.2", name: "GLM-5.2 (ClinePass)" },
{ id: "cline-pass/kimi-k2.7-code", name: "Kimi K2.7 Code (ClinePass)" },
{ id: "cline-pass/kimi-k2.6", name: "Kimi K2.6 (ClinePass)" },
{ id: "cline-pass/deepseek-v4-pro", name: "DeepSeek V4 Pro (ClinePass)" },
{ id: "cline-pass/deepseek-v4-flash", name: "DeepSeek V4 Flash (ClinePass)" },
{ id: "cline-pass/mimo-v2.5", name: "MiMo-V2.5 (ClinePass)" },
{ id: "cline-pass/mimo-v2.5-pro", name: "MiMo-V2.5-Pro (ClinePass)" },
{ id: "cline-pass/minimax-m3", name: "MiniMax M3 (ClinePass)" },
{ id: "cline-pass/qwen3.7-max", name: "Qwen3.7 Max (ClinePass)" },
{ id: "cline-pass/qwen3.7-plus", name: "Qwen3.7 Plus (ClinePass)" },
],
oauth: {
appBaseUrl: "https://app.cline.bot",
apiBaseUrl: "https://api.cline.bot",
authorizeUrl: "https://api.cline.bot/api/v1/auth/authorize",
tokenUrl: "https://api.cline.bot/api/v1/auth/token",
refreshUrl: "https://api.cline.bot/api/v1/auth/refresh",
},
thinkingConfig: {
options: ["auto", "on", "off"],
defaultMode: "auto",
},
};

View File

@@ -19,8 +19,6 @@ export default {
},
},
category: "freeTier",
authType: "apikey",
authModes: ["apikey"],
hasProviderSpecificData: true,
transport: {
baseUrl: "https://api.cloudflare.com/client/v4/accounts/{accountId}/ai/v1/chat/completions",

View File

@@ -1,77 +0,0 @@
// CodeBuddy international (codebuddy.ai) — mirrors codebuddy-cn registry shape,
// swapping the Tencent CN domain for the .ai endpoint set. All OAuth/plugin URLs
// use the /v2/plugin prefix with platform=ide (CN uses platform=CLI).
export default {
id: "codebuddy-intl",
alias: "cbai",
uiAlias: "cbai",
hidden: false,
priority: 90,
display: {
name: "CodeBuddy",
icon: "smart_toy",
color: "#006EFF",
website: "https://www.codebuddy.ai",
notice: {
signupUrl: "https://www.codebuddy.ai",
},
},
category: "oauth",
authModes: ["oauth", "apikey"],
hasOAuth: true,
transport: {
// Chat gateway is OpenAI-compatible SSE (same /v2/chat/completions path as CN).
baseUrl: "https://www.codebuddy.ai/v2/chat/completions",
forceStream: true,
// CodeBuddy intl speaks the same unified OpenAI reasoning_effort shape as CN.
thinkingFormat: "openai",
headers: {
"User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1",
"X-Product": "SaaS",
"X-IDE-Type": "IDE",
"X-IDE-Name": "IDE",
"x-requested-with": "XMLHttpRequest",
"x-codebuddy-request": "1",
},
auth: {
combined: true,
header: "Authorization",
scheme: "bearer",
},
// Intl billing endpoint mirrors CN shape (data.Response.Data.Accounts[]).
usage: {
url: "https://www.codebuddy.ai/v2/billing/meter/get-user-resource",
},
},
// Same model lineup exposed by the CN gateway — intl backend is the same catalog.
models: [
{ id: "glm-5.2", name: "GLM-5.2" },
{ id: "glm-5.1", name: "GLM-5.1" },
{ id: "glm-5.0", name: "GLM-5.0" },
{ id: "glm-5.0-turbo", name: "GLM-5.0-Turbo" },
{ id: "glm-5v-turbo", name: "GLM-5v-Turbo" },
{ id: "glm-4.7", name: "GLM-4.7" },
{ id: "minimax-m3", name: "MiniMax-M3" },
{ id: "minimax-m2.7", name: "MiniMax-M2.7" },
{ id: "kimi-k2.7", name: "Kimi-K2.7-Code" },
{ id: "kimi-k2.6", name: "Kimi-K2.6" },
{ id: "kimi-k2.5", name: "Kimi-K2.5" },
{ id: "hy3-preview", name: "Hy3 Preview" },
{ id: "deepseek-v4-pro", name: "DeepSeek-V4-Pro" },
{ id: "deepseek-v4-flash", name: "DeepSeek-V4-Flash" },
{ id: "deepseek-v3-2-volc", name: "DeepSeek-V3.2" },
],
oauth: {
baseUrl: "https://www.codebuddy.ai",
stateUrl: "https://www.codebuddy.ai/v2/plugin/auth/state",
tokenUrl: "https://www.codebuddy.ai/v2/plugin/auth/token",
refreshUrl: "https://www.codebuddy.ai/v2/plugin/auth/token/refresh",
userAgent: "IDE/2.63.2 CodeBuddy/2.63.2",
platform: "ide",
pollInterval: 5000,
},
features: {
usage: true,
usageApikey: true,
},
};

View File

@@ -40,23 +40,26 @@ export default {
},
usage: {
url: "https://chatgpt.com/backend-api/wham/usage",
resetCreditsUrl: "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits",
resetCreditsConsumeUrl: "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume",
},
},
models: [
{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol" },
{ id: "gpt-5.6-sol-review", name: "GPT 5.6 Sol Review", upstreamModelId: "gpt-5.6-sol", quotaFamily: "review" },
{ id: "gpt-5.6-terra", name: "GPT 5.6 Terra" },
{ id: "gpt-5.6-terra-review", name: "GPT 5.6 Terra Review", upstreamModelId: "gpt-5.6-terra", quotaFamily: "review" },
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna" },
{ id: "gpt-5.6-luna-review", name: "GPT 5.6 Luna Review", upstreamModelId: "gpt-5.6-luna", quotaFamily: "review" },
{ id: "gpt-5.5", name: "GPT 5.5" },
{ id: "gpt-5.5-review", name: "GPT 5.5 Review", upstreamModelId: "gpt-5.5", quotaFamily: "review" },
{ id: "gpt-5.4", name: "GPT 5.4" },
{ id: "gpt-5.4-review", name: "GPT 5.4 Review", upstreamModelId: "gpt-5.4", quotaFamily: "review" },
{ id: "gpt-5.4-mini", name: "GPT 5.4 Mini" },
{ id: "gpt-5.4-mini-review", name: "GPT 5.4 Mini Review", upstreamModelId: "gpt-5.4-mini", quotaFamily: "review" },
{ id: "gpt-5.3-codex", name: "GPT 5.3 Codex" },
{ id: "gpt-5.3-codex-review", name: "GPT 5.3 Codex Review", upstreamModelId: "gpt-5.3-codex", quotaFamily: "review" },
{ id: "gpt-5.3-codex-xhigh", name: "GPT 5.3 Codex (xHigh)" },
{ id: "gpt-5.3-codex-xhigh-review", name: "GPT 5.3 Codex (xHigh) Review", upstreamModelId: "gpt-5.3-codex-xhigh", quotaFamily: "review" },
{ id: "gpt-5.3-codex-high", name: "GPT 5.3 Codex (High)" },
{ id: "gpt-5.3-codex-high-review", name: "GPT 5.3 Codex (High) Review", upstreamModelId: "gpt-5.3-codex-high", quotaFamily: "review" },
{ id: "gpt-5.3-codex-low", name: "GPT 5.3 Codex (Low)" },
{ id: "gpt-5.3-codex-low-review", name: "GPT 5.3 Codex (Low) Review", upstreamModelId: "gpt-5.3-codex-low", quotaFamily: "review" },
{ id: "gpt-5.3-codex-none", name: "GPT 5.3 Codex (None)" },
{ id: "gpt-5.3-codex-none-review", name: "GPT 5.3 Codex (None) Review", upstreamModelId: "gpt-5.3-codex-none", quotaFamily: "review" },
{ id: "gpt-5.3-codex-spark", name: "GPT 5.3 Codex Spark" },
{ id: "gpt-5.3-codex-spark-review", name: "GPT 5.3 Codex Spark Review", upstreamModelId: "gpt-5.3-codex-spark", quotaFamily: "review" },
{ id: "gpt-5.5-image", name: "GPT 5.5 Image", capabilities: ["text2img","edit"], params: ["size","quality","background","image_detail","output_format"], kind: "image" },

View File

@@ -23,7 +23,7 @@ export default {
"Content-Type": "application/connect+proto",
"User-Agent": "connect-es/1.6.1",
},
clientVersion: "3.12.17",
clientVersion: "3.1.0",
},
models: [
{ id: "default", name: "Auto (Server Picks)" },
@@ -44,11 +44,11 @@ export default {
oauth: {
apiEndpoint: "https://api2.cursor.sh",
chatEndpoint: "/aiserver.v1.ChatService/StreamUnifiedChatWithTools",
modelsEndpoint: "/agent.v1.AgentService/GetUsableModels",
modelsEndpoint: "/aiserver.v1.AiService/GetDefaultModelNudgeData",
api3Endpoint: "https://api3.cursor.sh",
agentEndpoint: "https://agent.api5.cursor.sh",
agentNonPrivacyEndpoint: "https://agentn.api5.cursor.sh",
clientVersion: "3.12.17",
clientVersion: "3.1.0",
clientType: "ide",
dbKeys: {
accessToken: "cursorAuth/accessToken",

View File

@@ -48,8 +48,4 @@ export default {
{ id: "deepseek-chat", name: "DeepSeek V3.2 Chat" },
{ id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner" },
],
features: {
usage: true,
usageApikey: true,
},
};

View File

@@ -1,63 +0,0 @@
export default {
id: "devin-cli",
alias: "dv",
aliases: ["devin"],
uiAlias: "dv",
hidden: true,
display: {
name: "Devin CLI",
icon: "smart_toy",
color: "#6366F1",
textIcon: "DV",
website: "https://devin.ai",
notice: {
signupUrl: "https://cli.devin.ai",
text: "Install: `curl -fsSL https://cli.devin.ai/install.sh | bash` (macOS: `brew install --cask devin-cli`, Windows PowerShell: `irm https://static.devin.ai/cli/setup.ps1 | iex`). Then run `devin auth login`. No API key needed.",
},
},
category: "free",
authType: "none",
noAuth: true,
authModes: ["none"],
transport: {
baseUrl: "devin://acp/stdio",
format: "openai",
},
models: [
{ id: "swe-1.6-fast", name: "SWE-1.6 Fast" },
{ id: "swe-1.6", name: "SWE-1.6" },
{ id: "swe-1.5-fast", name: "SWE-1.5 Fast" },
{ id: "swe-1.5", name: "SWE-1.5" },
{ id: "claude-opus-4.7-max", name: "Claude Opus 4.7 Max", contextLength: 200000 },
{ id: "claude-opus-4.7-high", name: "Claude Opus 4.7 High", contextLength: 200000 },
{ id: "claude-opus-4.7-medium", name: "Claude Opus 4.7 Medium", contextLength: 200000 },
{ id: "claude-opus-4.7-low", name: "Claude Opus 4.7 Low", contextLength: 200000 },
{ id: "claude-sonnet-4.6-thinking-1m", name: "Claude Sonnet 4.6 Thinking 1M", contextLength: 1000000 },
{ id: "claude-sonnet-4.6-thinking", name: "Claude Sonnet 4.6 Thinking", contextLength: 200000 },
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", contextLength: 200000 },
{ id: "claude-opus-4.6-thinking", name: "Claude Opus 4.6 Thinking", contextLength: 200000 },
{ id: "claude-opus-4.6", name: "Claude Opus 4.6", contextLength: 200000 },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", contextLength: 200000 },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5", contextLength: 200000 },
{ id: "gpt-5.5-xhigh", name: "GPT-5.5 XHigh", contextLength: 200000 },
{ id: "gpt-5.5-high", name: "GPT-5.5 High", contextLength: 200000 },
{ id: "gpt-5.5-medium", name: "GPT-5.5 Medium", contextLength: 200000 },
{ id: "gpt-5.5-low", name: "GPT-5.5 Low", contextLength: 200000 },
{ id: "gpt-5.4-high", name: "GPT-5.4 High", contextLength: 200000 },
{ id: "gpt-5.4-medium", name: "GPT-5.4 Medium", contextLength: 200000 },
{ id: "gpt-5.4-low", name: "GPT-5.4 Low", contextLength: 200000 },
{ id: "gpt-5.3-codex-high", name: "GPT-5.3 Codex High", contextLength: 200000 },
{ id: "gpt-5.3-codex-medium", name: "GPT-5.3 Codex Medium", contextLength: 200000 },
{ id: "gpt-5.3-codex-low", name: "GPT-5.3 Codex Low", contextLength: 200000 },
{ id: "gpt-5.2-high", name: "GPT-5.2 High", contextLength: 200000 },
{ id: "gpt-5.2-medium", name: "GPT-5.2 Medium", contextLength: 200000 },
{ id: "gpt-5.2-low", name: "GPT-5.2 Low", contextLength: 200000 },
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High", contextLength: 1000000 },
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low", contextLength: 1000000 },
{ id: "gemini-3.0-flash-high", name: "Gemini 3 Flash High", contextLength: 1000000 },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", contextLength: 1000000 },
{ id: "deepseek-v4", name: "DeepSeek V4", contextLength: 1048576 },
{ id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 262144 },
{ id: "glm-5.1", name: "GLM-5.1", contextLength: 204800 },
],
};

View File

@@ -1,34 +0,0 @@
export default {
id: "featherless",
priority: 65,
alias: "featherless",
aliases: [
"fl",
],
uiAlias: "fl",
display: {
name: "Featherless",
icon: "flutter_dash",
color: "#111827",
textIcon: "FL",
website: "https://featherless.ai",
notice: {
apiKeyUrl: "https://featherless.ai/account/api-keys",
},
},
category: "apikey",
authType: "apikey",
transport: {
baseUrl: "https://api.featherless.ai/v1/chat/completions",
validateUrl: "https://api.featherless.ai/v1/models",
},
models: [
{ id: "deepseek-ai/DeepSeek-V4-Pro", name: "DeepSeek V4 Pro" },
{ id: "deepseek-ai/DeepSeek-V4-Flash", name: "DeepSeek V4 Flash" },
{ id: "zai-org/GLM-5.2", name: "GLM 5.2" },
{ id: "zai-org/GLM-5.1", name: "GLM 5.1" },
{ id: "moonshotai/Kimi-K2.7-Code", name: "Kimi K2.7 Code" },
{ id: "moonshotai/Kimi-K2.6", name: "Kimi K2.6" },
{ id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5" },
],
};

Some files were not shown because too many files have changed in this diff Show More