Compare commits
3 Commits
gitea/feat
...
feature/sy
| Author | SHA1 | Date | |
|---|---|---|---|
| 2305e26e25 | |||
| 1fe8115dad | |||
| 98412aa0bb |
4
.commandcode/taste/taste.md
Normal file
4
.commandcode/taste/taste.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# Taste (Continuously Learned by [CommandCode][cmd])
|
||||
|
||||
[cmd]: https://commandcode.ai/
|
||||
|
||||
@@ -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
28
.gitignore
vendored
@@ -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/
|
||||
|
||||
453
CHANGELOG.md
453
CHANGELOG.md
@@ -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)
|
||||
91
CLAUDE.md
91
CLAUDE.md
@@ -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(...)`).
|
||||
@@ -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
327
README.md
@@ -13,12 +13,11 @@
|
||||
[](https://github.com/decolua/9router/pkgs/container/9router)
|
||||
[](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!
|
||||
|
||||
[](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)**  — Rust token-saver. 9Router ports its compression pipeline to JS → **−20-40% input tokens** on every request.
|
||||
- **[Caveman](https://github.com/JuliusBrussee/caveman)**  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)**  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)**  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)**  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!
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
84
cli/cli.js
84
cli/cli.js
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 };
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
111
custom-server.js
111
custom-server.js
@@ -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");
|
||||
|
||||
@@ -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 |
@@ -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.
|
||||
@@ -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.
|
||||
1445
i18n/README.es.md
1445
i18n/README.es.md
File diff suppressed because it is too large
Load Diff
1442
i18n/README.fa_IR.md
1442
i18n/README.fa_IR.md
File diff suppressed because it is too large
Load Diff
1445
i18n/README.fr.md
1445
i18n/README.fr.md
File diff suppressed because it is too large
Load Diff
@@ -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.**
|
||||
|
||||
[](https://www.npmjs.com/package/9router)
|
||||
[](https://www.npmjs.com/package/9router)
|
||||
[](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 ($20–50/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
|
||||
|
||||
[](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>
|
||||
1526
i18n/README.pt-BR.md
1526
i18n/README.pt-BR.md
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
[](https://www.npmjs.com/package/9router)
|
||||
[](https://www.npmjs.com/package/9router)
|
||||
[](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 ดียิ่งขึ้น!
|
||||
|
||||
[](https://github.com/decolua/9router/graphs/contributors)
|
||||
|
||||
---
|
||||
|
||||
## 📄 ลิขสิทธิ์
|
||||
|
||||
MIT License - ดู [LICENSE](../LICENSE) สำหรับรายละเอียด
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub>สร้างด้วย ❤️ สำหรับนักพัฒนาที่เขียนโค้ด 24/7</sub>
|
||||
</div>
|
||||
@@ -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>
|
||||
|
||||
[](https://www.npmjs.com/package/9router)
|
||||
[](https://www.npmjs.com/package/9router)
|
||||
[](https://github.com/decolua/9router/blob/main/LICENSE)
|
||||
[](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ử dụng 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ừ mã 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">
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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*"
|
||||
|
||||
@@ -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()}`
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 || ""));
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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", {
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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
49
open-sse/executors/opencode-go.js
Normal file
49
open-sse/executors/opencode-go.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
129
open-sse/executors/qwen.js
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`;
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(/\/+$/, "");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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": "*",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export {
|
||||
refreshAccessToken,
|
||||
refreshClaudeOAuthToken,
|
||||
refreshGoogleToken,
|
||||
refreshQwenToken,
|
||||
refreshCodexToken,
|
||||
refreshIflowToken,
|
||||
refreshGitHubToken,
|
||||
|
||||
@@ -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] };
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user