257 Commits

Author SHA1 Message Date
6770f6ba0b fix(dashboard): ReferenceError getProviderLabel in RecentRequests
getProviderLabel was a useCallback inside the UsageStats component, but
RecentRequests (a sibling module-level component) called it too, causing
"getProviderLabel is not defined" at runtime.

Extract a module-level resolveProviderLabel() (registry lookup by
id/alias/display prefix) used by RecentRequests; keep the component-scoped
getProviderLabel (which adds connected-provider nodeName/name lookup) for
the main table render paths.
2026-08-17 16:30:26 +07:00
c61dc6de46 fix(dashboard): show provider names instead of node ids in Usage by Provider
The Usage by Provider table (and related provider cells) rendered raw
provider keys, so requests routed through a custom OpenAI-compatible node
appeared as "openai-compatible-chat-096baf9a-6433-4f22-b079-20371092555a"
instead of the node's display name.

- UsageStats: add getProviderLabel() resolving a provider key (built-in id,
  alias, or custom node id) to a friendly name via the providers state
  (nodeName > connection name) and AI_PROVIDERS/getProviderByAlias fallback
- Apply the label in group headers, detail rows, badges, and recent requests
  (raw key kept as hover title)
- UsageTable: accept providerLabel prop, use it for provider group headers
2026-08-17 14:24:13 +07:00
b5c0f10610 feat(settings): restore per-provider connect timeout overrides
Re-apply the settings/UI layer of the per-provider timeout feature that
was dropped during the origin/master merge (core providerTimeout.js +
executor wiring survived; the settings keys and dashboard UI did not):

- settingsRepo: providerTimeouts:{} + defaultTimeoutMs:null defaults
- Profile page: "Default Connect Timeout" card (global fallback, ms)
- Provider detail page: per-provider "Connect Timeout" input, saved to
  providerTimeouts[providerId].timeoutMs, applied via
  resolveProviderTimeoutMs() priority: per-provider > global > registry > env
2026-08-17 09:49:44 +07:00
1256f29d92 Merge remote-tracking branch 'origin/master' into gitea/new_feature
# Conflicts:
#	open-sse/handlers/chatCore.js
#	open-sse/services/combo.js
#	src/app/(dashboard)/dashboard/profile/page.js
#	src/app/api/v1/models/route.js
#	src/lib/db/repos/settingsRepo.js
2026-08-17 00:21:51 +07:00
de9e00c66d feat(settings): runtime log level + free provider enable/disable
- Add LOG_LEVEL env + runtime setLogLevel (dashboard Settings → Logging),
  applied immediately, persisted across restarts; WARN/ERROR quiet production
  INFO lines (▶ POST / 📊 DONE / [COMBO] / [CHAT])
- Allow toggling free/noAuth providers (gemini-cli, kilo, etc.) off via
  providerStrategies.enabled from Providers page and provider detail page
- auth.js: honor disabled override before noAuth/connection branches
- CompatibleModelsSection: parallel model testing
2026-08-17 00:17:55 +07:00
decolua
699edac327 # 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
2026-08-14 17:08:02 +07:00
decolua
540ebbe682 test(baseline): regenerate provider snapshot for opencode-go transports 2026-08-14 16:53:04 +07:00
KiMelody
e1115e2839 feat(opencode-go): route by request format via transports + per-model guard
opencode-go hard-coded targetFormat: claude per model, so every client
format was force-routed to /messages (Codex/OpenAI clients paid a lossy
Responses->OpenAI->Claude double translation). Declare the existing
upstream multi-endpoint transports [openai, claude, openai-responses]
and guard per model via registry supportedFormats: kimi/glm/mimo only
support /chat/completions, minimax/qwen add /messages, deepseek adds
/responses. Undeclared models keep the upstream default.

Drop the bespoke OpenCodeGoExecutor (its shared _lastModel cache could
cross auth headers between concurrent requests); DefaultExecutor already
consumes runtimeTransport and injects reasoning content.
2026-08-14 16:52:37 +07:00
Nguyen Thanh Dat
27f3710c8b fix(docker): ship sql.js so the pure-JS DB fallback can start
Next file tracing follows JS imports, and sql.js loads dist/sql-wasm.wasm by
path at runtime, so the standalone output carries the package's JS without its
wasm binary. When both native drivers fail the last-resort adapter then aborts
with ENOENT on the missing binary and the container never gets a database.

The CLI bundle already guards this explicitly (build-cli.js step 3b,
ensureModuleInBundle("sql.js")); the image just never got the same treatment.
Copy the package the same way node-forge and next already are.

Fixes #3248
2026-08-14 16:40:53 +07:00
Nguyen Thanh Dat
59d858b639 fix(usage): read Gemini usageMetadata out of the antigravity response envelope
Antigravity and gemini-cli wrap their payload in { response: {...} }.
extractUsageFromResponse only tested top-level usageMetadata, so every
non-streaming antigravity request logged zero usage (IN 0 | OUT 0) and
zeroed rows in the usage dashboard. Read the envelope the same way
usageTracking.js and nonStreamingHandler.js already do; top-level
metadata keeps priority and the OpenAI/Claude branches are untouched.

Fixes #3260
2026-08-14 16:34:54 +07:00
Nguyen Thanh Dat
92259214db fix(security): require proof that x-9r-real-ip came from the socket (GHSA-pjm4-8fpg-f9p6)
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).

custom-server.js now generates a per-process secret at boot and stamps it
as x-9r-peer-token on every request it sanitizes. hasTrustedPeerHeaders()
(src/lib/auth/trustedPeer.js) gates trust in x-9r-real-ip on that secret;
otherwise the guard falls back to Host only in development, and fails
closed in production. Same gate on loginLimiter.getClientIp() so a spoofed
header cannot rotate the login lockout bucket.

Also: fix isLoopbackHostname for IPv6 (::1, ::ffff:127.0.0.1) which the
old split(":")[0] reduced to empty string; route npm run start /
start:bun through custom-server.js (postbuild copies it into
.next/standalone, build-cli.js fails without it) so documented deployments
keep passwordless local access.
2026-08-14 16:33:58 +07:00
Nguyen Thanh Dat
b04c03c6b5 feat(providers): add Alibaba Token Plan (token-plan.ap-southeast-1)
Fourth Alibaba key type — Coding Plan (alicode/alicode-intl) and Model Studio
(alims-intl) both reject Token Plan keys. Registry entry only; PROVIDER_MODELS
builds from providers/registry so no executor or translator work is needed.

Singapore-only (eu-central-1 answers IllegalEndpoint) and OpenAI-compatible
transport only (the Anthropic surface is not authorized for this plan).

Closes #2754
Closes #2806
2026-08-14 16:32:54 +07:00
AlexNoVibe
8b2b2fefb5 docs(i18n): add Spanish and French README translations
Add i18n/README.es.md and i18n/README.fr.md mirroring the English
README structure, and link both from the language switcher.
2026-08-14 16:29:43 +07:00
Azriel Akbar Ferry Ardiansyah Kusumawardhana
86694ed8d0 feat(antigravity): add Gemini 3.7 Flash models (#3286, #3281)
Add gemini-3.7-flash and its tiered high/medium/low variants to the
Antigravity and Gemini registries, with matching capabilities, pricing
and Antigravity quota tracking.

extractModel now recognises gemini-3.7-flash-tiered alongside 3.6 and
derives the version from the request, so thinkingLevel still maps to the
right tiered alias.

Closes #3286
Closes #3281
2026-08-14 16:27:10 +07:00
Nguyen Thanh Dat
8af5e752da feat(tts): add Fish Audio as a text-to-speech provider
Registry entry plus one config-driven FORMAT_HANDLERS handler. The model id
travels in an HTTP `model` header rather than the JSON body, and the voice is
a reference_id (preset or cloned voice model).

Closes #2411
2026-08-14 16:21:11 +07:00
zmf
8ed9da7165 feat(providers): add glm-5.3 to GLM Coding and GLM (China) registries
Zhipu released GLM-5.3 on both api.z.ai and open.bigmodel.cn coding
endpoints. Verified live against both, returning model:"glm-5.3" with
native reasoning_content.

No other changes needed: the '*glm-5*' family pattern in capabilities.js
and 'glm-5*' in pricing.js already cover it.
2026-08-14 16:16:46 +07:00
decolua
7e5f5a8813 fix(claude): re-anchor passthrough cache breakpoints with 1h TTL
Passthrough kept the client's own cache_control markers, which point at
pre-normalization offsets. Once normalize/dedupe reshaped system and tools,
the breakpoints landed mid-array and the tail was re-cached every request.

- Pin the last system block and last tool at ttl 1h (was the client's 5m)
- Anchor the last assistant turn at 5m, falling back to the final message
  so a first turn still gets a breakpoint
- Fold mid-conversation system messages into the neighbouring user turn
  instead of hoisting them into body.system, where the volatile token
  counters invalidated the prefix on every request
- Run the anchoring after every token saver, at the final body

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 16:08:30 +07:00
Bertho Joris
345cdcf6a5 fix(combo): detect images from Hermes and attachment payloads for Vision Adapter
Inspect images[], experimental_attachments/attachments, message-level
image/image_url/audio_url, and inline data:image|audio|pdf URIs on trailing
user turns so Vision Adapter auto-switch fires for Hermes/Ollama/Vercel AI
SDK shapes. stripOpenAI now also drops msg.images and image attachments when
the active model lacks vision support.
2026-08-13 18:30:44 +07:00
Duc Nguyen
65197ad11c feat(auth): add native SAML 2.0 SSO integration
Add SAML 2.0 as a second SSO protocol alongside OIDC under a unified
authMode/ssoType model. SP flows via @node-saml/node-saml: AuthnRequest
generation, ACS POST assertion handling, SP metadata export, and admin
config test endpoint. Replay-protected via saml_state cookie (httpOnly,
SameSite=Lax) matched against InResponseTo; wantAssertionsSigned enforced.

- src/lib/auth/saml.js: SAML instance builder, X.509 cert formatter, claim pickers
- 4 routes under src/app/api/auth/saml/: start, acs, metadata, test
- settingsRepo: ssoType + saml* defaults; login/status routes dispatch by type
- profile page: SSO protocol switcher, IdP metadata XML + cert uploaders
- login page: dynamic SAML sign-in button; Header: SAML user badge
2026-08-13 17:56:34 +07:00
Fadjrir Herlambang
e02bde4a70 feat(providers): add Kimchi API key support (dual OAuth + API key)
Kimchi's transport is OpenAI-compatible (Authorization: Bearer) but the
registry declared it OAuth-only, so the dashboard, /api/providers, and
the connection test all rejected API keys. Enable dual auth
(authModes: ["oauth", "apikey"]) and add a kimchi case to
testApiKeyConnection so the Test Connection button works for both modes.
Regenerate the golden snapshot with the Kimchi entries (+ other
previously-missing providers).
2026-08-13 17:53:44 +07:00
haumanto
30fec4318e fix(models): expose snake_case token limits on /v1/models 2026-08-13 12:19:06 +07:00
CyrixJD115
67271d859e fix(opencode): send official client headers on free-tier requests
Mirror the official opencode CLI fingerprint (User-Agent, x-opencode-session, x-opencode-request, x-opencode-project) on free-tier requests so the Console no longer classifies traffic as an unidentified client and rate-limits it with FreeUsageLimitError / HTTP 429.

Session id resolves conversation-stable via resolveSessionId (client session to assistant-text hash to connection) to preserve prompt caching, normalized into opencode ses_ format with a generated fallback. When the downstream client is already opencode, its headers are forwarded as-is.
2026-08-13 12:13:51 +07:00
stoXmod
b566b20ade fix(antigravity): strip competitive system prompts to prevent 429 quota errors
Zed IDE injects a Claude-agent system prompt that Antigravity flags as
competitive, blocking the request with a 429 Quota Exhausted response.
Scan systemInstruction.parts and remove the prompt before dispatch.
2026-08-13 12:11:30 +07:00
Clayton Tavares
6d30ce6de5 fix: Fusion strip stream_options + reasoning model test probe
- combos: strip stream_options from Fusion panel fan-out to avoid DeepSeek 400 (#3024)
- dashboard: raise model-test probe budget to 1024 + soft-pass reasoning-only responses (#3010)
2026-08-13 11:56:43 +07:00
rm1dev
5b417f9bf2 fix(kiro): intercept chat via x-amz-target and prepend initial-response frame
Kiro IDE 1.0.228+ moved GenerateAssistantResponse from path
/generateAssistantResponse to POST / + x-amz-target header, so chat turns
bypassed MITM. The SmithyMessageDecoderStream also now requires an
initial-response frame at stream start, and agent/vibe mode sends
modelId "auto" which had no mappable slot.

- Add isChatRequest() header-based match for kiro in mitm/config.js
- Add buildInitialResponseFrame/withInitialFrame to emit the mandatory
  initial-response once per stream (kiro.js)
- Add "auto" model slot and update mitmDomain to runtime.us-east-1.kiro.dev
2026-08-13 11:56:31 +07:00
Cokky Turnip
b57c041345 fix(providers): add llm7 to provider test support 2026-08-13 11:56:06 +07:00
zmf
8a527fec91 fix(security): SSRF guard on search baseUrl, default-password remote login, and request-details redaction
- resolveBaseUrl() rejects client-supplied non-public baseUrls via assertPublicUrl (SSRF guard on /v1/search)
- fresh-install remote login with default password returns 403 without issuing a JWT
- /api/usage/request-details redacts request/providerRequest/providerResponse/response payloads
- declare chalk and prop-types in package.json (used but previously undeclared)
2026-08-13 11:50:25 +07:00
Nguyen Thanh Dat
70ba0024b0 fix(translator): preserve prompt_cache_key when converting chat to responses 2026-08-13 11:46:49 +07:00
brimob-sowax
80afb59907 fix(qoder): detect billing blocks at stream start, return 403 for failover
Peek the first SSE frame in wrapQoderSSE; if statusCodeValue != 200 and the
body carries a billing signature (code 112/10605 or pricingUrl), return a
synthetic 403 so chatCore marks the connection unavailable and triggers
combo/account fallback instead of leaking the error text into chat.

wrapQoderSSE becomes async; consumed peek bytes are re-processed in the
stream start() seed loop so nothing is dropped.
2026-08-13 11:43:11 +07:00
chisewaguri
10a923da11 fix(responses): don't close message on empty tool_calls array
Some providers (e.g. codebuddy/cbcn) attach an empty tool_calls array to every streaming chunk. An empty array is truthy in JS, so the guard 'if (delta.tool_calls)' closed the message on the first content token and emitted response.output_text.done early, dropping the remaining deltas. Guard on a non-empty array; finish_reason still closes the message and real tool calls still close it before emitting function_call items.

fixes #3234
2026-08-13 11:40:45 +07:00
yusei21
01858feca0 docs(i18n): add Brazilian Portuguese documentation 2026-08-13 11:40:26 +07:00
Moein Arabi
e2a4fe048f fix(hermes): add api_key parameter to model block in YAML configuration 2026-08-13 11:35:02 +07:00
nguyenha935
b44bb09f72 fix(kiro): report real output tokens and stop discarding usable turns 2026-08-13 11:33:41 +07:00
decolua
456f2a2635 feat(usage): wire force flag through client + usage route
Manual refresh (↻) sends ?force=1 so it bypasses the Claude quota cache (dedup + TTL) added in cd4003bc. Auto-refresh and multi-tab stays cached, so Anthropic's usage endpoint is no longer hammered.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-13 11:31:07 +07:00
decolua
cd4003bc8b feat(usage): dedup + cache Claude quota calls to avoid 429
Multiple tabs/accounts/auto-refresh funneled straight to Anthropic and tripped 429. Add a 120s TTL cache keyed by access token with in-flight promise dedup, serve the last good read on soft failure, and thread a force flag through getUsageForProvider for manual refresh. Also lower the dashboard poll cadence (180s to 600s) and stable group-by-provider so connection order stops jumping.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-13 11:27:57 +07:00
decolua
71dcdc1053 fix(headroom): toggle reflects enabled setting even when proxy is down
Toggle was checked={headroomEnabled && headroomRunning} and disabled when the proxy was down, so a downed proxy showed OFF while headroomEnabled stayed true in the DB. The engine only checks headroomEnabled, so it kept calling /v1/compress. Toggle now reflects the user setting; proxy up/down stays visible via the status chip.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-13 11:27:49 +07:00
a3182a7265 merge: integrate origin/master (v0.5.50) into gitea/new_feature
- Resolve conflicts in chatCore handlers: keep apiKey/streamErrorPatterns
  from the details-filters feature, adopt origin's stripContinuityFields,
  customToolNames, cache-inclusive usage accounting, and Responses-API
  SSE→JSON conversion
- Adopt origin's provider usage handlers (codebuddy-intl, qoder creds)
  and modality detection (audio/video inputs)
- Keep requestDetails apiKey column (schema v2) + masked key persistence

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-08-06 09:54:31 +07:00
386b25ff7f feat(dashboard): add model/status/account/api-key filters to usage details tab
- Add apiKey column to requestDetails (schema v2 + migration 002)
- Persist masked API key via buildRequestDetail across chatCore handlers
- Add getRequestDetails apiKey filter + distinct models/apiKeys/statuses helpers
- New /api/usage/filters endpoint returning grouped connections per provider
- Group account dropdown by provider using <optgroup>; add Status column
  with success/error badge to the details table

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-08-06 09:41:03 +07:00
decolua
15223724c3 # 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
  endpoint that drops packets never returns headers, so the request previously
  hung indefinitely
2026-08-05 16:49:14 +07:00
decolua
35f86e5828 fix(oauth): scope antigravity header fixes to loadCodeAssist/onboardUser only
Google fingerprints User-Agent/Client-Metadata on loadCodeAssist and
onboardUser, silently refusing to provision a cloudaicompanionProject
when they don't match the real IDE. Split antigravity's headers out of
the shared gemini-cli constants instead of overwriting them, so the fix
doesn't touch gemini-cli or any other provider.

Inspired by #3000 (thanks @stoXmod for flagging the resource-exhausted
issue), rewritten to keep gemini-cli untouched.
2026-08-05 16:40:09 +07:00
Dasep Moch Luay
41588bea01 feat(providers): add TokenRouter accurate pricing + thinking config
Adds exact per-model rates for 110 TokenRouter models (pulled from
TokenRouter's own pricing API) plus a dedicated thinkingFormat case
(reasoning_effort enum low/medium/high/xhigh/max) and the provider
logo. Provider registration itself already landed in a prior commit;
this fills in what PR #3043 added on top.
2026-08-05 16:31:03 +07:00
decolua
03f8487cc7 test(baseline): regenerate provider/alias snapshots
Sync alias-baseline.json and providers-baseline.json with the current
registry (poolside, tokenrouter, selfhosted-* providers already added;
stale claudeOverlay hook and Kiro X-Amz-Target header already removed).
2026-08-05 16:27:17 +07:00
decolua
99639c0540 test(capacity-adapter): remove unit test file 2026-08-05 16:25:47 +07:00
decolua
e41d85037d test(capacity-adapter): add unit coverage for the capacity adapter service
Covers pool flattening, model augmentation for required capabilities,
context-window history stripping, and the withCapacityAdapterStripping
wrapper.
2026-08-05 16:25:12 +07:00
decolua
02c66fe2bd feat(endpoint): auto-provision default API key for first-time users
- Endpoint page auto-creates a "Default Key" when no keys exist yet,
  so /v1 works out of the box without a manual dashboard step
- Show/copy key buttons stay visible instead of opacity-0 by default
2026-08-05 16:22:06 +07:00
decolua
dcdd4628b3 fix(providers): remove Qwen provider support
Qwen OAuth flow (portal.qwen.ai) stopped working reliably; drop the
executor, registry entry, OAuth provider/service, token refresh
profile, usage handler, and related test coverage and baselines.
2026-08-05 16:17:26 +07:00
decolua
6498b3122f feat(combos): wire capacity adapter into chat handler routing
- detectRequiredCapabilities: infer audioInput/videoInput from block
  type and embedded mime, not just vision/pdf
- handleChat / handleSingleModelChat: augment combo and single-model
  routing with capacity-adapter models when the target lacks a
  required capability, wrapped with history stripping for the
  adapter model's context window
2026-08-05 16:12:55 +07:00
decolua
8e59093db7 feat(combos): default-enable vision/audio adapter with mimo fallback
- Enable vision + audioInput capacity-adapter pools by default for new
  and existing users (mergeWithDefaults backward-compat)
- Fall back to oc/mimo-v2.5-free when an enabled pool has no models
  configured, both in the backend resolver and the combos UI (auto
  refill on removing the last model from a pool)
- Hide PDF/Video from the Vision Adapter UI (PDF never implemented,
  Video lacks translator support) while keeping the settings shape
- Exclude combos from the model picker when opened from the Vision
  Adapter section
- mimo-v2.5 registry entry now declares audioInput/videoInput
- Simplify combo strategy and Vision Adapter descriptions
2026-08-05 16:09:42 +07:00
decolua
cd13d904d7 fix(passthrough): detect codex-tui/Codex Desktop as native Codex client
detectClientTool only matched the legacy "codex-cli" User-Agent, so the
current codex-tui CLI and Codex Desktop (UA "Codex Desktop", originator
"codex_work_desktop") fell through to null and lost native passthrough —
their requests got re-translated, stripping/overwriting fields like
reasoning.summary instead of forwarding the client body as-is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 15:53:20 +07:00
omar-nahhas
fe547f4dc0 feat(providers): self-hosted OpenAI-compatible STT, TTS and embedding providers
Add Self-hosted STT/TTS/Embedding providers that read baseUrl per connection
instead of a fixed registry endpoint, so 9Router can point at whisper.cpp,
faster-whisper, Kokoro-FastAPI, llama-server, vLLM, Infinity, and similar
OpenAI-compatible local servers.

Self-hosted Embedding refuses to run without a baseUrl rather than falling
back to api.openai.com like openaiCompatNode does, since that fallback would
silently send input text and the API key to OpenAI under a provider named
"Self-hosted". Also fixes embeddingsCore to catch adapter build errors as a
400 instead of letting them escape uncaught, and bounds the upstream fetch
with FETCH_CONNECT_TIMEOUT_MS to avoid hanging forever on a dead endpoint.

Self-hosted TTS treats a bare model value as the model rather than the voice,
since the generic OpenAI TTS convention (bare = voice) is backwards for a
provider where the model is the variable part.
2026-08-05 13:38:13 +07:00
Ubuntu
b480892952 feat(providers): add TokenRouter provider
OpenAI-compatible gateway exposing 300+ models (OpenAI, Claude, Gemini,
Qwen, DeepSeek, Kimi, GLM, and more). Registered as p116, append-only.
2026-08-05 13:32:46 +07:00
RobertsXML
3fab15ae3e fix(db): implement ENABLE_REQUEST_LOGS env var override
- Add config priority chain: ENABLE_REQUEST_LOGS > UI setting > OBSERVABILITY_ENABLED fallback
- Fix transaction callback syntax from arrow to function
- Update saveRequestDetail guard to early return instead of semicolon
- Default enableObservability to false (opt-in)
2026-08-05 13:30:28 +07:00
nguyenha935
d06e0d26c6 fix(translator): preserve Responses Lite tools across Chat providers
Codex Responses Lite clients routed to a chat-native OpenAI-compatible
provider lost tool use in three places: non-streaming Chat responses
leaked the raw chat.completion envelope instead of Responses output
items, internal reasoning continuity fields leaked into the outbound
Chat body causing some upstreams to reject the request, and the
Responses to Chat request translator ignored additional_tools,
custom_tool_call, and custom_tool_call_output items entirely.

Also fixes apiType (chat vs responses) for openai-compatible nodes
being resolved from the immutable provider ID instead of the stored
node config, so editing a node's API Type had no runtime effect.
2026-08-05 13:27:25 +07:00
decolua
b11be8be0a fix(antigravity): drop retired Gemini 3.0 tiers from quota tracker
gemini-3-flash-agent, gemini-3-flash, and gemini-3-pro-image are retired;
they no longer need a quota bar in the tracker.
2026-08-05 13:24:57 +07:00
whale9820
42c691b3ea feat(antigravity): show Gemini 3.6 Flash usage bars in quota tracker
getAntigravityUsage filtered the fetchAvailableModels response through a
hardcoded importantModels list that only contained 3.5 Flash, silently
dropping the 3.6 Flash quota buckets so no usage bar rendered.
2026-08-05 13:21:38 +07:00
cardinusantara
a7941ddab4 fix(translator): don't drop image-only user messages in prepareClaudeRequest
hasValidContent() only treated text/tool_use/tool_result blocks as valid
content, so a user message containing only an image block was filtered
out as empty. When it was the only non-system message, this left an
empty messages array and Anthropic rejected the request.
2026-08-05 13:20:27 +07:00
Sutarto Jordan Chrisfivo
646b3b9ba3 fix(cloudflare-ai): declare API key authentication
Cloudflare AI registry entry was missing authType/authModes, causing
the dashboard to report "No connections" despite an active API-key
connection. Closes #2969
2026-08-05 13:13:54 +07:00
huuanh20
baebc9a06e docs(i18n): fix port typo and add RTK Token Saver features
Fix port 201281 -> 20128/v1 typo in README.zh-CN.md diagram, add
RTK Token Saver mention to README.vi.md/README.zh-CN.md, update
Tier 3 free providers to 2026 lineup, and drop hardcoded /tmp
node_modules path from tests/package.json and tests/README.md.
2026-08-05 11:55:02 +07:00
Matt Van Horn
25e4bf1c6c fix(cli): include complete API artifacts in CLI package
Merge the complete generated .next-cli-build/server tree into the
packaged CLI after the standalone copy, since Next's standalone output
is trace-pruned and can omit route modules (e.g. /api/v1/messages) or
chunks loaded dynamically. Add a post-copy integrity check for the
required API route artifacts so an incomplete package fails during
pack:cli instead of at runtime.

Fixes #2945
2026-08-05 11:54:16 +07:00
MiQieR
c570fe33ae feat(tts): add Xiaomi MiMo text-to-speech support
Adds mimo-v2.5-tts as a Media Provider TTS through the existing
OpenAI-compatible chat-completions endpoint. Voice is selected via the
top-level audio.voice field, and an optional style/language hint is
threaded through tts.js -> ttsCore.js -> the new adapter.
2026-08-05 11:46:23 +07:00
ryanngit
d0751bcff7 fix(grok-cli): display public subscription tier
Map the Grok OAuth access-token tier claim to the public plan label
and prefer it over internal /user entitlement names. Fails open to
existing plan detection for opaque or malformed tokens; upstream
remains authoritative for access and quota enforcement.
2026-08-05 11:43:40 +07:00
alfep
948dd8f89b fix(oauth): declare searchParams in register-session POST handler
Missing declaration caused a ReferenceError -> 500 HTML response instead
of JSON when clients called POST .../register-session.
2026-08-05 11:41:10 +07:00
seakleang.nhak
86131b9ca4 feat(codex): support GPT-5.6 Max and Ultra overrides
Add "ultra" reasoning level for Codex GPT-5.6 Sol and Terra, and expose
Max for Luna (Luna falls back Ultra to Max since it is not supported
upstream). Scoped to cx/ routes only; Kiro and generic OpenAI routing
unchanged.
2026-08-05 11:39:59 +07:00
Diwak4r
651df2f0e2 feat(cli-tools): add OpenDesign (manalkaff/opendesign) support
Adds a guide-type CLI Tools entry for OpenDesign, the open-sourced
claude.ai/design skills pack. It has no standalone config - it inherits
the host agent's model/provider config - so once the host (Claude Code,
Cursor, Codex, Gemini CLI, OpenCode) points at 9Router, /opendesign
sessions route through automatically.
2026-08-05 11:36:37 +07:00
minhnhat166
da8691f866 feat(headroom): report effective payload savings
Break out tool schema and tool-history bytes in the size snapshot and
add an effective byte-savings percentage so token-saved logs reflect
the actual outbound payload reduction, not just processed content.
2026-08-05 11:32:38 +07:00
decolua
13ed14568d fix(claude): remove global header cache, gate anthropic-beta by model
The global claudeHeaderCache singleton overlaid the last-seen Claude Code
client's identity headers onto every subsequent request, leaking one
client's headers (anthropic-beta, user-agent, x-stainless-*, etc.) onto
another client/account sharing the same server. Removed the singleton and
the claudeOverlay hook entirely, falling back to static per-provider
headers. anthropic-beta is now computed per-request from the requested
model, gating heavy-agent flags (advanced-tool-use, effort) to
opus/sonnet only.
2026-08-05 11:32:14 +07:00
decolua
1eb37db32d refactor(qoder): dedupe PAT exchange logic, validate PAT keys properly
PAT-to-job-token exchange was duplicated between the executor and the model service, each with its own cache. Consolidate into qoderModels.js and have the executor import it.

Also add a qoder case to the API-key validate route - the generic OpenAI-compat probe cannot validate a PAT (needs job-token exchange + COSY signing first), so bulk-add always reported unknown for qoder keys.
2026-08-05 11:26:22 +07:00
mannnrachman
d433c0b295 feat(qoder): support PAT (Personal Access Token) connections end-to-end
Adds pt-... token auth as an alternative to OAuth device flow. A PAT can't
sign COSY requests directly, so it's exchanged for a short-lived job token
(jt-...) plus userId via openapi.qoder.sh, then used for signing.

Also fixes job-token traffic (jt-...) being rejected by api3.qoder.sh with
403 "Login expired" — the official qodercli serves jt- traffic from
api2.qoder.sh instead, so buildUrl/model-list routing now branches on it.

Quota usage and the dashboard add-key modal are updated to resolve PAT
credentials and label the field correctly, and bulk-add now validates
each key so it gets a real testStatus instead of a hardcoded "unknown".
2026-08-05 11:16:50 +07:00
ryanngit
3292dfc102 fix(github): hold monthly-exhausted accounts until reset
Lock GitHub Copilot connections account-wide until 00:00 UTC on the
first of next month when the upstream 402 response indicates the
monthly additional-usage-limit was hit, instead of only cooling down
the requested model for 120s. Other GitHub 402 responses keep the
existing model-scoped cooldown.
2026-08-05 11:00:40 +07:00
Rafi Mahardika
9138c99391 fix(codebuddy): dodge Tencent filter for CN, add usage tracking & normalize messages for INT
Neutralize CLI-agent system prompts that trigger CodeBuddy CN's content filter, add usage/quota tracking for codebuddy-intl sharing CN's logic, and normalize codebuddy-intl request messages to the shape it expects.
2026-08-05 10:52:03 +07:00
omar-nahhas
41606a37a3 fix(usage): don't lose cached tokens in the forced-SSE->JSON path
handleForcedSSEToJson dropped cached prompt tokens in two ways: the
Responses branch summed only input_tokens, which excludes cache_read
and cache_creation on cache-capable upstreams (measured 2012 reported
vs ~5344 actual, 5332 from cache); and the Chat Completions branch
computed usage correctly but it didn't always reach the client (an
Anthropic response with cache_read_input_tokens: 11022 arrived with no
usage field at all). Now folds cache counters into prompt_tokens,
surfaces them via prompt_tokens_details, and re-attaches usage before
serialisation.
2026-08-05 10:45:55 +07:00
Muhammad Usama
2abe8b855c fix(translator): drop JSON Schema keywords Gemini has no field for
Tool schemas carrying uniqueItems, contains, multipleOf,
unevaluatedProperties, unevaluatedItems, or contentSchema get rejected
by the Gemini API with "Unknown name ...: Cannot find field", failing
the whole request. Add them to UNSUPPORTED_SCHEMA_CONSTRAINTS alongside
the existing stripped keywords (minItems, maxItems, format, ...).
2026-08-05 10:42:54 +07:00
Tomauskasz
c06cc08453 fix(oauth): keep open external so xAI/Grok token refresh works on Windows
`open` derives its own directory from import.meta.url at module scope.
Webpack replaces that with the build machine's absolute path as a
string literal, so a release built on macOS ships a file:///Users/...
URL that fileURLToPath rejects on Windows (no drive letter), throwing
on import. refreshXaiToken dynamic-imports the xAI OAuth service, which
imports open eagerly, so every Grok token refresh silently failed and
was swallowed by a catch that only logs a warning.

Add open to serverExternalPackages so it keeps its real import.meta.url
at runtime, and bundle it into the CLI package via ensureModuleInBundle
(same guard already used for sql.js) since externalizing it means
webpack no longer traces/copies it automatically.
2026-08-05 10:38:25 +07:00
Cokky Turnip
d6df6576c5 fix(providers): count apikey connections for ollama freeTier provider
ollama's registry entry lacked authModes, so dualAuthTypes on the providers page defaulted to oauth and its apikey connections showed as No connections on the freeTier card.
2026-08-05 10:34:44 +07:00
DaDecky
786b3013ba fix(build): include assets in standalone output
With output: "standalone", next build writes server.js under
.next/standalone but leaves generated static/public assets in the
project root, so starting the standalone server directly (e.g. via PM2)
404s on JS/CSS/font/favicon requests and /login stays stuck loading.
Add a postbuild step that copies .next/static and public into the
standalone directory, skipping the workspace-traced CLI build which
already copies its own assets.
2026-08-05 10:32:55 +07:00
dajinglingpake
0648e9e420 fix(server): support IntelliJ IDEA OpenAI clients over HTTP
JetBrains Runtime (JBR 25+) sends an h2c upgrade on OpenAI-compatible requests, which the HTTP/1.1 server would otherwise close. Intercept the upgrade, replay the buffered request through the existing handler, and respond over HTTP/1.1.
2026-08-05 10:32:32 +07:00
DaDecky
ae4f76c433 fix(auth): redirect active sessions from /login
/api/auth/status did not expose whether the auth cookie corresponds to a
valid dashboard session, so /login could only detect "auth disabled"
(requireLogin === false) and not "already logged in". Add authenticated
to the status response and redirect from /login when it's true.
2026-08-05 10:27:27 +07:00
lazysaltyfish
918b3c87a1 fix(cli-tools): enable Apply button for dynamic OpenAI/Anthropic-compatible providers
getAllAvailableModels() only consulted the static PROVIDER_MODELS catalog,
which has no entry for dynamically-registered compatible providers
(id like openai-compatible-chat-uuid). Fall back to the connection's
own defaultModel/customModels/placeholder, mirroring ModelSelectModal.js.
2026-08-05 10:23:59 +07:00
techysy
0e5da70cb1 fix: freeTier/apikey providers without authModes default to apikey in dualAuthTypes
Free-tier and apikey providers (e.g. cloudflare-ai, byteplus, ollama, vertex) whose registry entry omits authModes were treated as oauth-only, hiding their apikey connections on the providers grid card.
2026-08-05 10:22:57 +07:00
2a37a4085e chore: normalize formatting (2-space → tabs) in stream-error-patterns files
Re-tab only — no logic changes. Follows the repo's tab-based formatting
for these files, matching the CommandCode executor/translator style.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-08-05 09:03:41 +07:00
e2f8323ab1 docs(open-sse): in-stream error handling recipe + CHANGELOG 2026-08-04 23:35:57 +07:00
9bd7adc556 feat(dashboard): Stream Error Patterns editor on provider page
Per-provider textarea (one pattern per line: plain text or /regex/flags)
saved via /api/settings as streamErrorPatterns. Mirrors the existing
providerTimeouts load/save pattern.
2026-08-04 23:35:25 +07:00
34a78f579e feat(open-sse): mark streaming requestDetail as error on stream-error pattern match 2026-08-04 23:34:21 +07:00
c8b96a61e7 feat(open-sse): non-streaming stream-error pattern match → 502 fallback 2026-08-04 23:34:00 +07:00
e438a03f96 feat(open-sse): early-peek stream error detection + fix UTF-8 loss in CommandCode peek
- new open-sse/utils/streamErrorPeek.js: bounded peek of the first bytes of a
  200 stream; configured pattern match → 502 so account/combo fallback can run
  before any byte reaches the client (streaming included). Re-emits RAW bytes
  so split multi-byte UTF-8 sequences survive the peek (never re-encode
  decoded text — TextDecoder flush corrupts a lone leading byte to U+FFFD).
- chatCore: run the peek after executor.execute when the provider has
  streamErrorPatterns configured; chat.js passes the settings through.
- commandcode executor: same raw-bytes fix in peekForUpstreamError + regression
  test that fails against the old flush-based re-encode.
2026-08-04 23:33:12 +07:00
008e0ef311 feat(settings): default streamErrorPatterns key 2026-08-04 23:28:17 +07:00
5058a402f1 feat(open-sse): streamErrorPatterns matching util (text + regex) 2026-08-04 23:27:49 +07:00
9b27ee2611 fix(open-sse): treat CommandCode in-stream error events as request failures
Upstream emits AI SDK v5 {"type":"error"} events inside an HTTP 200 stream.
The translator turned them into fake success content ([CommandCode error: ...]
+ finish_reason stop), so account/model fallback never fired and logs showed
Status: success.

- translator: error events now emit an OpenAI-shaped error chunk (chunk.error)
  instead of content; parseSSEToOpenAIResponse already detects chunk?.error
- executor: peek the first events before committing the response; an early
  error event returns 502 so fallback runs before any byte reaches the client
2026-08-04 23:26:37 +07:00
c5ce1ef140 feat(commandcode): quota usage dashboard, CLI-parity request, and connect timeout fixes
- Add CommandCode usage handler mirroring the official CLI /usage
  (whoami → credits/subscriptions → summary) with 5h/weekly/monthly
  quota rows, registered in services/usage.js and registry config
- Parse commandcode quota rows in ProviderLimits (remainingPercentage + $ unit)
- Match official CLI request shape: x-command-code-version 1.10.0,
  User-Agent cli, and config.environment '${platform}-${arch}, Node.js ${version}'
- Fix connect timeout unit confusion: both profile and provider pages
  now use ms with a 1s minimum guard (prevents 60ms footgun)
- Fix fetchT0 ReferenceError in base.js error path and log fetch
  diagnostics only on upstream failure
- Quota Tracker defaults to the Active account filter
- Ignore .commandcode/ CLI local state
2026-08-04 22:34:13 +07:00
fcd3dcb409 feat(translator): add vision support for commandcode provider
Map OpenAI image_url / Claude-style image blocks to the {type:"image",
image:"<data URI|url>"} shape the command-code CLI sends to /alpha/generate
instead of dropping them to "[image omitted]". Handles data URIs, raw base64
(with media_type / image/png fallback), and remote URLs.

- Promote bugs-gemini-cursor-commandcode "image content is preserved" from
  it.fails to a real assertion (bug fixed)
- Add vision unit tests to openai-to-commandcode.test.js
- Add test-commandcode-vision.sh curl helper for live verification

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-08-03 17:06:32 +07:00
067f18aaa1 fix(usage): ReferenceError in byProvider lastUsed overlay broke daily-summary periods
The provider lastUsed overlay loop referenced histRows before its const
declaration (temporal dead zone), throwing ReferenceError for 7d/30d/60d/all
which use the usageDaily summary path — leaving the overview cards and tables
empty regardless of the selected period. Merged the provider overlay into the
existing histRows loop.
2026-08-02 22:48:15 +07:00
B1nh M1nh
f260a1817b feat: Ollama Cloud quota tracker + proactive background OAuth refresh
Ollama: replace informational stub with real quota tracker hitting ollama.com/api/usage (session 5h + weekly 7d, 0..1 ratio) and /api/me plan label; bind handler to apiKey + add features.usageApikey so apikey connections work.

Token refresh: add backgroundTokenRefresh scheduler that refreshes OAuth connections within max(provider lead, 30min) of expiry, independent of inbound traffic (10s after boot, then every 5min, unref'd timers, DISABLE_BACKGROUND_TOKEN_REFRESH kill-switch, fail-open per tick/connection). Registered from custom-server.js (listening) and initializeApp.js. checkAndRefreshToken gains opt-in {force} for the scheduler; request path unchanged.
2026-08-02 09:34:27 +07:00
88faba150a feat(dashboard): model test-all with connection selector, usage by provider, combo enable toggle and showOnlyComboModels setting
- provider detail: Test All Models button runs every model (built-in + custom)
  with an optional connection selector; failed models can be disabled in bulk
- bulk selected-model test now runs all connections in parallel (Promise.all)
- usage overview: new 'Usage by Provider' table view (default) and a Provider
  column in Recent Requests; byProvider now tracks lastUsed
- combos: per-combo enable/disable toggle; disabled combos are skipped by the
  routing engine (getComboModels/getComboModelsFromData) and model listing
- settings: 'Only show combo models' toggle filters ModelSelectModal and the
  /v1/models response to models present in enabled combos
2026-07-31 10:37:45 +07:00
0dbae80930 Merge branch 'master' into gitea/new_feature 2026-07-30 23:14:05 +07:00
decolua
6fcd27337a # v0.5.45 (2026-07-30)
## Features
- **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
2026-07-30 09:43:55 +07:00
decolua
9be6588cc8 chore: drop source-attribution comments from provider code
Remove "Ported from OmniRoute" and cockpit-tools attribution comments.
User-Agent strings and README/landing credits are left intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:05:23 +07:00
decolua
1319dea620 fix(providers): count apikey connections for freeTier providers
openrouter, nvidia, gemini lack authModes, so dualAuthTypes on the
providers page defaulted to "oauth" and their apikey connections showed
as "No connections" on the freeTier card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 20:21:27 +07:00
whale9820
31df0635aa feat(providers): add Poolside provider (OpenAI-compatible)
Adds Poolside (inference.poolside.ai) as an API-key provider using the default OpenAI transport. Registers three Laguna models with reasoning capabilities (262K context, 32K max output).
2026-07-29 20:17:26 +07:00
decolua
baf3356583 fix(ui): count free-tier oauth connections on providers list
Free-tier cards (e.g. kimchi, oauth-only) hardcoded "apikey" for stats and
toggle, so oauth connections were invisible on /dashboard/providers despite
showing on the detail page. Use dualAuthTypes per provider instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 20:06:20 +07:00
ridwan kulu
24fd165b0d docs(readme): add Indonesian translation
Add an Indonesian README and link it from the root README language switcher.
2026-07-29 19:38:30 +07:00
Sutarto Jordan Chrisfivo
a8313cd322 feat(kiro): add Claude Opus 5 models
Register Opus 5 and its thinking/agentic variants with 1M context
and adaptive-thinking capabilities.
2026-07-29 19:34:14 +07:00
Fábio A.
f8e8039446 i18n(pt-BR): expand partial translation to 986 terms
Add ~793 new pt-BR UI strings for the dashboard and settings.
2026-07-29 19:31:16 +07:00
Cokky Turnip
e3e3e235f6 fix(gemini): fill empty tool schemas after $ref strip
Vertex rejects orphan {} left when $ref/$defs are removed from function declarations. Promote empty nodes to object+reason placeholder in addPlaceholders.
2026-07-29 19:31:15 +07:00
Nurwanda Romadhon
0afe949387 fix(antigravity): strip stream_options from non-stream requests
OpenAI clients may send stream_options with stream=false; Google
generateContent rejects that combination. Drop it when not streaming.
2026-07-29 19:30:44 +07:00
Kyle Welsworth
5e59790824 fix(cursor): stop leaking agent tool errors as text
Emit SSE error frame for unsupported Cursor AgentService IDE tools
instead of assistant content, and drop frames after the turn finishes
to avoid double-closing the stream controller.
2026-07-29 19:29:27 +07:00
nguyenha935
16cb40fda1 fix(kiro): canonicalize tool history and route API keys correctly
Route API-key inference through Amazon Q first, enforce adjacent
one-to-one tool use/result pairs after session replay, and treat
payload-invalid HTTP 400 as terminal.
2026-07-29 19:27:41 +07:00
decolua
44c7b34837 fix(ui): count dual-auth provider cards correctly
Share oauth+apikey/api_key stats for dual-auth providers (incl. kiro)
so card totals match the detail page.
2026-07-29 19:27:31 +07:00
decolua
15dfd86416 fix(ui): flex quota rows and thin global scrollbars
Replace the fixed-table quota layout with flex rows that shrink cleanly,
keep the hidden-quota chip row from overflowing, and use thin mac-like
scrollbars app-wide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:10:33 +07:00
decolua
8b0fcf4b16 feat(cli-tools): allow setting Claude Code max context tokens
Add a context-window selector on ClaudeToolCard that writes
CLAUDE_CODE_MAX_CONTEXT_TOKENS into settings.json (nudged 2K under the
labeled cap), and clear it on reset/default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:10:31 +07:00
decolua
6d96e24bd9 chore(providers): refresh catalogs, free tiers, and hide stale ones
Update model lists and context lengths across free/apikey providers,
move bazaarlink, kilo-gateway, and kimchi into freeTier, demote llm7 to
apikey, and hide bluesminds, sambanova, zed, and mimo-free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:10:09 +07:00
decolua
3b14bf4a49 feat(devin-cli): bridge client tools via MCP and use full agent
Default to the full agent with built-in tools, expose client function
tools as an MCP server, surface tool calls as OpenAI tool_use, resolve
workspace cwd from the request, and bump context windows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:10:03 +07:00
decolua
9c9dd7b191 feat(qoder): support PAT auth and refresh model list
Exchange Personal Access Tokens for short-lived job tokens, close the
SSE stream on terminal frames so non-streaming clients do not hang,
re-enable OAuth plus API-key auth modes, and replace the model catalog
with the current Qoder aliases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:09:57 +07:00
decolua
f17a68aaee feat(usage): fetch SuperGrok weekly pool via gRPC-web
Decode GetGrokCreditsConfig frames when REST billing returns empty
caps, so SuperGrok weekly quota shows in the usage dashboard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:09:43 +07:00
decolua
6eaa9f8369 feat(usage): add Kimi and DeepSeek usage handlers
Wire /v1/usages for Kimi (OAuth + API key) and balance API for DeepSeek,
flag both providers with usage/usageApikey, and normalize their quotas
in the dashboard ProviderLimits parser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:09:35 +07:00
decolua
65ac9b3cec fix(ui): prevent hidden quota row from overflowing
Use w-full instead of min-w-0/flex-1 + overflow-x-auto so the hidden
quota chips wrap cleanly instead of stretching the row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 10:03:31 +07:00
decolua
72ec06a81d feat(cli-tools): add Devin CLI provider with ACP stdio executor
Wire Devin CLI as a routed provider that spawns the local `devin acp`
binary. Add the DevinCliExecutor, register it in the executor map, expose
its status through the cli-tools batch endpoint and devin-settings route,
and document setup in cliTools constants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 10:03:22 +07:00
decolua
de2da19a9e feat(providers): add api-airforce, baidu, bazaarlink, bluesminds, kilo-gateway, llm7, morph, sambanova, tencent
Register 9 new upstream providers with logos and update the auto-generated
registry index. Refresh providers/alias baselines and extend the alias
token allowlist so verify-alias stays green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 10:03:10 +07:00
decolua
aa0448f7e2 fix(refresh): rotate refresh_token between retry attempts
Rotating-RT providers (xAI/grok-cli) issue a new refresh_token on every
refresh; mutate credentials in-place so refreshWithRetry reuses the fresh
RT instead of the already-consumed one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 17:30:11 +07:00
decolua
41c9e6be87 feat(claude): bump default Opus to claude-opus-5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 17:30:02 +07:00
decolua
8e04fe1734 feat(oauth): zed/trae/windsurf providers + harden callback proxies
- zed live model discovery; codebuddy-intl handler; remove duplicate workbuddy
- split oauth providers.js into per-provider files (facade re-export)
- fold 5 standard refresh providers into config-driven generic
- hide trae/windsurf from registry (no tool calling support)
- fix login-CSRF + SSRF on trae/windsurf/zed local callback proxies
  via loopback-origin guard + strict state validation + apiOrigins allowlist
- move zed RSA private key transit to POST body; redact proxy logs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 17:25:19 +07:00
Phuong Lambert
783e271c16 feat(gemini): add Gemini 3.6 Flash tier routing and 3.5 Flash Lite
Add gemini-3.6-flash tiered (high/medium/low) for Antigravity routing
via upstreamModelId "gemini-3.6-flash-tiered(level)" + thinkingLevel,
plus gemini-3.6-flash and gemini-3.5-flash-lite direct API models.

- getModelUpstreamId: split (level) suffix before lookup, re-append after
- Antigravity executor: preserve transformed body.model
- MITM extractModel: parse thinkingLevel for tiered model (default medium)
- Isolate Cloud Code endpoints: discovery (loadCodeAssist/onboardUser/
  quota) on PROD cloudcode-pa, chat transport on daily-cloudcode-pa
  to bypass prod 429
2026-07-23 16:34:24 +07:00
jacardl
3c17d3406b fix(jina-reader): recover after transient errors and use JSON POST API
Clear stale provider error code and account lock after a successful web
fetch (the core fetch handler never consumed the onRequestSuccess
callback), switch Jina Reader to its documented JSON POST request, and
parse the Title: metadata line before falling back to a Markdown heading.
2026-07-23 16:28:37 +07:00
ankit1324
007d372724 fix(kiro): normalize dashboard thinking intensity models
Strip the generic dashboard model(level) suffix before resolving Kiro
synthetic -thinking/-agentic variants so the upstream request no longer
carries an invalid parenthesized model id. Map explicit levels to native
Kiro effort fields only for supported Claude/GPT model families, and stop
advertising native levels for unsupported legacy Kiro models. Applies to
both OpenAI→Kiro and direct Claude→Kiro routes.
2026-07-23 16:27:06 +07:00
ryanngit
e45bd73d6e fix(tunnel): preserve successor cloudflared PID
Make PID cleanup conditional on the exiting child still owning the PID file so a stale exit cannot erase a replacement tunnel's PID. Only null the in-memory process when the exiting child is current. Explicit disable keeps unconditional cleanup.
2026-07-23 16:22:47 +07:00
zie
c85a5c57ba fix(usage): record exact embedding tokens 2026-07-23 16:07:57 +07:00
Duc Nguyen
57b3b2c175 fix(console-log): initialize capture at server boot + prevent SSE proxy buffering
Initialize initConsoleLogCapture() via Next.js instrumentation register()
hook so logs are captured from startup in headless/Docker deployments, and
add X-Accel-Buffering/Cache-Control headers to the SSE stream route to
prevent reverse proxies from buffering the initial payload.
2026-07-23 16:06:21 +07:00
Biuzai OpenClaw Agent
53a8b5ed55 feat(providers): add Gemini 3.6 Flash and Gemini 3.5 Flash Lite models 2026-07-23 15:48:23 +07:00
decolua
039c4dbc72 feat(providers): add trae/windsurf/zed/workbuddy/codebuddy-intl + icons
- New providers: trae, windsurf, zed, workbuddy, codebuddy-intl
  (registry + executor, wired into executors/index.js + registry/index.js)
- zed: port hosted cloud proxy from OmniRoute — RSA access-token → short-lived
  LLM token exchange (shared/zedAuth.js) + NDJSON {event}/{status}/[DONE]
  stream translated back to OpenAI via Claude/Gemini/OpenAI-Responses translators
- Provider icons (128x128 png) for the 5 new providers
- qoder + tokenRefresh provider tweaks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:37:24 +07:00
decolua
79918c7830 # 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
2026-07-20 17:21:41 +07:00
long2ice
6994cd1f70 fix(cursor): HTTP/2 AgentService support + version bump to 3.12.17
Real Cursor IDE now uses AgentService at agent.api5.cursor.sh (HTTP/2-only)
while 9router still spoke the retired ChatService at api2.cursor.sh with
outdated headers, producing HTTP 429 "Update Required". Add an executeAgent
path that builds an agent.v1.RunRequest Connect RPC over a raw http2 stream
and fetches the account-specific usable model catalog via GetUsableModels.

Also implement MCP tool calling over AgentService: encode OpenAI tools as
AgentRunRequest.mcp_tools (McpToolDefinition with google.protobuf.Value
input_schema), decode McpArgs tool calls, and forward them to the client as
OpenAI tool_calls so the client runs the tool and resumes in the next turn.
Reply to request_context_args with a non-empty RequestContext, to server
heartbeats with client_heartbeat, and to KV blob get/set with empty results,
so action queries no longer stall the stream. Fold the client system prompt
into the user message (custom_system_prompt makes the server return an empty
turn). Bump clientVersion to 3.12.17 and add the x-cursor-client-commit
header so the gateway identifies as a current Cursor IDE release.
2026-07-20 15:39:55 +07:00
Lê Tấn Thắng
4f48ab8c7f fix: resolve better-sqlite3 parameter array binding crash
Spread params into better-sqlite3 Statement.run/get/all so positional ? placeholders bind correctly. better-sqlite3 accepts positional args, not an array, so binding crashed whenever a query had parameters. Matches the bun:sqlite and node:sqlite adapters.
2026-07-20 12:08:58 +07:00
Rafli Ahmad Zulfikar
c97963c4fb fix(translator): pass service_tier through OpenAI→Responses conversion
Forward the service_tier field from OpenAI requests into the Responses API payload so clients can select priority/default/flex tiers instead of the field being silently dropped.
2026-07-20 11:24:37 +07:00
Edison42
cef5dd4d61 fix(kiro): map GPT-5.6 reasoning effort fields
Route GPT-5.6 reasoning effort through Kiro's native reasoning.effort field instead of the legacy Claude output_config.effort path. GPT-5.6 models now emit reasoning.effort for low/medium/high/xhigh, with max mapped to the xhigh wire value.

Preserve the Responses API reasoning.effort through the OpenAI intermediate by copying it to reasoning_effort before the field is dropped. Skip legacy thinking_mode prompt tags when a supported native GPT effort is emitted, while keeping the legacy fallback for unsupported values (auto/minimal/ultra) and explicit disable semantics (none/off/disabled). Claude adaptive effort continues to use thinking plus output_config.effort.
2026-07-20 11:11:37 +07:00
Nur Ad-Duja
d587b2a487 fix(codex): current client_version + refresh-aware model sync
Bump client_version to 0.144.6 (above the 0.144.0 gate in codex CLI's
manifest) so /codex/models no longer returns 200 with newest entries
silently filtered out. Add the originator: codex_cli_rs header used by
every other codex call site, and move the entry onto buildOAuthResolver
so token refresh on 401/403 and a warning field on empty results are
wired in, matching gemini-cli and grok-cli.
2026-07-20 10:55:42 +07:00
Edison42
7c7fae3955 fix(kiro): validate terminal streams before emitting output
Validate AWS EventStream framing, header bounds, CRCs, error frames,
and terminal stop metadata before exposing Kiro output. Classify stop
reasons into dispositions (complete / retryable / terminal_incomplete /
refusal) and retry once when the stream ends with a malformed tool call,
ellipsis-only output, or a short future-action sentence.

Fail closed: propagate streaming failures as error SSE (502) instead of
collapsing them into a successful stop, so incomplete responses no longer
leak as final answers.

Detect the observed evidence-prefixed trailing progress final without
broadening the Chinese heuristic to completed findings.
2026-07-20 10:55:33 +07:00
seakleang.nhak
9ba8f37486 feat(i18n): add Khmer language support
Register Khmer (km) locale, add complete 1,394-entry dashboard translation,
and show the Cambodia flag in the language selector. Place Khmer immediately
before Thai in the selector and keep the header locale in sync after switching.
2026-07-19 16:30:37 +07:00
decolua
55628eea02 fix(alicode-intl): split into Coding Plan + Model Studio providers
8b9cac1 swapped alicode-intl to the DashScope compatible-mode endpoint to
fix #2591 for standard DashScope keys, but that broke Coding Plan keys
(sk-sp-...) which only work on coding-intl.dashscope.aliyuncs.com. The two
key types use two different hosts and are not interchangeable.

- alicode-intl: revert to coding-intl endpoint (Coding Plan keys)
- alims-intl: new provider for dashscope-intl/compatible-mode (standard keys)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:29:55 +07:00
Don Tanggang
c4a120af8f docs(readme): update free-tier provider status for 2026
Outdated free-tier info was misleading new users. Sync corrections into
README.md and README.zh-CN.md, and force IPv4-first DNS resolution in the
CLI launcher to avoid undici IPv6 connect timeouts (502).

- Kiro AI: free tier now ~50 credits/month (was "Unlimited FREE")
- Qwen Code / Gemini CLI: free tiers discontinued in 2026
- OpenCode Free: note free model list fluctuates
- Vertex AI: Gemini API no longer uses $300 free credits since Mar 2026
- cli/cli.js: spawn server/tray with --dns-result-order=ipv4first
2026-07-19 14:12:58 +07:00
Edison42
eb00222c4f fix(kiro): map GPT reasoning effort fields
GPT-5.6 via Kiro needs reasoning.effort while Claude uses output_config.effort.
Resolve the effort path per-model schema (like Kiro CLI/KAS) so GPT-5.6
receives the correct structured thinking level. Claude path unchanged.

- Add resolveKiroEffortPath returning "reasoning" | "output_config" | null
- buildKiroAdditionalModelRequestFields emits schema-specific shape
- Keep prompt tags for backward compatibility
- Add OpenAI/Claude translator coverage for GPT-5.6 effort mapping
2026-07-19 13:53:30 +07:00
tuanminhhole
43d4abbcf2 docs(README): add Vietnamese OpenClaw Zalo video guide 2026-07-19 13:45:20 +07:00
rixzkiye
e0ba667450 feat(cli-tools): configure Grok Build subagent models
Add separate model selectors for Grok Build main, general-purpose,
explore, and plan agents. Each override gets an independent 9Router
custom-model slot and context_window derived from 9Router model
capabilities. Preserve and restore pre-existing config on reset.
2026-07-19 13:35:38 +07:00
decolua
0513bf393f Flow animatopn 2026-07-19 13:19:13 +07:00
d826e39008 merge origin/master into gitea/new_feature
Bring local branch up to v0.5.35 while keeping xAI image/edit, SuperGrok
quota tracking, per-provider timeouts, and pinned model-test actions.
2026-07-17 15:31:47 +07:00
2897cc3972 feat(providers): pin model tests to a specific account
Add Test action on each connection and Test Selected for multi-select.
Model pings go through /api/models/test with x-connection-id so the call
uses only the chosen account, with no round-robin fallback.
2026-07-17 15:23:35 +07:00
decolua
ccb0842d0a fix(dashboard): cut duplicate API/icon spam, lazy-load provider assets
Share one /api/models fetch via useModelCaps cache, mount ModelSelectModal
only when open, stop double fetchModelAliases on CLI tool cards, and resolve
provider icons through a session 404 cache with missing PNGs + loading=lazy.
Also include Claude Exa MCP toggle (claude-settings + ClaudeToolCard) that
was already in the working tree.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 12:12:20 +07:00
decolua
68566f53dc feat(kimi): merge OAuth into dual-auth provider, add K3/K2.7 models
Gộp kimi-coding vào kimi (oauth+apikey), parity CLIProxyAPI device flow/headers/refresh.
Thêm K3 + K2.7 Code (+ Kimi Code ids), pricing/caps vision, cập nhật baseline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 12:09:14 +07:00
decolua
bc252ea802 # v0.5.35 (2026-07-16)
## Features
- **xAI**: Grok Imagine video generation (`/v1/videos`) + CLI
- **CLI tools**: Grok Build setup — writes `[model.9router]` to `~/.grok/config.toml`
- **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
2026-07-16 18:13:51 +07:00
asynx6
de680e789f fix(providers): bulk-add API keys no longer overwrite existing keys
Bulk-add named auto-generated keys by paste-line index, blind to existing
connection names. The backend upserts apikey connections by exact name
(connectionsRepo), so a colliding generated name silently replaced an
existing key instead of inserting a new one.

Add a collision-aware planner (src/shared/utils/bulkAdd.js) that gap-fills
the smallest free "<base> <n>" against both existing connection names and
names assigned earlier in the same batch, so a generated name is never
reused and the backend always inserts. Applies to auto-named lines, custom
name|apiKey lines, and Cloudflare name|apiKey|accountId lines.

Wire the planner into AddApiKeyModal and pass existing connection names
from the provider detail page. Add unit tests covering gap-fill, custom
names, Cloudflare 3-part format, and robustness.
2026-07-16 17:20:10 +07:00
Tuan Do
6acc3bb965 fix(anthropic): lowercase anthropic-version header key to prevent duplication on /v1/messages 2026-07-16 16:15:24 +07:00
Ella CEO
8b9cac180e fix(alicode-intl): use DashScope compatible-mode endpoint so standard keys work
Switch baseUrl from coding-intl.dashscope.aliyuncs.com (Coding Plan keys
only) to dashscope-intl.aliyuncs.com/compatible-mode so ordinary DashScope
API keys authenticate. Path /v1/chat/completions and preserveCacheControl
quirk unchanged.

Fixes #2591
2026-07-16 15:56:19 +07:00
YasharSL
30d0f6d3d8 docs(README): Add Persian youtube video tutorial 2026-07-16 15:47:40 +07:00
ryanngit
59b7828237 fix(grok-cli): align Grok Build with current subscription protocol (#2590) 2026-07-16 15:33:19 +07:00
ann
d6761c6fb0 feat(xai): add Grok Imagine video generation (/v1/videos) + CLI
Async video job proxy mirroring the existing image-generation layer split:
Next routes → src/sse/handlers/videoGeneration.js (auth gate, account
fallback loop, refresh persistence) → open-sse/handlers/videoCore.js
(transparent upstream proxy, 401 refresh-once/retry-once, secret sanitization).

- POST /v1/videos/{generations,edits,extensions}: byte-exact body forward
  (JSON + multipart), request_id passthrough, Idempotency-Key forwarded
- GET /v1/videos/{request_id}: status/progress/video.url passthrough
- Register grok-imagine-video (kind: "video"); add "video" to MODEL_TYPE_TO_KIND
  so video models stay out of chat lists (also fixes runwayml leak)
- 9router xai video CLI: submit → poll → atomic MP4 download
- No auto-retry of creation POSTs (billable jobs); rotate accounts only on
  401/403/429; sanitize Bearer tokens + credential values from errors/logs

Closes #1285
2026-07-16 15:29:52 +07:00
M0nt
02ccdc2d22 i18n: add Persian (fa) translations for README and UI
Add full Persian (Farsi) translation of README and sync UI literals
to match zh-CN key set (1389 keys). No runtime code changes.
2026-07-16 15:19:38 +07:00
Edison42
9c58ba645e fix(kiro): improve direct session cache reuse
Reshape Kiro direct requests so resumed client sessions reuse Kiro's
cache-affinity fields instead of starting unrelated CodeWhisperer
conversations.

- keep conversationState.conversationId stable when the client sends an
  explicit session id (x-session-id, session_id, conversation_id, Claude
  Code session metadata)
- add a stable conversationState.agentContinuationId per Kiro session
- send conversationState.agentTaskType: "vibe" and agentMode: "vibe",
  matching the normal Kiro CLI/KAS chat path
- move Kiro thinking instructions into Kiro-compatible systemPrompt /
  additionalModelRequestFields instead of generic top-level thinking
- keep volatile timestamp context out of the top-level systemPrompt; it
  remains only in user content fallback
- suppress additionalModelRequestFields for legacy 4.5-era Claude/Kiro
  models that reject it, while defaulting future Claude/Kiro model ids
  to supported
- preserve Kiro meteringEvent credit usage internally for accounting
  without leaking provider-specific fields into OpenAI-compatible usage
- prevent unrelated headerless Kiro requests from sharing one
  connection-wide continuation
- cap/evict continuation sessions so long-running processes do not grow
  the continuation map unbounded
- treat generated headerless Kiro sessions as one-shot so they do not
  evict real explicit-session continuations
- keep credit-only Kiro metering valid for internal persistence when
  token metrics are unavailable
2026-07-16 15:15:05 +07:00
rixzkiye
70e8dc4974 feat(cli-tools): add Grok Build setup
Add Grok Build to Dashboard → CLI Tools. Apply writes a [model.9router]
custom model to ~/.grok/config.toml and sets [models].default, routing
the xAI Grok TUI through 9Router. Reset removes the slot and restores
the previous default.
2026-07-16 14:47:20 +07:00
Edison42
b94685b80d feat(kiro): add GPT-5.6 model family (#2596)
Add GPT-5.6 Sol/Terra/Luna and their synthetic thinking/agentic/
thinking-agentic variants to the Kiro static catalog with the observed
272k context window and credit multipliers (2.4/1.2/0.6), register MITM
mapping slots for the new base ids, and override runtime capabilities so
the GPT-5.6 family reports the 272k window instead of the generic GPT-5
profile.
2026-07-16 14:38:08 +07:00
decolua
0248dd5348 feat(i18n): add Thai language translation (#2581) 2026-07-16 14:31:23 +07:00
hungtrinh
27b37705b3 perf(startup): skip inactive background services 2026-07-16 12:09:48 +07:00
Ella CEO
7dfb346667 fix(grok-cli): surface expiresAt so proactive token refresh fires (#2546) 2026-07-16 12:00:14 +07:00
decolua
a6a41dfb3c Merge remote-tracking branch 'upstream/master'
# Conflicts:
#	.gitignore
#	open-sse/handlers/chatCore.js
2026-07-16 11:59:46 +07:00
luoyide
2629218b04 fix(models): populate capabilities for live-catalog LLM models 2026-07-16 11:50:15 +07:00
joachimBrindeau
c9926897ba feat(rtk): add X-9Router-Token-Saver header to bypass token savers per request 2026-07-16 11:27:42 +07:00
liamgnc
88a8c72d2d fix(models): list compatible provider models in /v1/models
Replace the overly-broad UPSTREAM_CONNECTION_RE regex (which matched all
provider IDs with UUID suffixes) with an x-9r-internal-models-fetch
header to detect cross-instance recursive /models fetches.

fetchCompatibleModelIds now sends the header when fetching upstream
/models; the GET handler detects it and skips dynamic fetching, breaking
the recursion loop while letting compatible providers (MLX, Ollama, vLLM)
list their models. Fixes #2626.
2026-07-16 11:18:09 +07:00
luoyide
ba508f2506 fix(thinking): send explicit thinking:{type:adaptive} alongside output_config.effort 2026-07-16 11:17:08 +07:00
decolua
a077ee85bd gitignore 2026-07-16 11:16:57 +07:00
qianze
e567ba800f fix(translator): strip client_metadata when converting openai-responses to openai
client_metadata is an OpenAI Responses API-specific field. When translating
openai-responses requests to openai (Chat Completions), it leaked through
to providers like NVIDIA, which rejected it with a 400 "Unsupported
parameter". Strip it in the Responses-specific cleanup block alongside
input, instructions, store, and reasoning.
2026-07-15 17:38:53 +07:00
luoyide
542a088c04 feat(github): route Claude models through Copilot's native /v1/messages
GitHub Copilot's /chat/completions and /responses endpoints never surface
prompt-cache token counts for Claude models. Route Claude models (detected
by name pattern) to Copilot's Anthropic-native /v1/messages shim via a new
executeWithMessagesEndpoint(), translating OpenAI-shape requests to Claude
natively so cache_control gets injected and cached_tokens surface.

Also fixes translateRequest()'s internal _toolNameMap being sent upstream,
which made Anthropic's strict schema reject tool-call requests with a 400 —
now stripped and threaded through response state. Removes the now-dead
response_format Claude JSON-mode workaround.
2026-07-15 17:34:08 +07:00
Moradii.Mohammadreza
9173c29b66 feat(translator): drop temperature for all Claude models
Broaden strip rule from /claude-opus-4/i to /claude/i so temperature is
removed for every Claude model, not just opus-4. Fixes Anthropic 400 on
OpenAI-compatible routes. #1748
2026-07-15 17:08:41 +07:00
decolua
eceac9d7ae gitignore 2026-07-15 16:40:41 +07:00
ab9a3c1d43 feat(xai): track SuperGrok weekly limit + API usage quota
Fetch OAuth quota from cli-chat-proxy billing, GetGrokCreditsConfig weekly
window, and settings plan label so the dashboard matches grok.com usage.
2026-07-13 23:44:36 +07:00
minnyww
837cfec5a9 feat(i18n): complete Thai translation (1389 keys) + README.th.md 2026-07-13 17:08:49 +07:00
b1d368d960 feat: xAI image generate/edit, API key import, and per-provider timeouts
- Add dedicated xAI image adapter with generate + edit (multi-image) via
  /v1/images/generations and /v1/images/edits, plus aspect_ratio/resolution UI
- Support importing existing API keys and exposing connection api-key routes
- Add global/per-provider connect timeout overrides from settings
- Keep unrelated provider UX improvements on this branch; no Grok quota tracking
2026-07-13 16:52:22 +07:00
minnyww
f89ba32d79 feat(i18n): add Thai language translation 2026-07-13 16:50:09 +07:00
decolua
9845a1702f # 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
2026-07-10 18:12:07 +07:00
decolua
a625ea9fd8 refactor(log): unify request lifecycle logging with session-colored tags
Collapse scattered per-request console lines (request/routing/auth/pending/
usage/stream-usage/stream) into 3 correlated lines: request, transform,
done. Add stable per-session color tag so concurrent request lines are
easy to follow, surface thinking intent, always-on full error logging
for debug, re-enable warn level, and uppercase keyword labels. Also fix
usage overview cards wrapping (5 cards -> grid-cols-5).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 18:01:20 +07:00
decolua
b61c50cbb7 # v0.5.29 (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
2026-07-10 17:51:48 +07:00
decolua
baafc74c1f Bump version 2026-07-10 17:48:53 +07:00
decolua
bb314118f2 Bump version 2026-07-10 17:38:40 +07:00
decolua
2d515c8abc # v0.5.28 (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
2026-07-10 17:36:21 +07:00
decolua
74d5fedf79 feat(headroom): activate/uninstall extras + fix interpreter detection
- find interpreter next to headroom binary so extras/version read correctly
- add on/off toggle to activate [code]/[ml] via proxy restart
- add uninstall action + live install log progress + ~1GB confirm modal
2026-07-10 17:32:45 +07:00
Elio Bonfim Júnior
dcf1927f22 feat(pxpipe): PXPIPE token saver — multimodal prompt compression (#2465)
Add pxpipe as an experimental fifth Token Saver: Claude-format request
bodies above a configurable size threshold are rendered as dense PNGs
via the pxpipe-proxy library API (transformAnthropicMessages) before
dispatch, cutting estimated input tokens by ~35-60% on token-dense
contexts. Integration follows the Headroom pattern: applied to the final
body in chatCore just before dispatch, fail-open on any error/timeout.

Managed npm install into DATA_DIR/pxpipe, dynamic loader with per-version
cache-bust, JSONL event log with rotation, /api/pxpipe/* endpoints, Token
Saver card (marked experimental) + /dashboard/pxpipe page, and per-request
Activated/Skipped annotation in Request Details. Disabled by default.
2026-07-10 16:10:42 +07:00
Fadjrir Herlambang
e1f3399b73 feat(proxy-pools): auto-rotate strategy for no-auth providers (#2409)
Add round-robin/random proxy pool rotation for no-auth free providers
(e.g. OpenCode Free) to distribute load across all active pools and
avoid per-IP rate limits. Rotation strategy is selectable per provider
in NoAuthProxyCard and persisted to settings.providerStrategies.
2026-07-10 16:05:07 +07:00
KunN-21
f1f9d27061 feat(headroom): add extras detection and install UI (#2403)
- add Headroom extras status + install endpoints
- show Headroom version + code/ml extras in Token Saver UI
- fix Windows interpreter selection to read from env with headroom-ai
2026-07-10 16:05:04 +07:00
decolua
90df008f0c chore(release): v0.5.25
Update CHANGELOG, bump version, trim usage overview cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 16:04:37 +07:00
decolua
d2599ebf17 fix(pricing): update Claude/Codex model rates and add new models
Add claude-fable-5, gpt-5.6 family; correct gpt-5/5.1/5.2/5.3-codex rates to official pricing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 16:02:18 +07:00
Rizkal
5cdcf67484 fix(cloudflare-ai): support accountId in bulk key import (#2449)
Bulk import now parses name|apiKey|accountId lines for Cloudflare AI and
forwards accountId via providerSpecificData, with a provider-specific
placeholder and format hint.
2026-07-10 13:10:29 +07:00
decolua
b25e10160d fix: DB backup on schema change, MCP child cleanup, codex models, usage providers OOM
- Backup DB only on real SCHEMA_VERSION change, not every app version bump
- Kill idle MCP stdio bridge children to prevent orphan process leaks
- Add getDistinctProviders to avoid loading every row JSON blob (OOM fix)
- Update codex model list (gpt-5.6 sol/terra/luna, drop 5.3 codex variants)
- Reorder Claude default models (fable first)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 13:08:58 +07:00
decolua
0270f6ea70 perf: faster dev startup and lighter bundle
- Switch dev default to Turbopack (5-14x faster compile); keep webpack as dev:webpack
- Tailwind v4 source() base so JIT scans identically under both bundlers
- Lazy-load @xyflow/react via next/dynamic to keep it out of the shared bundle
- optimizePackageImports for heavy barrel imports (xyflow, dnd-kit, material-symbols, marked)
- Replace blind setTimeout waits with TCP health-check (waitServerReady)
- Run checkForUpdate in parallel instead of blocking server spawn
- Background MITM/tunnel/cloudflared kills off the critical path

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 13:08:02 +07:00
newnol
ce6bdf7fc2 feat(perplexity): add Agent API provider (#2492)
Add perplexity-agent provider using OpenAI-compatible Responses API,
routing third-party models (GPT, Claude, Gemini, Grok, GLM, Kimi, Sonar)
through one endpoint. Expose /v1/models discovery, add chat-search wrapper
via web_search tool. Existing Sonar provider unchanged.
2026-07-10 11:57:15 +07:00
Fadjrir Herlambang
a11937cdd6 feat(grok-cli): add Grok CLI / Grok Build provider with OAuth device-code flow (#2502)
New OAuth provider routing through cli-chat-proxy.grok.com (OpenAI Responses
API), distinct from xai (api.x.ai) and grok-web (cookie SSO):

- Registry + GrokCliExecutor: Chat Completions -> Responses transform, CLI
  fingerprint headers, virtual effort models grok-4.5-{low,medium,high}
- OAuth device-code flow (auth.x.ai) with no-PKCE, shared xAI token refresh
- store=false multi-turn continuity via reasoning encrypted_content
- Quota tracker: on-demand window + prepaid balance on dashboard
- Connection test: 402 spending-limit = soft success (auth OK, out of credits)
- Alias/oauth/provider baselines + unit tests
2026-07-10 11:47:08 +07:00
Hermes Hunter
c73c419d09 fix(codex): avoid bare-email OAuth dedup (#2477)
Only update an existing Codex OAuth row when both rows share the same
chatgptAccountId, so a second Codex login no longer overwrites the first
account's rotated token pair. Also fall back to
workspaceId || chatgptAccountId || accountId for the chatgpt-account-id header.
2026-07-10 11:41:43 +07:00
ryanngit
a3b267a5cb fix(cli): allow staged app bundle builds (#2479)
Write the standalone app bundle and MITM bundle to NINEROUTER_CLI_APP_DIR
when set, so staged deploys can build to a separate destination before
swapping. Default output stays at cli/app when the env var is unset.
2026-07-10 11:40:03 +07:00
Edison42
65c65a0f56 fix(headroom): compress Kiro conversation state (#2488)
Project conversationState history/currentMessage into OpenAI-style
messages for /v1/compress, then write compressed text back into the
original Kiro fields while preserving provider payload shape. Fail open
when the proxy returns malformed or reordered messages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 11:33:21 +07:00
DOMANHDUC
7610f28f42 fix(gemini-cli): raise output floor for thinking and add validated toolConfig (#2486)
Gemini CLI requests with small max_tokens spend the whole output budget on
thoughts after reasoning_effort maps to thinkingConfig, returning blank
content or finish=length. Raise maxOutputTokens floors per thinking level/
budget (clamped to caps.maxOutput). Also emit toolConfig
functionCallingConfig.mode=VALIDATED for Gemini CLI tool requests to avoid
MALFORMED_FUNCTION_CALL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 11:32:11 +07:00
newnol
0d4d4bc261 feat(featherless): add OpenAI-compatible provider presets
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 11:29:48 +07:00
ryanngit
3a7a878f91 fix(github): label Copilot profiles by account identity (#2498)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 11:29:12 +07:00
zie
e79f9eddb4 feat(searxng): configure endpoint via SEARXNG_URL env (#2499)
Add SEARXNG_URL runtime override for the built-in SearXNG web-search
provider, defaulting to http://localhost:8888/search. Enables Docker
and remote SearXNG deployments without changing existing behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 11:29:06 +07:00
Rafli Ahmad Zulfikar
b9e2611045 feat(providers): add max thinking level for gpt-5.6-sol (#2500)
Expose max in the Codex thinking dropdown for gpt-5.6-sol only (maps to
xhigh on wire; live probe rejected ultra). Include custom/kilo models
when computing provider thinking options so manually added gpt-5.6-sol
contributes its max level.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 11:17:32 +07:00
Samir Abis
ddd5509e97 fix(openai-to-claude): unwrap bare {function:{…}} tools without parent type (#2473)
Translator only unwrapped tool.function when both tool.type==="function"
and tool.function were truthy. Loose/legacy OpenAI clients emit the bare
{ function: { name, parameters } } shape (no parent type), which fell
through and forwarded name: undefined upstream, rejected by strict
Anthropic-compatible gateways (MiniMax M3) as (2013) invalid tool type.

Unwrap tool.function whenever present. Built-in tools stay pass-through.
Adds regression coverage for the 4 tool shapes. See #2435.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 11:11:37 +07:00
thienpv
288940960a fix(translator): clamp thinking effort max->xhigh for OpenAI format (#2466)
Claude Code sends reasoning_effort "max" (its top level); OpenAI enum caps
at "xhigh" and rejects "max" with HTTP 400 "max effort not support".
applyFormat case "openai" now clamps "max"->"xhigh" before assigning
body.reasoning_effort; other levels pass through unchanged.

Add regression test covering client output_config.effort, direct
reasoning_effort, passthrough of xhigh/high, and budget_tokens capping.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 15:13:24 +07:00
Diwak4r
d75471bbbc fix(rtk/find): detect and group Windows backslash-style find output (#2448)
isPathLike rejected any line with a colon, so Windows absolute paths
(C:\Users\me\a.js) were never recognized and find dumps went uncompacted.
find.js also split only on "/", mis-grouping backslash paths.

- autodetect: treat drive-letter prefix (X:\ or X:/) as path-like before
  the general colon rejection.
- find.js: split on the last "/" or "\" separator and normalize emitted
  directory labels to forward slashes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 15:12:52 +07:00
ryanngit
0c55d49ab6 fix(codex): handle fast tier and capacity SSE (#2452)
- map service_tier=fast to upstream priority; drop unsupported tiers
- normalize reasoning effort max to xhigh (codex-only)
- convert 200-SSE model-capacity errors into 503 so account fallback rotates
- keep normal SSE output intact after peeking

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 15:12:46 +07:00
whale9820
cfbdf06047 fix(volcengine-ark): clamp Kimi max_tokens to 32768 endpoint cap
VolcEngine Ark caps the Kimi family at max_tokens <= 32768, but the
model's advertised ceiling is far higher (Kimi-K2.7-Code resolves to
maxOutput 262144), so clampToModelMaxOutput alone leaves it uncapped and
the request 400s. Add a Kimi-scoped rule with an explicit maxOutputCap of
32768, combined with the model ceiling via min(). Covers max_tokens,
max_completion_tokens, max_output_tokens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 15:10:05 +07:00
decolua
a4c5fa4e14 refactor(api): implement caching for tunnel and version status endpoints 2026-07-09 15:08:30 +07:00
qianze
20b442b708 i18n(zh-CN): complete Chinese translations for all UI strings (#2436)
Add 551 new translations covering previously untranslated areas
(landing, CLI tools, MITM, skills, combos, token-saver, OIDC,
relay deploy, provider details, proxy pools, quota tracker,
OAuth modals). Total: 838 -> 1389 entries.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 15:07:12 +07:00
nguyenha935
71cd5b2f23 fix(antigravity): align provider fingerprint with IDE Desktop 2.1.1 (#2389)
Match captured official Antigravity IDE traffic: cloudcode-pa host,
antigravity/ide/2.1.1 User-Agent, IDE-shaped agent requestId, and drop
router-only stream/usage headers plus the legacy double system prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 10:17:47 +07:00
decolua
b10b807063 # 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
2026-07-07 16:29:11 +07:00
baibiao
081c6f2aff fix(count_tokens): count structured Anthropic blocks (#2419)
Estimate tokens for tool_use, tool_result, thinking, system, and tools
blocks instead of text only, so count_tokens no longer returns 0 for
structured content and breaks Claude Code auto-compaction (#2337).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 12:06:09 +07:00
KunN-21
19281b5524 feat(rtk): add JS-native git-log filter (#2423)
Compress git log output via dedicated RTK filter: keep commit headers,
Author/Date, subject; drop body padding, decoration, embedded diff lines.
Wire into autodetect (git-log prioritized before git-diff) and registry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 12:02:56 +07:00
whale
bbae990b92 fix(volcengine-ark): clamp GLM-5 max_tokens to model output ceiling (#2428)
Ark rejects max_tokens above 128000 for GLM-5.2. Add a config-driven STRIP_RULES entry that clamps max_tokens, max_completion_tokens and max_output_tokens down to the model maxOutput before the upstream call.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 11:57:04 +07:00
whale
8c068a1f5c fix(kimi): normalize reasoning_effort to backend enum (#2427)
Map auto→high, minimal→low, xhigh→max and whitelist low/medium/high/max
so Kimi/kimchi SGLang backends no longer receive invalid effort values.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 11:56:39 +07:00
KunN-21
97a6708651 feat(caveman): add targeted upstream-aligned style rules (#2424)
Add four shared Caveman prompt fragments (no invented abbreviations,
preserve user language, no self-reference, no decoration) across all six
levels, and remove ULTRA contradictions around abbreviations/arrow
shorthand. Adds regression tests for the prompt rules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 11:55:45 +07:00
deranalabs
a3cd7c82bc fix(translator): preserve developer instructions in openai-responses conversion (#2434)
Map role="developer" messages to top-level instructions alongside
role="system" in openaiToOpenAIResponsesRequest. Previously developer
messages matched no branch and were silently dropped from the Responses
request, losing GPT-5/Codex system-level prompts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 11:54:07 +07:00
decolua
bf7da67859 docs(readme): swap in Vietnamese tutorial video; chore(pricing): minor update
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 11:44:30 +07:00
decolua
da0149de97 fix(mitm): recover from stale lock file on server start
Detect dead PID in lock file and reclaim it instead of failing, and drop unused fs dependency.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 11:44:20 +07:00
MuhammadHamidRaza
1885ad7f64 docs(readme): add English and Urdu/Hindi video tutorials (#2305)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 17:47:17 +07:00
Sutarto Jordan Chrisfivo
481e7e467b fix(headroom): proxy dashboard through app (#2372)
Add a 9Router-side proxy so the Headroom dashboard and its data
endpoints (/stats, /health, /stats-history, /transformations/feed)
stay same-origin when opened remotely through the 9Router app, and
add an "Open Headroom Dashboard" link in the Token Saver modal.

Gate /api/headroom/proxy as LOCAL_ONLY (loopback + CLI token) to
match start/stop, and strip cookie/authorization when the Headroom
target is non-loopback to avoid leaking viewer credentials.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 17:45:04 +07:00
Mohammed Faheem
008de32c06 docs: add CLAUDE.md guidance for Claude Code (#2354)
Top-level guide for Claude Code / AI coding agents working in this repo,
complementing docs/ARCHITECTURE.md and open-sse/AGENTS.md.

Docs-only: PR's incidental code changes were dropped as they reverted #2366.

Co-Authored-By: Mohammed Faheem <mohammed.faheem@adbsafegate.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 17:44:04 +07:00
Arash Kadkhodaei
b6454d84da feat(i18n): add Farsi (fa) language support (#2385)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 17:38:17 +07:00
thienpv
46e6c01a01 fix(claude): reconcile max_tokens vs thinking budget and lift per-model ceiling (#2381)
On the translated OpenAI->Claude path, adjustMaxTokens capped max_tokens
before applyThinking set thinking.budget_tokens, so max-effort budget
(128000) could exceed a 64k-clamped max_tokens -> Anthropic 400.
prepareClaudeRequest now reconciles after the budget is known: prefer
raising max_tokens, only shrink budget when it meets/exceeds the ceiling.

Also lift the global 64000 cap: the ceiling is now the model's real
maxOutput, so high-output models (fable/mythos, opus-4.8/sonnet-4.6) get
their full budget. adjustMaxTokens gains an optional ceiling arg (default
unchanged, callers untouched); openai-to-claude passes the model maxOutput.

Native Claude Code passthrough is unaffected.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 17:38:16 +07:00
VitzS7
5041494e1c fix(kiro): deliver system prompt natively, add Opus 4.5/4.7/4.8, tolerate dash version ids (#2366)
- Pass system prompt via native systemInstruction field (+ <instructions> fallback)
  so Claude models stop treating it as info-only <system-reminder>
- Add Opus 4.5/4.7/4.8 (base/thinking/agentic/thinking+agentic) to Kiro registry
- normalizeModelId(): dash->dot version separator, scoped to Kiro provider only
- Replace <system-reminder> with <instructions> in claude-to-openai/openai-to-kiro

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 17:34:04 +07:00
nguyenha935
4dadab9d5f feat: add provider quota visibility settings 2026-07-04 23:26:49 +07:00
decolua
7f436e2792 # 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
2026-07-03 15:37:17 +07:00
hodtien
54e3245ace feat(usage): track cached tokens + correct input/output/cache cost (#2209)
Normalize every provider to one cache-inclusive convention via
canonicalizeUsage() before persist, and price cached + cache_creation as
subsets of prompt_tokens in calculateCostFromTokens() to stop
double-counting. usageRepo now delegates cost math to a single source.
Surface Cached tokens/cost across dashboard (overview, tokens, cost,
details). Merge Claude message_start cache with message_delta output so
cache counts survive. Compatible LLM nodes now allow multiple API-key
connections (key pool).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 15:18:27 +07:00
Qin Li
960f8a0379 fix(usage): dedupe streaming request-details log entries
handleStreamingResponse and buildOnStreamComplete each generated their
own streamDetailId for what should be one logical record — the
placeholder row (0 tokens) and the final row (real usage) never shared
an id, so the DB's ON CONFLICT(id) upsert never merged them, leaving a
permanent 0-token stub for every streaming request.

Share the id from buildOnStreamComplete with handleStreamingResponse
so both writes hit the same row.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 15:14:49 +07:00
Rafli Ahmad Zulfikar
5cc4f222f8 feat(codex): show reset credit expiry details (#2290)
Add read-only GET to inspect per-credit reset inventory (status, granted,
expiry, remaining) with a Quota Tracker modal. DRY the route via shared
connection/refresh helpers; keep existing consume POST unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 15:07:45 +07:00
decolua
cd557a2552 fix(claude): drop foreign thinking signatures in passthrough
Combo mixes models, so non-Claude thinking signatures leak into
conversation history. Native passthrough forwarded them verbatim and
Anthropic rejected the request. Validate signatures and drop invalid
thinking blocks, re-inserting a placeholder when tool_use requires one.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 15:06:19 +07:00
decolua
ced51ed62f feat(nvidia): add new models and capabilities for NVIDIA provider
- Updated capabilities for NVIDIA models to enforce OpenAI-compatible reasoning formats.
- Added new models: MiniMax M3, GLM 5.2, DeepSeek V4 Pro, DeepSeek V4 Flash, Kimi K2.6, and Nemotron 3 Ultra to the NVIDIA registry.

This enhances the provider's functionality and aligns with OpenAI standards.
2026-07-03 12:15:58 +07:00
KunN-21
cb0135b695 fix: prevent non-SSE stream pipe crash and cross-IdP account overwrites (#2244)
- streamingHandler: when upstream returns non-SSE/JSON (e.g. Cloudflare
  5xx HTML), read body, sanitize <title>, notify streamController and
  return a clean JSON error instead of crashing the pipe.
- connectionsRepo: dedup OAuth connections on (email + username) so
  cross-IdP accounts sharing an email no longer overwrite each other;
  workspace providers keep workspace-id matching.
- kimchi: bump User-Agent to 0.1.50, add svg asset + browser-login
  service, and 21 unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 11:11:07 +07:00
Volodymyr Saakian
abc0add031 fix(kiro): route IdC auth to regional CodeWhisperer surface (#2297)
IAM Identity Center (authMethod=idc) tokens failed every request with 403
"bearer token invalid". Treat idc like api_key/external_idp:

- executors/kiro.js: route idc to *.amazonaws.com CodeWhisperer surface,
  region-aware from credentials.region instead of hardcoded us-east-1.
- openai-to-kiro.js / claude-to-kiro.js: send resolved profileArn or empty
  for idc/external_idp, never the shared builder-id placeholder ARN.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 11:06:40 +07:00
MiQieR
9102c4c6d8 fix(xiaomi-tokenplan): region selector, key validation, multi-connection (#2251)
- Add top-level regions array so Add/Edit modals render region <Select>
- EditConnectionModal: load/persist region generically for region-aware providers
- validate: accept 403 for xiaomi-tokenplan valid keys, add 8s fetch timeout
- Remove single-connection guard for compatible/embedding nodes

Co-authored-by: MiQieR <122154116+MiQieR@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 11:03:18 +07:00
Sahrul Ramadhan Hardiansyah
ce6120ce7b fix(translator): strict Anthropic content block compliance (#2225)
Filter empty text blocks from thoughtSignature-only parts, preserve
tool_calls when functionResponse and functionCall coexist in the same
content, and skip empty regular text parts before they reach Claude.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 10:58:39 +07:00
KunN-21
7afaecd617 fix(kimchi): strip reasoning_content echo to bound multi-turn input tokens
Clients echo full message history each turn including reasoning_content,
which the Kimchi OpenAI gateway counts as input tokens. Multi-turn convos
balloon to 100k+ tokens and the model returns empty content.

KimchiExecutor.transformRequest now strips reasoning_content from assistant
messages when it exceeds an 8-char threshold, preserving the 1-char
placeholder injectReasoningContent sets and keeping content intact.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 10:58:31 +07:00
Edison42
a5363b83b5 fix(kiro): add Claude Sonnet 5 model support (#2264)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 10:54:15 +07:00
sternelee
b08751c4ea feat(clinepass): add ClinePass provider support
Register clinepass provider (OAuth + API-key) using Cline's
OpenAI-compatible API with 10 curated models, live /v1/models
resolver, refreshCline-based token refresh with workos: prefix,
and dashboard OAuth login handler.

Reference: https://github.com/jellydn/pi-clinepass-provider
Closes #2261

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 10:53:41 +07:00
Ansh7473
76752a4396 fix(kimchi): bump User-Agent to kimchi/0.1.40 (#2256)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 10:49:53 +07:00
zmf
602ee4054b fix(codebuddy-cn): strip empty tool_calls arrays to preserve reasoning
CodeBuddy CN includes "tool_calls": [] in every SSE streaming delta.
@ai-sdk/openai-compatible checks delta.tool_calls != null — an empty
array passes ([] != null is true in JS), triggering premature
reasoning-end on every reasoning chunk (0/1ms durations in OpenCode).

Strip empty tool_calls arrays in passthrough before hasValuableContent.
Zero side-effect: real tool_calls always have at least one element.

Closes #2176

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 09:39:31 +07:00
Sutarto Jordan Chrisfivo
8f81f17b99 fix(antigravity): preserve Claude tool delta index (#2223)
Gemini response translation wrote OpenAI-shaped bookkeeping into the
shared state.toolCalls map, which the downstream openai-to-claude
translator uses for Claude block metadata. That pre-population skipped
blockIndex creation, so Anthropic input_json_delta events lost index.

Track Gemini function calls via state.geminiToolCallCount instead,
leaving state.toolCalls clean for the Claude translator.

Closes #2218

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 09:36:43 +07:00
Sutarto Jordan Chrisfivo
182c849979 fix(mitm): generate root ca on server startup (#2228)
Direct MITM server startup read rootCA.key/.crt immediately and exited
when either was missing, bypassing the manager.js CA setup path.

- generate Root CA from server.js when key/cert is missing
- make generateRootCA()/generateCert() synchronous to avoid a startup
  race before readFileSync
- add unit test covering synchronous Root CA creation

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 09:30:52 +07:00
decolua
0b3c794075 # 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
2026-06-29 16:23:33 +07:00
rifuki
a9785a5f70 fix(responses): handle response.done terminal events (#2142)
Treat response.done as a terminal OpenAI Responses stream event so
passthrough streams ending with response.done are not flagged incomplete
and no synthetic response.failed is emitted. Restore the data: [DONE]
sentinel for same-format Responses passthrough streams.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 16:05:29 +07:00
Sutarto Jordan Chrisfivo
373850ee36 fix(headroom): skip unsafe responses tool history (#2132)
Guard openai-responses compression: skip Headroom when body.input
contains non-message items (function_call, function_call_output,
reasoning) to preserve the Responses contract instead of collapsing
them into chat messages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:59:21 +07:00
decolua
749c2e3f9c fix(translator): map mid-conversation system message to user in claude-to-openai
Claude Code chèn role:system cuối messages[], trước đây bị map thành assistant
khiến hội thoại không kết thúc bằng user → provider OpenAI-compat (LiteLLM)
dịch ngược Anthropic trả 400 "assistant message prefill". Map system -> user
và wrap <system-reminder> để giữ ngữ nghĩa instruction.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:53:26 +07:00
decolua
7fa2e7f029 feat(capabilities): refine Qwen vision/video and thinking model patterns
Add qwen omni (audio/video input), qwen3.5/3.6/3.7 (native vision/video),
and mark qwen coder & max as text-only reasoning models.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:51:56 +07:00
warelik
8d1db46beb fix(gemini): normalize contents to prevent 400 invalid_argument (#2192)
Merge adjacent same-role blocks and strip empty parts before sending to
Gemini, avoiding 400 INVALID_ARGUMENT on consecutive same-role messages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:38:03 +07:00
Rex
9e3866658a fix(alicode): preserve cache_control for DashScope providers (#2069)
Opt-in quirk preserveCacheControl keeps cache_control on content blocks
for alicode/alicode-intl, enabling DashScope prompt caching. signature
is always stripped; all other providers unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:29:49 +07:00
Nant361
8a664d619d feat(kimchi): add Kimchi OAuth provider support
Add Kimchi as a browser-token OAuth provider routed through its
OpenAI-compatible gateway. Discover live models for /v1/models and
provider models, normalize Claude-compatible requests, and wire up
provider connection tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:29:17 +07:00
WARELIK
2d94fffe3b fix(gemini): backfill thoughtSignature and suppress stream done sentinel
Backfill DEFAULT_THINKING_AG_SIGNATURE onto functionCall parts missing it
(client history replay) and on Claude tool_use blocks, fixing 400
INVALID_ARGUMENT from Gemini-family APIs. Suppress the OpenAI-style
data: [DONE] sentinel for antigravity/gemini/vertex to avoid parser crashes.

Fixes #2193.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:28:03 +07:00
Yudhistira-Official
319caa2d7b fix(antigravity): strip 'deprecated' from tool schemas before Gemini
Gemini rejects the non-standard 'deprecated' keyword in nested tool
schemas with INVALID_ARGUMENT (400). Add it to UNSUPPORTED_SCHEMA_CONSTRAINTS
alongside 'optional' so it gets stripped during translation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:28:02 +07:00
whale9820
95bfc64f06 fix(codebuddy-cn): show bonus packs as one-time, not monthly-replenishing
CodeBuddy CN bonus packs ("Bonus Pack N") are one-shot credits whose
CycleEndTime equals DeductionEndTime — they expire for good and never
replenish. The dashboard rendered their resetAt as "Reset in Xd",
implying a monthly refill.

Tag bonus packs recurring:false (refill packs recurring:true) in the
usage handler, forward the flag through parseQuotaData, and word the
quota table / progress bar as "Expires in" / "Expires at" for
one-shot packs.
2026-06-29 15:22:47 +07:00
hamsa0x7
eff81b1242 fix(kiro): strip leaked <thinking> tags from content stream (#2158)
CodeWhisperer leaks literal <thinking> blocks into assistantResponseEvent,
duplicating reasoning already routed via reasoningContentEvent. Track
inThinking state to strip these tags during SSE transform, handling split
chunks across tag boundaries.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:14:15 +07:00
Emirhan
b66b5c68ce feat(quota): add opt-in Codex auto-ping
Generalize Claude 5h auto-ping into a provider-generic scheduler and add
opt-in Codex auto-ping that warms the next 5h window via a tiny gpt-5.5
request when session.resetAt slides. Default off, per-connection toggle,
failure cooldown, blocking-quota skip, drains stream before success.

Closes #2107

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:13:17 +07:00
Emirhan
fc8722e897 fix(tray): make Windows context menu DPI-aware
Set process DPI awareness (Per-Monitor V2 with fallbacks) and enable
WinForms visual styles before creating the tray NotifyIcon, so the
Windows context menu renders sharply on DPI-scaled displays.

Closes #2161

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:12:24 +07:00
jellylarper
713c563765 fix(kilocode): expose full gateway catalog in combo model picker
Add modelsFetcher + passthroughModels so the dynamic Kilo Gateway
catalog surfaces in the combo model picker, matching openrouter.js.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:12:18 +07:00
iletai
3d20a4ccd2 fix(antigravity): strip deprecated/readOnly/writeOnly from tool schemas
Gemini/Antigravity generateContent rejects the JSON Schema annotation
keywords deprecated, readOnly, writeOnly with a 400 INVALID_ARGUMENT.
MCP tool schemas (e.g. Claude Code) commonly set deprecated:true, making
every request with such a tool fail. Add them to
UNSUPPORTED_SCHEMA_CONSTRAINTS so cleanJSONSchemaForAntigravity removes
them recursively before the request is sent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:12:17 +07:00
decolua
526235872a Fix OpenCode Go GLM 2026-06-29 15:00:03 +07:00
648 changed files with 65922 additions and 9348 deletions

View File

@@ -14,6 +14,10 @@ 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
@@ -34,5 +38,8 @@ 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

17
.gitignore vendored
View File

@@ -75,3 +75,20 @@ gitbook/README.md
# Refactor backup reference (do not bundle/lint)
open-sse.old/
.graphifyignore
graphify-out/*
# Local-only working dirs (notes, vendored repos, scripts, skills)
.claude/
.docs/
.repo/
.script/
.codegraph/
.PR/
.next-analyze/*
# CommandCode CLI local state (auth/taste/projects)
.commandcode/
# Pi subagent run artifacts
.pi-subagents/

View File

@@ -1,3 +1,347 @@
# 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
@@ -20,6 +364,7 @@
- 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
@@ -280,44 +625,4 @@
# 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
# 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)
- Tunnel public URL changed — old tunnel links no longer work, please reconnect to get the new URL

91
CLAUDE.md Normal file
View File

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

View File

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

327
README.md
View File

@@ -13,11 +13,12 @@
[![GHCR](https://img.shields.io/badge/GHCR-decolua%2F9router-blue?logo=github)](https://github.com/decolua/9router/pkgs/container/9router)
[![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE)
<a href="https://trendshift.io/repositories/22628" target="_blank"><img src="https://trendshift.io/api/badge/repositories/22628" alt="decolua%2F9router | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com)
<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)
[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md)
</div>
---
@@ -83,7 +84,7 @@ npm install -g 9router
**2. Connect a FREE provider (no signup needed):**
Dashboard → Providers → Connect **Kiro AI** (free Claude unlimited) or **OpenCode Free** (no auth) → Done!
Dashboard → Providers → Connect **Kiro AI** (~50 credits/month free: Claude 4.5 + GLM-5 + MiniMax) or **OpenCode Free** (no auth) → Done!
**3. Use in your CLI tool:**
@@ -114,6 +115,7 @@ 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`
@@ -125,6 +127,20 @@ 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"/>
@@ -133,11 +149,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://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 href="https://youtu.be/3dF5GIYMrcQ?si=bAyfyiHbARJQAHj_">
<img src="https://img.youtube.com/vi/3dF5GIYMrcQ/hqdefault.jpg" alt="9Router Setup Tutorial" 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>
<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>
</td>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=o3qYCyjrFYg">
@@ -169,8 +185,6 @@ 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"/>
@@ -186,6 +200,25 @@ 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>
@@ -252,6 +285,32 @@ 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>
@@ -284,6 +343,10 @@ 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>
@@ -296,12 +359,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/>Unlimited FREE</sub>
<sub>Claude 4.5 + GLM-5 + MiniMax<br/>50 credits/month 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/>Unlimited FREE</sub>
<sub>No auth • Auto-fetch models<br/>Free (model list varies)</sub>
</td>
<td align="center" width="150">
<img src="./public/providers/gemini.png" width="70" alt="Vertex AI"/><br/>
@@ -312,7 +375,11 @@ Default URLs:
</table>
</div>
> **Note:** iFlow, Qwen and Gemini CLI free tiers were discontinued in 2026. Use Kiro / OpenCode Free / Vertex instead.
> **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.
### 🔑 API Key Providers (40+)
@@ -400,26 +467,68 @@ 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 |
| 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.
<details>
<summary><b>📖 Feature Details</b></summary>
@@ -470,7 +579,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.
@@ -506,6 +615,7 @@ 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
@@ -559,14 +669,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 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
>
> **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
> routing through 9Router!
### 🌐 Deploy Anywhere
@@ -582,19 +692,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 | 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 |
| 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) |
**💡 Pro Tip:** RTK + Kiro AI + OpenCode Free combo = **$0 cost + 20-40% token savings**!
@@ -607,7 +717,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** (iFlow, Kiro, Qwen = $0 unlimited)
✅ **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
❌ **9Router never sends invoices** or charges your card
**How Cost Display Works:**
@@ -615,6 +725,7 @@ 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
@@ -622,12 +733,13 @@ Dashboard Display:
• Display Cost: $290
Reality Check:
• Provider: iFlow (FREE unlimited)
• Provider: Kiro (free tier: ~50 credits/mo)
• 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
@@ -642,6 +754,7 @@ Reality Check:
**Problem:** Quota expires unused, rate limits during heavy coding
**Solution:**
```
Combo: "maximize-claude"
1. cc/claude-opus-4-7 (use subscription fully)
@@ -657,9 +770,10 @@ 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 unlimited)
1. kr/claude-sonnet-4.5 (Claude 4.5 free via Kiro, ~50 credits/mo)
2. kr/glm-5 (GLM-5 free via Kiro)
3. oc/<auto> (OpenCode Free, no auth)
@@ -672,13 +786,14 @@ 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 unlimited)
5. kr/claude-sonnet-4.5 (free via Kiro, ~50 credits/mo)
Result: 5 layers of fallback = zero downtime
Monthly cost: $20-200 (subscriptions) + $10-20 (backup)
@@ -689,6 +804,7 @@ 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)
@@ -709,8 +825,9 @@ 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 iFlow (FREE unlimited)
- **Reality:** You're using Kiro free models (~50 credits/mo)
- **Your actual cost:** **$0.00**
- **What $290 means:** Amount you **saved** by using free models instead of paid APIs!
@@ -724,6 +841,7 @@ 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**
@@ -735,19 +853,21 @@ The cost display is a "savings tracker" to help you understand your usage patter
<details>
<summary><b>🆓 Are FREE providers really unlimited?</b></summary>
**Yes!** The current FREE providers (Kiro, OpenCode Free, Vertex) are genuinely free with **no hidden charges**.
**Mostly!** The current FREE providers (Kiro, OpenCode Free, Vertex) are genuinely free, but free tiers have limits:
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)
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.
- **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.
**Discontinued free tiers (no longer recommended):**
- ❌ **iFlow**: Was free unlimited, now changed to paid (2026)
- ❌ **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
- ❌ **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.
</details>
@@ -757,17 +877,21 @@ These are free services offered by those respective companies:
**Free-First Strategy:**
1. **Start with 100% free combo:**
```
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)
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)
```
**Cost: $0/month**
**Cost: $0/month** (within Kiro's free credit cap; OpenCode/Vertex subject to their free-tier limits)
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:**
@@ -786,10 +910,12 @@ 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
@@ -983,7 +1109,7 @@ Monthly cost example (100M tokens):
```
Name: free-combo
Models:
1. kr/claude-sonnet-4.5 (Claude 4.5 free unlimited)
1. kr/claude-sonnet-4.5 (Claude 4.5 free via Kiro, ~50 credits/mo)
2. kr/glm-5 (GLM-5 free via Kiro)
3. vertex/gemini-3.1-pro-preview ($300 free credits)
@@ -1113,6 +1239,7 @@ 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)
@@ -1140,6 +1267,7 @@ docker run -d --name 9router -p 20128:20128 \
```
**Container defaults:**
- `PORT=20128`
- `HOSTNAME=0.0.0.0`
@@ -1156,26 +1284,28 @@ 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 |
| 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 |
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.
@@ -1198,6 +1328,7 @@ 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`
@@ -1205,6 +1336,7 @@ Notes:
- `cc/claude-haiku-4-5-20251001`
**Codex (`cx/`)** - Plus/Pro:
- `cx/gpt-5.5`
- `cx/gpt-5.4`
- `cx/gpt-5.3-codex`
@@ -1212,6 +1344,7 @@ Notes:
- `cx/gpt-5.1-codex-max`
**GitHub Copilot (`gh/`)**:
- `gh/gpt-5.4`
- `gh/claude-opus-4.7`
- `gh/claude-sonnet-4.6`
@@ -1219,25 +1352,30 @@ 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 unlimited:
**Kiro (`kr/`)** - Free (~50 credits/month, paid tiers above):
- `kr/claude-sonnet-4.5`
- `kr/claude-haiku-4.5`
- `kr/glm-5`
@@ -1246,9 +1384,11 @@ 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`
@@ -1262,31 +1402,38 @@ 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`
---
@@ -1349,8 +1496,6 @@ Thanks to all contributors who helped make 9Router better!
[![Star Chart](https://starchart.cc/decolua/9router.svg?variant=adaptive)](https://starchart.cc/decolua/9router)
## 🔀 Forks
**[OmniRoute](https://github.com/diegosouzapw/OmniRoute)** — A full-featured TypeScript fork of 9Router. Adds 36+ providers, 4-tier auto-fallback, multi-modal APIs (images, embeddings, audio, TTS), circuit breaker, semantic cache, LLM evaluations, and a polished dashboard. 368+ unit tests. Available via npm and Docker.
@@ -1363,8 +1508,8 @@ Built on the shoulders of giants:
- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — original Go implementation that inspired this JavaScript port.
- **[RTK](https://github.com/rtk-ai/rtk)** ![Stars](https://img.shields.io/github/stars/rtk-ai/rtk?style=flat&color=yellow) — Rust token-saver. 9Router ports its compression pipeline to JS → **−20-40% input tokens** on every request.
- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) by **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral *"why use many token when few token do trick"*. 9Router adapts its prompt → **−65% output tokens**.
- **[Ponytail](https://github.com/DietrichGebert/ponytail)** ![Stars](https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat&color=yellow) by **[@DietrichGebert](https://github.com/DietrichGebert)** — *"lazy senior dev"* skill. 9Router injects its YAGNI-first ladder → **fewer tokens, less code, shorter diffs**.
- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) by **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral _"why use many token when few token do trick"_. 9Router adapts its prompt → **−65% output tokens**.
- **[Ponytail](https://github.com/DietrichGebert/ponytail)** ![Stars](https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat&color=yellow) by **[@DietrichGebert](https://github.com/DietrichGebert)** — _"lazy senior dev"_ skill. 9Router injects its YAGNI-first ladder → **fewer tokens, less code, shorter diffs**.
Huge thanks to these authors — without their work, 9Router's token-saving features wouldn't exist. ⭐ them on GitHub!

View File

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

View File

@@ -4,8 +4,28 @@ 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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -47,6 +67,19 @@ 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.
@@ -119,6 +152,11 @@ 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") {
@@ -212,17 +250,18 @@ function killCloudflaredByAppPort(appPort) {
function killAllAppProcesses(appPort) {
return new Promise((resolve) => {
try {
// Kill MIT first (privileged process, needs special handling)
killProxyByPidFile();
// Kill cloudflared/tailscale by PID file (precise, only this app's tunnel)
killTunnelByPidFile();
// 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 {}
});
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 {
@@ -499,14 +538,11 @@ if (!fs.existsSync(serverPath)) {
process.exit(1);
}
// Check for updates FIRST, then start server
checkForUpdate().then((latestVersion) => {
killAllAppProcesses(port).then(() => {
return killProcessOnPort(port);
}).then(() => {
startServer(latestVersion);
});
});
// Start server immediately; run update check in parallel (not on the critical path).
const updatePromise = checkForUpdate();
killAllAppProcesses(port)
.then(() => killProcessOnPort(port))
.then(() => startServer(updatePromise));
// Show interface selection menu
async function showInterfaceMenu(latestVersion) {
@@ -556,7 +592,9 @@ async function showInterfaceMenu(latestVersion) {
const MAX_RESTARTS = 2;
const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s
function startServer(latestVersion) {
function startServer(updatePromise) {
// Accept either a Promise (parallel update check) or a resolved value.
const latestVersionPromise = Promise.resolve(updatePromise);
const displayHost = getDisplayHost();
const url = `http://${displayHost}:${port}/dashboard`;
// Surface real network exposure when bound to all interfaces (default 0.0.0.0).
@@ -574,7 +612,7 @@ function startServer(latestVersion) {
function spawnServer() {
serverStartTime = Date.now();
crashLog = [];
const child = spawn(RUNTIME, ["--max-old-space-size=6144", serverPath], {
const child = spawn(RUNTIME, ["--dns-result-order=ipv4first", "--max-old-space-size=6144", serverPath], {
cwd: standaloneDir,
stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"],
detached: true,
@@ -677,17 +715,19 @@ function startServer(latestVersion) {
console.log(`\n🚀 ${pkg.name} v${pkg.version}`);
console.log(`Server: http://${displayHost}:${port}`);
setTimeout(() => {
waitServerReady(port).then(() => {
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
setTimeout(async () => {
waitServerReady(port).then(async () => {
// Resolve parallel update check (already running); don't block server start on it.
const latestVersion = await latestVersionPromise;
// Start tray icon alongside TUI
initTrayIcon();
@@ -745,7 +785,7 @@ function startServer(latestVersion) {
// 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, [__filename, "--tray", "--skip-update", "-p", port.toString()], {
const bgProcess = spawn(process.execPath, ["--dns-result-order=ipv4first", __filename, "--tray", "--skip-update", "-p", port.toString()], {
detached: true,
stdio: "ignore",
windowsHide: true,
@@ -772,7 +812,7 @@ function startServer(latestVersion) {
cleanup();
process.exit(1);
}
}, 3000);
});
function attachServerEvents() {
server.on("error", (err) => {

View File

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

View File

@@ -7,7 +7,7 @@ const { execSync } = require("child_process");
const cliDir = path.resolve(__dirname, "..");
const appDir = path.resolve(cliDir, "..");
const rootDir = path.resolve(appDir, "..");
const cliAppDir = path.join(cliDir, "app");
const cliAppDir = process.env.NINEROUTER_CLI_APP_DIR || path.join(cliDir, "app");
const buildHomeDir = path.join(cliDir, ".build-home");
const buildDistDirName = ".next-cli-build";
const buildDistDir = path.join(appDir, buildDistDirName);
@@ -81,201 +81,274 @@ function copyRecursive(src, dest) {
}
}
console.log("📦 Building 9Router CLI package with Next.js...\n");
function resolveStandaloneBuild(appDir, buildDistDir) {
const legacyStandaloneRoot = path.join(appDir, ".next", "standalone");
const resolvedStandaloneRoot = path.join(buildDistDir, "standalone");
let standaloneRoot = fs.existsSync(resolvedStandaloneRoot)
? resolvedStandaloneRoot
: legacyStandaloneRoot;
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...");
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");
}
// 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;
// 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;
}
const candidates = [
path.join(appDir, "node_modules", pkg),
path.join(rootDir, "node_modules", pkg),
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 };
}
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"));
}
}
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);
}
function assertRequiredApiArtifacts(cliAppDir) {
const requiredArtifacts = [
"app/api/v1/chat/completions/route.js",
"app/api/v1/messages/route.js",
];
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;
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"),
);
}
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");
}
// 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");
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 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");
}
module.exports = {
assertRequiredApiArtifacts,
copyStandaloneBuild,
mergeServerArtifacts,
};
// 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
if (require.main === module) {
buildCliPackage();
}

View File

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

View File

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

View File

@@ -53,6 +53,12 @@ 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" },
@@ -78,6 +84,7 @@ const PROVIDER_MODELS = {
{ id: "grok-code-fast-1" },
],
kr: [
{ id: "claude-sonnet-5" },
{ id: "claude-sonnet-4.5" },
{ id: "claude-haiku-4.5" },
],
@@ -94,6 +101,8 @@ 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" },
@@ -130,7 +139,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 Coding" },
kimi: { id: "kimi", name: "Kimi" },
openai: { id: "openai", name: "OpenAI" },
anthropic: { id: "anthropic", name: "Anthropic" },
gemini: { id: "gemini", name: "Gemini" },

View File

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

View File

@@ -1,7 +1,51 @@
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.
@@ -22,11 +66,74 @@ 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);
};
return origCreate(...rest, wrapped);
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;
};
require("./server.js");
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);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

View File

@@ -0,0 +1,122 @@
# 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 Normal file

File diff suppressed because it is too large Load Diff

1442
i18n/README.fa_IR.md Normal file

File diff suppressed because it is too large Load Diff

1445
i18n/README.fr.md Normal file

File diff suppressed because it is too large Load Diff

951
i18n/README.id-ID.md Normal file
View File

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

1526
i18n/README.pt-BR.md Normal file

File diff suppressed because it is too large Load Diff

723
i18n/README.th.md Normal file
View File

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

View File

@@ -1,21 +1,15 @@
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
# 9Router - Free AI Router & Token Saver
**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.**
**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ẻ.**
**Nhà cung cấp AI Miễn cho OpenClaw.**
<p align="center">
<img src="../public/providers/openclaw.png" alt="OpenClaw" width="80"/>
</p>
**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.**
[![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router)
[![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router)
[![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE)
[![License](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>
@@ -24,19 +18,21 @@ Dưới đây là bản dịch tiếng Việt của tài liệu Markdown, giữ
## 🤔 Tại sao chọn 9Router?
**Ngừng lãng phí tiền bạc và gặp phải giới hạn:**
**Ngừng lãng phí tiền bạc, token và không bao giờ lo chạm giới hạn (rate limit):**
- ❌ 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) 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
- ❌ 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
**9Router giải quyết vấn đề này:**
- ✅ **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
- ✅ **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
---
@@ -44,25 +40,26 @@ Dưới đây là bản dịch tiếng Việt của tài liệu Markdown, giữ
```
┌─────────────┐
│ Your CLI │ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...)
│ Tool │
│ Công cụ │ (Claude Code, Codex, OpenClaw, Cursor, Cline, Antigravity...)
│ CLI AI │
└──────┬──────┘
│ http://localhost:20128/v1
↓
┌────────────────────────────────────────┐
│ 9Router (Smart Router) │
│ • Format translation (OpenAI ↔ Claude) │
│ • Quota tracking │
│ • Auto token refresh │
└──────┬──────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ 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 │
└──────┬──────────────────────────────────────┘
│
├─→ [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)
├─→ [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)
Result: Never stop coding, minimal cost
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
```
---
@@ -76,26 +73,26 @@ npm install -g 9router
9router
```
🎉 Bảng điều khiển mở tại `http://localhost:20128`
🎉 Bảng điều khiển (Dashboard) sẽ tự động 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 **ude Code** hoặc **Antigravity** -> Đăng nhập OAuth -> Xong!
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!
**3. Sử dụng trong công cụ CLI của bạn:**
```
Cài đặt Claude Code/Codex/Gemini CLI/OpenClaw/Cursor/Cline:
Cài đặt Claude Code/Codex/OpenClaw/Cursor/Cline/Antigravity:
Endpoint: http://localhost:20128/v1
API Key: [sao chép từ bảng điều khiển]
Model: if/kimi-k2-thinking
Model: kr/claude-sonnet-4.5
```
**Xong rồi!** Bắt đầu code với các mô hình AI MIỄN PHÍ.
**Thế là xong!** Bắt đầu code ngay với các mô hình AI MIỄN PHÍ.
**Phương án khác: chạy từ nguồn (k lưu trữ này):**
**Phương án khác: chạy từ mã nguồn (repository này):**
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.
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.
```bash
cp .env.example .env
@@ -111,11 +108,12 @@ 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: `http://localhost:20128/dashboard`
- Bảng điều khiển Dashboard: `http://localhost:20128/dashboard`
- API tương thích OpenAI: `http://localhost:20128/v1`
---
## 🎥 Hướng dẫn Video
<div align="center">

View File

@@ -42,25 +42,26 @@
```
┌─────────────┐
│ Your CLI │ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...)
│ Your CLI │ (Claude Code, Codex, OpenClaw, Cursor, Cline, Antigravity...)
│ Tool │
└──────┬──────┘
│ http://localhost:201281
│ http://localhost:20128/v1
↓
┌─────────────────────────────────────────┐
│ 9Router (Smart Router) │
│ • Format translation (OpenAI ↔ Claude) │
│ • Quota tracking │
│ • Auto token refresh │
└──────┬──────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ 9Router (Smart Router) │
│ • RTK Token Saver (节省 20-40% Token) │
│ • 格式转换 (OpenAI ↔ Claude) │
│ • 配额追踪 (Quota tracking) │
│ • 自动刷新 OAuth Token │
└──────┬──────────────────────────────────────┘
│
├─→ [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)
├─→ [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)
Result: Never stop coding, minimal cost
结果:永不停歇的编程体验,最低成本 + 通过 RTK 节省 20-40% Token
```
---

View File

@@ -13,7 +13,14 @@ const proxyClientMaxBodySize = process.env.NINEROUTER_PROXY_CLIENT_MAX_BODY_SIZE
const nextConfig = {
distDir: process.env.NEXT_DIST_DIR || ".next",
output: "standalone",
serverExternalPackages: ["better-sqlite3", "sql.js", "node:sqlite", "bun:sqlite"],
// `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"],
turbopack: {
root: tracingRoot
},
@@ -30,6 +37,8 @@ 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

View File

@@ -37,3 +37,7 @@ Provider-agnostic SSE engine: one OpenAI-style request → any provider (LLM cha
- `registry/index.js` is an auto-generated static import list; regenerate it (don't hand-edit) after adding a `registry/{id}.js`. REGISTRY_TEMPLATE is excluded by design.
- Special binary/protobuf formats (kiro EventStream, cursor protobuf, commandcode NDJSON) don't round-trip through OpenAI — handle in their executor.
- `rtk/` + `headroom.js` mutate the request body in-place and are **fail-open**: any error returns null and leaves the body untouched — never throw out of them. RTK skips `is_error`/`status:"error"` tool results to preserve traces.
- **HTTP 200 in-stream errors**: some upstreams signal failure INSIDE a 200 stream (AI SDK v5 `{"type":"error"}` events, error text in content). HTTP-level success checks miss these → no fallback, `Status: success` in logs. Three hook points + one config escape hatch:
1. **Translator** — never map an error event to content. Emit an OpenAI-shaped `chunk.error = { message, type }` + terminal chunk (`translator/response/commandcode-to-openai.js` is the worked example). Downstream `parseSSEToOpenAIResponse` already detects `chunk?.error`.
2. **Executor early-peek** — for streaming fallback, read the first events BEFORE returning the response; an error → non-ok Response (`executors/commandcode.js` `peekForUpstreamError`).
3. **Config escape hatch (no code)** — per-provider `streamErrorPatterns` setting (UI: provider page → Stream Error Patterns). Patterns matched against the first ~8KB of the stream and the assembled non-streaming content; see `utils/streamErrorPeek.js` + `utils/streamErrorPatterns.js`.

View File

@@ -1,5 +1,7 @@
import { platform, arch } from "os";
import { platform, arch, hostname } 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;
@@ -59,7 +61,7 @@ export function getPlatformEnum() {
}
export function getPlatformUserAgent() {
return `antigravity/1.104.0 ${platform()}/${arch()}`;
return ANTIGRAVITY_IDE_USER_AGENT;
}
export const CLIENT_METADATA = {
@@ -129,13 +131,22 @@ export const AG_DEFAULT_TOOLS = new Set([
// Antigravity chat/stream headers
export const ANTIGRAVITY_HEADERS = {
"User-Agent": `antigravity/1.107.0 ${platform()}/${arch()}`
"User-Agent": ANTIGRAVITY_IDE_USER_AGENT
};
// Cloud Code Assist API
// Cloud Code Assist API endpoints differ by client ecosystem.
export const CLOUD_CODE_API = {
loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
"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",
},
};
export const LOAD_CODE_ASSIST_HEADERS = {
@@ -145,6 +156,13 @@ 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(),
@@ -165,17 +183,48 @@ 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 },
};
// Generate Kimi OAuth custom headers
export function buildKimiHeaders() {
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()}`;
return {
"X-Msh-Platform": "9router",
"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()}`
"X-Msh-Version": getAppPackageVersion(),
"X-Msh-Device-Name": deviceName,
"X-Msh-Device-Model": deviceModel,
"X-Msh-Device-Id": resolvedId,
};
}

View File

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

View File

@@ -8,18 +8,24 @@
* - `-agentic` model suffix detection + chunked-write system prompt
* - reasoning / thinking trigger detection (Anthropic-Beta header,
* Claude `thinking`, OpenAI `reasoning_effort`, AMP/Cursor magic tag)
* - the `<thinking_mode>enabled</thinking_mode>` system-prompt injection
* that turns Kiro reasoning on
* - schema-specific native effort fields for supported GPT and Claude models
* - legacy `<thinking_mode>` system-prompt injection for other models
*
* 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 } from "../translator/concerns/thinkingUnified.js";
import { extractThinking, parseSuffix } 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
@@ -40,6 +46,39 @@ 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)
@@ -109,6 +148,7 @@ 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;
@@ -131,6 +171,86 @@ export function resolveKiroThinkingBudget(body, headers, model) {
return null;
}
export function extractKiroEffortLevel(body) {
const effort =
body?.output_config?.effort ??
body?.reasoning_effort ??
(typeof body?.reasoning === "object" ? body.reasoning?.effort : null);
if (typeof effort !== "string") return null;
const normalized = effort.toLowerCase();
if (normalized === "none" || normalized === "off" || normalized === "disabled") return null;
if (normalized === "xhigh" || normalized === "max") return "high";
if (["low", "medium", "high"].includes(normalized)) return normalized;
return null;
}
function extractKiroGptEffortLevel(body) {
const effort =
body?.output_config?.effort ??
body?.reasoning_effort ??
(typeof body?.reasoning === "object" ? body.reasoning?.effort : null);
if (typeof effort !== "string") return null;
const normalized = effort.toLowerCase();
if (normalized === "max") return "xhigh";
// Kiro CLI does not advertise an explicit GPT "none" wire value; omit it.
if (["low", "medium", "high", "xhigh"].includes(normalized)) {
return normalized;
}
return null;
}
export function buildKiroAdditionalModelRequestFields(body, effortPath = "output_config") {
const effort = effortPath === "reasoning"
? extractKiroGptEffortLevel(body)
: extractKiroEffortLevel(body);
if (!effort) return undefined;
if (effortPath === "reasoning") {
// Mirrors Kiro CLI/KAS buildEffortRequestFields("reasoning") for GPT.
return { reasoning: { effort } };
}
// Mirrors Kiro CLI/KAS buildEffortRequestFields("output_config").
return {
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort },
};
}
export function resolveKiroEffortPath(model) {
if (typeof model !== "string") return null;
const normalized = model.toLowerCase().replace(/-/g, ".");
if (/(?:^|[/.])gpt[/.]5[/.]6(?:[/.]|$)/.test(normalized)) {
return "reasoning";
}
if (!normalized.includes("claude")) return null;
const match = normalized.match(/(?:^|[/.])claude(?:[/.][a-z]+)*[/.](\d+)(?:[/.](\d+))?(?:[/.]|$)/);
if (!match) return null;
const [, majorText, minorText] = match;
const major = Number(majorText);
const minor = minorText === undefined ? null : Number(minorText);
const dateSuffixMinor = minor !== null && minor >= 1000;
// Kiro rejected additionalModelRequestFields on legacy 4.5 models in live smoke.
// Default future Claude/Kiro models to supported so new model releases do not
// need a code allowlist update.
return major < 4 || (major === 4 && (minor === null || minor <= 5 || dateSuffixMinor))
? null
: "output_config";
}
export function supportsKiroAdditionalModelRequestFields(model) {
return resolveKiroEffortPath(model) !== null;
}
export function usesKiroNativeGptEffort(body, model) {
return resolveKiroEffortPath(model) === "reasoning"
&& extractKiroGptEffortLevel(body) !== null;
}
export function buildKiroAdditionalModelRequestFieldsForModel(body, model) {
const effortPath = resolveKiroEffortPath(model);
if (!effortPath) return undefined;
return buildKiroAdditionalModelRequestFields(body, effortPath);
}
/**
* Detect whether an inbound request is asking for reasoning / thinking output.
* Thin wrapper over resolveKiroThinkingBudget (single source of truth).

View File

@@ -2,9 +2,8 @@ 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 } from "../providers/models/schema.js";
import { modelQuotaFamily, modelStrip, modelTargetFormat, modelSupportedFormats, normalizeModelId } from "../providers/models/schema.js";
import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js";
export { PROVIDER_MODELS };
@@ -18,46 +17,82 @@ 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 models.some(m => m.id === modelId);
return !!findModel(models, modelId, aliasOrId);
}
export function findModelName(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId];
if (!models) return modelId;
const found = models.find(m => m.id === modelId);
const found = findModel(models, modelId, aliasOrId);
return found?.name || modelId;
}
export function getModelTargetFormat(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId];
if (!models) return null;
return modelTargetFormat(models.find(m => m.id === modelId));
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));
}
export function getModelType(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId];
if (!models) return null;
const found = models.find(m => m.id === modelId);
const found = findModel(models, modelId, aliasOrId);
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 = 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);
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);
}
return modelId;
if (aliasOrId === "cx" && typeof baseId === "string" && baseId.endsWith(CODEX_REVIEW_SUFFIX)) {
return baseId.slice(0, -CODEX_REVIEW_SUFFIX.length) + suffix;
}
return baseId + suffix;
}
export function getModelQuotaFamily(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId];
return modelQuotaFamily(models?.find(m => m.id === modelId));
return modelQuotaFamily(findModel(models, modelId, aliasOrId));
}
// OAuth short aliases — derived from registry `alias` (single source). everything else: alias = id.
@@ -79,5 +114,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(PROVIDER_MODELS[alias]?.find(m => m.id === modelId));
return modelStrip(findModel(PROVIDER_MODELS[alias], modelId, alias));
}

View File

@@ -39,6 +39,15 @@ 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);
@@ -56,6 +65,8 @@ export const GEMINI_NATIVE_TTS_FETCH_TIMEOUT_MS = envMs("GEMINI_NATIVE_TTS_FETCH
export const DEFAULT_MAX_TOKENS = 64000;
export const DEFAULT_MIN_TOKENS = 32000;
export const TOKEN_SAVER_HEADER = "x-9router-token-saver";
// Retry config for 429 responses (legacy - kept for backward compatibility)
export const RETRY_CONFIG = {
maxAttempts: 2,

View File

@@ -33,6 +33,21 @@ 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: {
@@ -107,6 +122,14 @@ export const TTS_MODELS_CONFIG = {
},
allVoices: GEMINI_VOICES,
},
"xiaomi-mimo": {
models: [
{ id: "mimo-v2.5-tts", name: "MiMo V2.5 TTS", type: "tts" },
],
voices: {
"mimo-v2.5-tts": MIMO_VOICES,
},
},
};
// ── Helper: get voices for a specific model ────────────────────────────────

View File

@@ -1,11 +1,12 @@
import crypto from "crypto";
import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js";
import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, INTERNAL_REQUEST_HEADER, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX } from "../config/appConstants.js";
import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, 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,7 +18,8 @@ function sanitizeFunctionName(name) {
const MAX_RETRY_AFTER_MS = 10000;
const ANTIGRAVITY_TRANSIENT_RETRY_MAX_MS = 15000;
const MAX_ANTIGRAVITY_OUTPUT_TOKENS = 16384;
const MAX_ANTIGRAVITY_OUTPUT_TOKENS = 64000;
const ANTIGRAVITY_IDE_REQUEST_ID_RE = /^agent\/[^/]+\/\d+\/[^/]+\/\d+$/;
const ANTIGRAVITY_TRANSIENT_ERROR_PATTERNS = [
/high\s+traffic/i,
@@ -86,6 +88,27 @@ 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);
@@ -103,20 +126,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);
@@ -141,25 +164,26 @@ 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: `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
},
requestId: buildIdeRequestId({ body, request, credentials, model: cleanModel, requestType: "image_gen" }),
request,
};
}
@@ -177,8 +201,19 @@ export class AntigravityExecutor extends BaseExecutor {
if (p.thoughtSignature && !p.functionCall && !p.text) return false;
return true;
});
if (role !== c.role || parts?.length !== c.parts?.length) {
return { ...c, role, parts };
// 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,
};
}
return c;
});
@@ -210,6 +245,18 @@ export class AntigravityExecutor extends BaseExecutor {
// 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;
@@ -233,10 +280,10 @@ export class AntigravityExecutor extends BaseExecutor {
return {
...body,
project: projectId,
model: model,
model: body.model || model,
userAgent: "antigravity",
requestType: "agent",
requestId: `agent-${crypto.randomUUID()}`,
requestId: buildIdeRequestId({ body, request: transformedRequest, credentials, model, requestType: "agent" }),
request: transformedRequest
};
}

View File

@@ -2,7 +2,9 @@ import { HTTP_STATUS, RETRY_CONFIG, DEFAULT_RETRY_CONFIG, resolveRetryEntry, FET
import { shouldRefreshCredentials } from "../services/oauthCredentialManager.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { dbg } from "../utils/debugLog.js";
import { resolveProviderTimeoutMs } from "../services/providerTimeout.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
@@ -30,7 +32,7 @@ export class BaseExecutor {
if (this.provider?.startsWith?.("openai-compatible-")) {
const baseUrl = credentials?.providerSpecificData?.baseUrl || OPENAI_COMPAT_BASE;
const normalized = baseUrl.replace(/\/$/, "");
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
const path = resolveOpenAICompatibleApiType(this.provider, credentials) === "responses" ? "/responses" : "/chat/completions";
return `${normalized}${path}`;
}
if (this.provider?.startsWith?.("anthropic-compatible-")) {
@@ -126,19 +128,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);
const headers = this.buildHeaders(credentials, stream, url, model);
if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0;
// Abort if upstream doesn't return response headers within connection timeout
const connectCtrl = new AbortController();
const timeoutMs = this.config?.timeoutMs || FETCH_CONNECT_TIMEOUT_MS;
const timeoutMs = await resolveProviderTimeoutMs(this.provider, this.config?.timeoutMs, FETCH_CONNECT_TIMEOUT_MS);
const connectTimer = setTimeout(() => connectCtrl.abort(new Error("fetch connect timeout")), timeoutMs);
const mergedSignal = signal ? AbortSignal.any([signal, connectCtrl.signal]) : connectCtrl.signal;
let fetchT0 = 0;
try {
const bodyStr = JSON.stringify(transformedBody);
const fetchT0 = Date.now();
fetchT0 = Date.now();
dbg("FETCH", `${this.provider.toUpperCase()} → ${url} | body=${bodyStr.length}B | connectTimeout=${timeoutMs}ms`);
const response = await proxyAwareFetch(url, {
method: "POST",
@@ -164,6 +167,11 @@ export class BaseExecutor {
clearTimeout(connectTimer);
lastError = error;
const isConnectTimeout = connectCtrl.signal.aborted && error.name === "AbortError";
// Error diagnostic — only logs on actual upstream failure. Distinguishes
// undici connect timeout (UND_ERR_CONNECT_TIMEOUT), DNS (ENOTFOUND),
// refused (ECONNREFUSED) vs our own connectCtrl abort (AbortError).
const cause = error?.cause || {};
console.log(`[FETCH-DIAG] ${this.provider} fetch error | name=${error.name} | code=${error.code ?? cause?.code ?? "none"} | msg=${String(error.message).slice(0, 120)} | connectTimeout=${timeoutMs}ms | elapsed=${Date.now() - fetchT0}ms`);
dbg("FETCH", `${this.provider.toUpperCase()} ✖ ${error.name}: ${error.message}${isConnectTimeout ? " (connect timeout)" : ""}`);
// Connect timeout is internal — convert to retryable network error, don't propagate AbortError
if (error.name === "AbortError" && !isConnectTimeout) throw error;

View File

@@ -18,6 +18,35 @@ 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

View File

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

View File

@@ -8,13 +8,22 @@ import {
import { normalizeResponsesInput } from "../translator/formats/responsesApi.js";
import { fetchImageAsBase64 } from "../translator/concerns/image.js";
import { getModelUpstreamId } from "../config/providerModels.js";
import { DEFAULT_RETRY_CONFIG, resolveRetryEntry } from "../config/runtimeConfig.js";
import { getThinkingLevels } from "../providers/thinkingLevels.js";
import { DEFAULT_RETRY_CONFIG, HTTP_STATUS, resolveRetryEntry } from "../config/runtimeConfig.js";
import { dbg } from "../utils/debugLog.js";
import { resolveSessionId } from "../utils/sessionManager.js";
// 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;
// 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.";
// Server-generated item id prefixes that Codex /responses cannot resolve when store=false
const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/;
@@ -116,6 +125,66 @@ 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
@@ -135,10 +204,17 @@ 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";
// 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;
// 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;
}
return headers;
}
@@ -198,7 +274,7 @@ export class CodexExecutor extends BaseExecutor {
let attempt = 0;
while (true) {
const result = await super.execute(args);
const peek = await this._peekSseOverloaded(result.response);
const peek = await this._peekSseTransientError(result.response);
if (!peek.matched) {
// Replace body with re-assembled stream (prefix bytes already read + rest)
if (peek.replacementBody) {
@@ -210,48 +286,57 @@ 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})`);
// 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,
});
}
result.response = codexSseErrorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, peek.message || peek.matched);
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 "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 };
// 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 };
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 hit = CODEX_SSE_OVERLOADED_PATTERNS.find(p => text.includes(p));
if (hit) { matched = hit; break; }
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;
}
} 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
@@ -273,7 +358,7 @@ export class CodexExecutor extends BaseExecutor {
try { upstreamReader?.cancel(reason); } catch { /* noop */ }
},
});
return { matched, replacementBody };
return { matched: null, message: null, accountFallback: false, replacementBody };
}
// Parse Codex usage_limit_reached to extract precise resetsAtMs; fallback to default otherwise
@@ -347,7 +432,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', 'low', 'medium', 'high', 'xhigh'];
const effortLevels = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'];
let modelEffort = null;
for (const level of effortLevels) {
if (body.model.endsWith(`-${level}`)) {
@@ -360,10 +445,11 @@ export class CodexExecutor extends BaseExecutor {
// Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (medium)
if (!body.reasoning) {
const effort = body.reasoning_effort || modelEffort || 'low';
const effort = normalizeReasoningEffort(body.model, body.reasoning_effort || modelEffort || 'low');
body.reasoning = { effort, summary: "auto" };
} else if (!body.reasoning.summary) {
body.reasoning.summary = "auto";
} else {
body.reasoning.effort = normalizeReasoningEffort(body.model, body.reasoning.effort);
if (!body.reasoning.summary) body.reasoning.summary = "auto";
}
delete body.reasoning_effort;
@@ -391,6 +477,9 @@ export class CodexExecutor extends BaseExecutor {
delete body.safety_identifier; // Droid CLI sends this but Codex doesn't support it
delete body.previous_response_id; // store=false → backend can't resolve previous resp; avoid 404
if (body.service_tier === "fast") body.service_tier = "priority";
if (body.service_tier && body.service_tier !== "priority") delete body.service_tier;
// Final allowlist filter — strip any unknown field that could trigger upstream "routing_unsupported"
for (const k of Object.keys(body)) {
if (!RESPONSES_API_ALLOWLIST.has(k)) delete body[k];

View File

@@ -1,6 +1,7 @@
import { randomUUID } from "crypto";
import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { commandCodeToOpenAIResponse } from "../translator/response/commandcode-to-openai.js";
import { SSE_DONE } from "../utils/sseConstants.js";
@@ -14,81 +15,271 @@ import { SSE_DONE } from "../utils/sseConstants.js";
* We translate each event to an OpenAI chat.completion.chunk and emit it as SSE so
* both the streaming and non-streaming (forced SSE → JSON) downstream handlers in
* 9router can consume it without further format translation.
*
* Terminal upstream failures arrive as `{"type":"error"}` events inside the HTTP
* 200 stream, so a plain `response.ok` check cannot see them. We peek the first
* events before committing the response (see peekForUpstreamError) so a stream
* that starts with an error fails fast — the normal `!response.ok` path then
* triggers account/model fallback instead of streaming fake success content.
*/
export class CommandCodeExecutor extends BaseExecutor {
constructor() {
super("commandcode", PROVIDERS.commandcode);
}
constructor() {
super("commandcode", PROVIDERS.commandcode);
}
transformRequest(model, body, stream, credentials) {
body.stream = true;
return body;
}
transformRequest(_model, body, _stream, _credentials) {
body.stream = true;
return body;
}
buildHeaders(credentials, stream = true) {
const headers = {
"Content-Type": "application/json",
...(this.config.headers || {}),
"x-session-id": randomUUID(),
};
buildHeaders(credentials, stream = true) {
const headers = {
"Content-Type": "application/json",
...(this.config.headers || {}),
"x-session-id": randomUUID(),
};
const token = credentials?.apiKey || credentials?.accessToken;
if (token) headers["Authorization"] = `Bearer ${token}`;
const token = credentials?.apiKey || credentials?.accessToken;
if (token) headers["Authorization"] = `Bearer ${token}`;
if (stream) headers["Accept"] = "text/event-stream";
return headers;
}
if (stream) headers["Accept"] = "text/event-stream";
return headers;
}
async execute(opts) {
const result = await super.execute(opts);
if (!result?.response?.ok || !result.response.body) return result;
result.response = wrapNdjsonAsOpenAISse(result.response, opts.model);
return result;
}
async execute(opts) {
const result = await super.execute(opts);
if (!result?.response?.ok || !result.response.body) return result;
result.response = await peekForUpstreamError(result.response, opts.model, {
signal: opts.signal,
});
return result;
}
}
// How long to hold the response open while peeking the first upstream events.
// An upstream error event ("Network connection lost") is emitted at stream
// start, so the peek is fast; the bound just prevents a slow-started stream
// from being held hostage. Env: COMMANDCODE_PEEK_TIMEOUT_MS.
const PEEK_TIMEOUT_MS = (() => {
const raw = process.env.COMMANDCODE_PEEK_TIMEOUT_MS;
const n = raw ? parseInt(raw, 10) : NaN;
return Number.isFinite(n) && n > 0 ? n : 10 * 1000;
})();
// Event types that count as "the stream has started producing". Everything
// else (start, start-step, reasoning-start, text-start, ...) is metadata and
// does not end the peek.
const MEANINGFUL_EVENT_TYPES = new Set([
"text-delta",
"reasoning-delta",
"tool-input-start",
"tool-input-delta",
"tool-input-end",
"tool-call",
"finish-step",
"finish",
]);
function makeAbortError(reason) {
const error = new Error(reason?.message || reason || "Request aborted");
error.name = "AbortError";
return error;
}
function tryParseEvent(line) {
const trimmed = line.trim();
if (!trimmed) return null;
const json = trimmed.startsWith("data:") ? trimmed.slice(5).trim() : trimmed;
if (!json || json === "[DONE]") return null;
try {
return JSON.parse(json);
} catch {
return null;
}
}
function formatErrorValue(errVal) {
const errStr =
typeof errVal === "string"
? errVal
: typeof errVal?.message === "string"
? errVal.message
: JSON.stringify(errVal);
const errType =
typeof errVal === "string"
? "upstream_error"
: errVal?.type || "upstream_error";
return { message: errStr, type: errType };
}
/**
* Read the first upstream events before committing the response.
*
* - `{"type":"error"}` as the first meaningful event → return a 502 Response so
* chatCore's `!response.ok` path parses the error and triggers fallback.
* - Otherwise → re-emit the buffered bytes + the rest of the stream through the
* normal NDJSON → OpenAI SSE wrapper and return it untouched in spirit.
*
* Bounded by `timeoutMs` (default PEEK_TIMEOUT_MS): if no meaningful event
* arrives in time, or the request signal aborts, we commit whatever we have and
* let the regular stream pipeline (stall detection, abort handling) take over.
*/
export async function peekForUpstreamError(
originalResponse,
model,
{ signal = null, timeoutMs = PEEK_TIMEOUT_MS } = {},
) {
const reader = originalResponse.body.getReader();
const decoder = new TextDecoder();
const abortController = new AbortController();
const forwardAbort = () => abortController.abort(signal?.reason);
if (signal?.aborted) abortController.abort(signal?.reason);
else if (signal)
signal.addEventListener("abort", forwardAbort, { once: true });
// Raw bytes for lossless re-emission; decoded text is only used for line
// parsing / error detection. Never re-encode decoded text: TextDecoder
// holds a split multi-byte char internally and flush() would replace it
// with U+FFFD, corrupting the stream.
const rawChunks = [];
let peeked = "";
let errorEvent = null;
let committed = false;
const readWithTimeout = (ms) => {
if (abortController.signal.aborted) {
return Promise.reject(makeAbortError(abortController.signal.reason));
}
const timeoutPromise = new Promise((_, reject) => {
const t = setTimeout(() => reject(new Error("peek timeout")), ms);
t.unref?.();
});
const abortPromise = new Promise((_, reject) => {
abortController.signal.addEventListener(
"abort",
() => reject(makeAbortError(abortController.signal.reason)),
{ once: true },
);
});
return Promise.race([reader.read(), timeoutPromise, abortPromise]);
};
try {
const deadline = Date.now() + timeoutMs;
while (!errorEvent && !committed && Date.now() < deadline) {
const { done, value } = await readWithTimeout(
Math.max(deadline - Date.now(), 1),
);
if (done) break;
rawChunks.push(value);
peeked += decoder.decode(value, { stream: true });
const lines = peeked.split("\n");
// The last segment may be a partial line — only parse complete ones.
for (const line of lines.slice(0, -1)) {
const event = tryParseEvent(line);
if (!event?.type) continue;
if (event.type === "error") {
errorEvent = event;
break;
}
if (MEANINGFUL_EVENT_TYPES.has(event.type)) {
committed = true;
break;
}
}
}
} catch {
// timeout / abort / read failure during the peek → commit whatever we have;
// the downstream stream pipeline (stall detection, abort handling) takes over.
}
if (signal) signal.removeEventListener("abort", forwardAbort);
if (errorEvent) {
await reader.cancel("commandcode early error detected").catch(() => {});
const { message, type } = formatErrorValue(
errorEvent.error ?? errorEvent.message ?? "unknown",
);
return new Response(JSON.stringify({ error: { message, type } }), {
status: HTTP_STATUS.BAD_GATEWAY,
statusText: message.slice(0, 200),
headers: { "Content-Type": "application/json" },
});
}
const remaining = new ReadableStream({
start(controller) {
(async () => {
try {
// Re-emit RAW bytes (never re-encoded decoded text) so split
// multi-byte UTF-8 sequences survive the peek untouched.
for (const c of rawChunks) controller.enqueue(c);
while (true) {
const { done, value } = await reader.read();
if (done) break;
controller.enqueue(value);
}
controller.close();
} catch (err) {
controller.error(err);
}
})();
},
cancel() {
reader.cancel("commandcode stream cancelled").catch(() => {});
},
});
const combined = new Response(remaining, {
status: originalResponse.status,
statusText: originalResponse.statusText,
headers: originalResponse.headers,
});
return wrapNdjsonAsOpenAISse(combined, model);
}
function wrapNdjsonAsOpenAISse(originalResponse, model) {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let buffer = "";
const state = { model };
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let buffer = "";
const state = { model };
const emitChunks = (chunks, controller) => {
if (!chunks) return;
const list = Array.isArray(chunks) ? chunks : [chunks];
for (const c of list) {
if (c == null) continue;
controller.enqueue(encoder.encode(`data: ${JSON.stringify(c)}\n\n`));
}
};
const emitChunks = (chunks, controller) => {
if (!chunks) return;
const list = Array.isArray(chunks) ? chunks : [chunks];
for (const c of list) {
if (c == null) continue;
controller.enqueue(encoder.encode(`data: ${JSON.stringify(c)}\n\n`));
}
};
const transform = new TransformStream({
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;
// Translate AI SDK v5 NDJSON line to one or more OpenAI chunks
emitChunks(commandCodeToOpenAIResponse(trimmed, state), controller);
}
},
flush(controller) {
const trimmed = buffer.trim();
if (trimmed) {
emitChunks(commandCodeToOpenAIResponse(trimmed, state), controller);
}
controller.enqueue(encoder.encode(SSE_DONE));
},
});
const transform = new TransformStream({
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;
// Translate AI SDK v5 NDJSON line to one or more OpenAI chunks
emitChunks(commandCodeToOpenAIResponse(trimmed, state), controller);
}
},
flush(controller) {
const trimmed = buffer.trim();
if (trimmed) {
emitChunks(commandCodeToOpenAIResponse(trimmed, state), controller);
}
controller.enqueue(encoder.encode(SSE_DONE));
},
});
const newBody = originalResponse.body.pipeThrough(transform);
return new Response(newBody, {
status: originalResponse.status,
statusText: originalResponse.statusText,
headers: originalResponse.headers,
});
const newBody = originalResponse.body.pipeThrough(transform);
return new Response(newBody, {
status: originalResponse.status,
statusText: originalResponse.statusText,
headers: originalResponse.headers,
});
}
export default CommandCodeExecutor;

View File

@@ -1,18 +1,22 @@
import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js";
import { PROVIDERS, PROVIDER_OAUTH } 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 } from "../utils/sse.js";
import { chatChunkSse, sseChunk } 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 = () => {
@@ -38,6 +42,130 @@ 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);
@@ -253,7 +381,304 @@ export class CursorExecutor extends BaseExecutor {
});
}
/**
* AgentService (agent.api5.cursor.sh) is HTTP/2-only. Node's fetch/undici speaks
* HTTP/1.1 and fails with HTTPParserError on the h2 preface — use http2 duplex.
*/
openAgentHttp2Stream(url, headers, signal) {
if (!http2) {
throw new Error("HTTP/2 is required for Cursor AgentService (endpoint is h2-only)");
}
const urlObj = new URL(url);
const client = http2.connect(`https://${urlObj.host}`);
const chunkQueue = [];
let waiting = null;
let ended = false;
let streamError = null;
let req = null;
const wake = (result) => {
if (!waiting) return;
const resolve = waiting;
waiting = null;
resolve(result);
};
const fail = (error) => {
if (streamError) return;
streamError = error;
ended = true;
wake(null);
};
const close = () => {
try { req?.destroy(); } catch {}
try { client.close(); } catch {}
};
client.on("error", fail);
req = client.request({
":method": "POST",
":path": urlObj.pathname,
":authority": urlObj.host,
":scheme": "https",
...headers,
});
req.on("error", fail);
req.on("data", (chunk) => {
if (waiting) wake({ value: chunk, done: false });
else chunkQueue.push(chunk);
});
req.on("end", () => {
ended = true;
wake({ value: undefined, done: true });
});
if (signal) {
const onAbort = () => {
fail(new Error("Request aborted"));
close();
};
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
}
const responseHeaders = new Promise((resolve, reject) => {
const onEarlyError = (error) => reject(error);
client.once("error", onEarlyError);
req.once("error", onEarlyError);
req.once("response", (hdrs) => {
client.off("error", onEarlyError);
req.off("error", onEarlyError);
resolve(hdrs);
});
});
return {
responseHeaders,
write(frame) {
if (req && !req.destroyed) req.write(Buffer.from(frame));
},
end() {
try { if (req && !req.destroyed) req.end(); } catch {}
},
close,
async read() {
if (chunkQueue.length) return { value: chunkQueue.shift(), done: false };
if (ended) {
if (streamError) throw streamError;
return { value: undefined, done: true };
}
const result = await new Promise((resolve) => { waiting = resolve; });
if (streamError) throw streamError;
return result || { value: undefined, done: true };
},
};
}
async executeAgent({ model, body, stream, credentials, signal }) {
const agentEndpoint = PROVIDER_OAUTH.cursor?.agentEndpoint;
if (!agentEndpoint) throw new Error("Cursor AgentService endpoint is not configured");
const url = `${agentEndpoint}${AGENT_RUN_PATH}`;
const headers = this.buildHeaders(credentials);
const requestController = new AbortController();
if (signal?.addEventListener) {
signal.addEventListener("abort", () => requestController.abort(signal.reason), { once: true });
}
let session;
try {
session = this.openAgentHttp2Stream(url, headers, requestController.signal);
session.write(buildAgentRunFrame(body.messages || [], model));
} catch (error) {
throw new Error(`Cursor AgentService request failed: ${error.message}`);
}
let responseHeaders;
try {
responseHeaders = await session.responseHeaders;
} catch (error) {
session.close();
throw new Error(`Cursor AgentService request failed: ${error.message}`);
}
const status = Number(responseHeaders[":status"] || 0);
if (status !== 200) {
let errorText = "";
try {
while (true) {
const { done, value } = await session.read();
if (done) break;
errorText += Buffer.from(value).toString("utf8");
}
} catch {}
session.close();
return {
response: new Response(JSON.stringify({
error: { message: `Cursor AgentService ${status}: ${errorText || "request failed"}`, type: "api_error" },
}), { status: status || HTTP_STATUS.SERVER_ERROR, headers: { "Content-Type": "application/json" } }),
url,
headers,
transformedBody: body,
responseFormat: FORMATS.OPENAI,
};
}
// The Claude SSE translator derives Anthropic's message ID by stripping
// `chatcmpl-`. Keep the remaining ID in Anthropic's required `msg_` form
// so strict clients such as Claude Code accept the completed stream.
const responseId = `chatcmpl-msg_${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
let pending = Buffer.alloc(0);
let finished = false;
const consume = async (onEvent) => {
try {
while (!finished) {
const { done, value } = await session.read();
if (done) break;
pending = Buffer.concat([pending, Buffer.from(value)]);
pending = decodeAgentFrames(pending, (payload) => {
// A single read can carry several frames; once the turn is over the
// rest of the batch must not reach the already-closed controller.
if (finished) return;
const serverMessage = decodeMessage(payload);
// agent.v1.AgentServerMessage.interaction_update
if (serverMessage.has(1)) {
const update = decodeMessage(serverMessage.get(1)[0].value);
if (update.has(1)) {
const textDelta = extractAgentString(decodeMessage(update.get(1)[0].value), 1);
if (textDelta) onEvent({ type: "text", value: textDelta });
}
// Cursor's AgentService emits internal reasoning without the
// cryptographic signature required by Anthropic thinking blocks.
// Forwarding it makes strict Anthropic clients (Claude Code)
// discard or wait on an otherwise complete response. Keep the
// reasoning upstream-only and emit the normal answer text.
if (update.has(14)) {
finished = true;
onEvent({ type: "done" });
}
}
// AgentService requests IDE context before producing a response.
// Return an empty context; 9router is not coupled to an editor.
if (serverMessage.has(2)) {
const execRequest = decodeMessage(serverMessage.get(2)[0].value);
if (execRequest.has(10)) {
session.write(createRequestContextResponse());
} else {
// Every other ExecServerMessage variant is an editor-backed tool
// (shell, read, write, …) that 9router cannot service. Fail the
// turn rather than narrating protocol state as assistant text.
debugLog(`[CURSOR AGENT] Unsupported exec request fields: ${[...execRequest.keys()].join(",")}`);
finished = true;
onEvent({ type: "error", value: "Cursor AgentService requested an unsupported IDE tool" });
}
}
});
}
} finally {
try { session.end(); } catch {}
try { session.close(); } catch {}
if (!finished) onEvent({ type: "done" });
}
};
if (stream === false) {
let content = "";
let reasoning = "";
let agentError = null;
await consume((event) => {
if (event.type === "text") content += event.value;
else if (event.type === "thinking") reasoning += event.value;
else if (event.type === "error") agentError = event.value;
});
if (agentError) {
return {
response: new Response(JSON.stringify({ error: { message: agentError, type: "api_error" } }), {
status: HTTP_STATUS.BAD_REQUEST,
headers: { "Content-Type": "application/json" },
}),
url,
headers,
transformedBody: body,
responseFormat: FORMATS.OPENAI,
};
}
return {
response: new Response(JSON.stringify({
id: responseId,
object: "chat.completion",
created,
model,
choices: [{ index: 0, message: { role: "assistant", content: content || null, ...(reasoning ? { reasoning_content: reasoning } : {}) }, finish_reason: "stop" }],
usage: estimateUsage(body, content.length, FORMATS.OPENAI),
}), { headers: { "Content-Type": "application/json" } }),
url,
headers,
transformedBody: body,
responseFormat: FORMATS.OPENAI,
};
}
const encoder = new TextEncoder();
const responseStream = new ReadableStream({
start(controller) {
consume((event) => {
if (event.type === "text") {
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { content: event.value } })));
} else if (event.type === "thinking") {
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { reasoning_content: event.value } })));
} else if (event.type === "error") {
// An SSE error frame, not a content delta: a protocol failure must not
// be rendered to the user as the assistant's reply, and downstream
// usage tracking must not record the turn as a success.
controller.enqueue(encoder.encode(sseChunk({ error: { message: event.value, type: "api_error" } })));
controller.enqueue(encoder.encode(SSE_DONE));
controller.close();
} else if (event.type === "done") {
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: {}, finishReason: "stop" })));
controller.enqueue(encoder.encode(SSE_DONE));
controller.close();
}
}).catch((error) => controller.error(error));
},
cancel() {
requestController.abort();
},
});
return {
response: new Response(responseStream, { headers: SSE_HEADERS }),
url,
headers,
transformedBody: body,
responseFormat: FORMATS.OPENAI,
};
}
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
if (isAgentTextRequest(body)) {
try {
return await this.executeAgent({ model, body, stream, credentials, signal });
} catch (error) {
return {
response: new Response(JSON.stringify({
error: { message: error.message, type: "connection_error", code: "" },
}), { status: HTTP_STATUS.SERVER_ERROR, headers: { "Content-Type": "application/json" } }),
url: `${PROVIDER_OAUTH.cursor?.agentEndpoint || ""}${AGENT_RUN_PATH}`,
headers: {},
transformedBody: body,
};
}
}
const url = this.buildUrl();
const headers = this.buildHeaders(credentials);
const transformedBody = this.transformRequest(model, body, stream, credentials);

View File

@@ -1,9 +1,9 @@
import { BaseExecutor } from "./base.js";
import { PROVIDERS, PROVIDER_OAUTH } from "../config/providers.js";
import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE } from "../providers/shared.js";
import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE, selectAnthropicBeta } from "../providers/shared.js";
import { resolveOpenAICompatibleApiType } from "../services/provider.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,24 +38,10 @@ function applyAuth(headers, desc, credentials) {
// Provider-specific header quirks kept as small hooks (not pure auth).
const HEADER_HOOKS = {
kimiHeaders: (h) => Object.assign(h, buildKimiHeaders()),
// Stable device_id from OAuth connection (CLIProxyAPI KimiTokenStorage.DeviceID)
kimiHeaders: (h, c) => Object.assign(h, buildKimiHeaders(c?.providerSpecificData?.deviceId)),
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.
@@ -124,7 +110,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 = this.provider.includes("responses") ? "/responses" : "/chat/completions";
const path = resolveOpenAICompatibleApiType(this.provider, credentials) === "responses" ? "/responses" : "/chat/completions";
return `${normalized}${path}`;
}
if (this.provider?.startsWith?.("anthropic-compatible-")) {
@@ -160,14 +146,18 @@ export class DefaultExecutor extends BaseExecutor {
return BEARER;
}
buildHeaders(credentials, stream = true) {
buildHeaders(credentials, stream = true, url, model) {
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 (claude cached headers) can't clobber the token.
// Hooks run BEFORE auth so dynamic overlays 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 || "";
@@ -221,12 +211,13 @@ 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),
"kimi-coding": () => this.refreshKimiCoding(credentials.refreshToken, proxyOptions),
clinepass: () => this.refreshCline(credentials.refreshToken, proxyOptions),
kimi: () => this.refreshKimi(credentials, proxyOptions),
"kimi-coding": () => this.refreshKimi(credentials, proxyOptions),
kilocode: () => this.refreshKilocode(credentials.refreshToken, proxyOptions)
};
@@ -299,19 +290,27 @@ 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;
return { accessToken: data?.accessToken, refreshToken: data?.refreshToken || refreshToken, expiresIn };
let accessToken = data?.accessToken;
if (accessToken && !accessToken.startsWith("workos:")) {
accessToken = `workos:${accessToken}`;
}
return { accessToken, refreshToken: data?.refreshToken || refreshToken, expiresIn };
}
async refreshKimiCoding(refreshToken, proxyOptions = null) {
const kimiHeaders = buildKimiHeaders();
const response = await proxyAwareFetch(PROVIDERS["kimi-coding"].refreshUrl, {
// 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, {
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: PROVIDERS["kimi-coding"].clientId })
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: cfg.clientId })
}, proxyOptions);
if (!response.ok) return null;
const tokens = await response.json();

View File

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

View File

@@ -4,11 +4,13 @@ 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 } from "../translator/index.js";
import { initState, translateRequest, translateResponse } from "../translator/index.js";
import { FORMATS } from "../translator/formats.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 {
@@ -17,6 +19,16 @@ 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;
}
@@ -35,47 +47,20 @@ 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.
// Sanitize messages for GitHub Copilot /chat/completions endpoint (gpt/gemini/grok models —
// claude models never reach this, see execute() below).
// 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;
@@ -138,6 +123,15 @@ 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)) {
@@ -145,8 +139,8 @@ export class GithubExecutor extends BaseExecutor {
return this.executeWithResponsesEndpoint(options);
}
// Sanitize messages before sending to /chat/completions
// This handles Claude models on GitHub Copilot which reject non-text/image_url content types
// Sanitize messages before sending to /chat/completions (gpt/gemini/grok — the
// endpoint rejects non-text/image_url content parts).
const sanitizedOptions = {
...options,
body: this.sanitizeMessagesForChatCompletions(options.body)
@@ -251,6 +245,101 @@ export class GithubExecutor extends BaseExecutor {
};
}
// Claude models arrive here OpenAI-shape (chatCore.js targets "openai" for github —
// see the note in execute() above), so we translate to Anthropic-native ourselves.
// This is what makes prepareClaudeRequest() (translator/formats/claude.js) inject
// cache_control — /chat/completions never gets there, so it never sees cache tokens.
async executeWithMessagesEndpoint({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
const url = this.config.messagesUrl;
const headers = this.buildHeaders(credentials, stream);
// Force stream:true upstream regardless of client preference, same as
// executeWithResponsesEndpoint below — chatCore.js's non-streaming handler already
// knows how to buffer an SSE response into a single JSON reply when the client
// asked for stream:false.
const transformedBody = translateRequest(FORMATS.OPENAI, FORMATS.CLAUDE, model, body, true, credentials, "github");
// _toolNameMap is internal bookkeeping (see openai-to-claude.js) — chatCore.js
// normally strips it before dispatch and threads it into the response state to
// restore original tool names; we must do the same here, or Anthropic's strict
// schema rejects the extra field with a 400.
const toolNameMap = transformedBody._toolNameMap;
delete transformedBody._toolNameMap;
log?.debug("GITHUB", "Sending translated request to /v1/messages");
const response = await proxyAwareFetch(url, {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal
}, proxyOptions);
if (!response.ok) {
return { response, url, headers, transformedBody };
}
const state = initState(FORMATS.CLAUDE);
state.model = model;
if (toolNameMap) state.toolNameMap = toolNameMap;
const decoder = new TextDecoder();
let buffer = "";
const emitAll = (controller, chunks) => {
for (const c of chunks) {
controller.enqueue(new TextEncoder().encode(formatSSE(c, "openai")));
}
};
const transformStream = new TransformStream({
async transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const parsed = parseSSELine(trimmed);
if (!parsed) continue;
if (parsed.done && stream === true) {
controller.enqueue(new TextEncoder().encode(SSE_DONE));
continue;
}
emitAll(controller, translateResponse(FORMATS.CLAUDE, FORMATS.OPENAI, parsed, state));
}
},
flush(controller) {
if (buffer.trim()) {
const parsed = parseSSELine(buffer.trim());
if (parsed && !parsed.done) {
emitAll(controller, translateResponse(FORMATS.CLAUDE, FORMATS.OPENAI, parsed, state));
}
}
}
});
if (!response.body) {
return { response: new Response("", { status: response.status, headers: response.headers }), url, headers, transformedBody };
}
const convertedStream = response.body.pipeThrough(transformStream);
return {
response: new Response(convertedStream, {
status: response.status,
statusText: response.statusText,
headers: response.headers
}),
url,
headers,
transformedBody
};
}
async refreshCopilotToken(githubAccessToken, log, proxyOptions = null) {
try {
const response = await proxyAwareFetch("https://api.github.com/copilot_internal/v2/token", {

View File

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

View File

@@ -5,20 +5,25 @@ 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(),
@@ -28,15 +33,17 @@ 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(),
@@ -44,6 +51,11 @@ 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();
@@ -66,17 +78,22 @@ export { GithubExecutor } from "./github.js";
export { IFlowExecutor } from "./iflow.js";
export { QoderExecutor } from "./qoder.js";
export { KiroExecutor } from "./kiro.js";
export { KimchiExecutor } from "./kimchi.js";
export { CodexExecutor } from "./codex.js";
export { CursorExecutor } from "./cursor.js";
export { VertexExecutor } from "./vertex.js";
export { DefaultExecutor } from "./default.js";
export { QwenExecutor } from "./qwen.js";
export { OpenCodeExecutor } from "./opencode.js";
export { OpenCodeGoExecutor } from "./opencode-go.js";
export { GrokWebExecutor } from "./grok-web.js";
export { GrokCliExecutor } from "./grok-cli.js";
export { PerplexityWebExecutor } from "./perplexity-web.js";
export { OllamaLocalExecutor } from "./ollama-local.js";
export { CommandCodeExecutor } from "./commandcode.js";
export { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js";
export { MimoFreeExecutor } from "./mimo-free.js";
export { CodeBuddyExecutor } from "./codebuddy-cn.js";
export { CodeBuddyIntlExecutor } from "./codebuddy-intl.js";
export { default as TraeExecutor } from "./trae.js";
export { default as ZedExecutor } from "./zed.js";
export { default as WindsurfExecutor } from "./windsurf.js";
export { DevinCliExecutor } from "./devin-cli.js";

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,16 +1,43 @@
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";
// Models that use /zen/v1/messages (claude format)
const OPENCODE_UA = "opencode";
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) {
transformRequest(model, body, stream, credentials) {
this._currentSessionId = resolveOpencodeSession(body, credentials);
return injectReasoningContent({ provider: this.provider, model, body });
}
@@ -21,12 +48,23 @@ export class OpenCodeExecutor extends BaseExecutor {
: `${base}/zen/v1/chat/completions`;
}
buildHeaders() {
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");
return {
"Content-Type": "application/json",
"Authorization": "Bearer public",
"x-opencode-client": "desktop",
"Accept": "text/event-stream"
"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" : "*/*",
};
}
}

View File

@@ -30,11 +30,14 @@ import { PROVIDERS } from "../config/providers.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { SSE_DONE } from "../utils/sseConstants.js";
import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js";
import { resolveProviderTimeoutMs } from "../services/providerTimeout.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 } from "../services/qoderModels.js";
import { getQoderModelConfig, resolveQoderModels, isQoderPat, resolveQoderCredentials } from "../services/qoderModels.js";
/**
* Hoist role:"system" messages out of the messages array (Qoder rejects
@@ -213,6 +216,52 @@ 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.
@@ -220,25 +269,47 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio
* 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 `data: [DONE]\n\n` plus
* a synthetic OpenAI error chunk.
* 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.
*/
function wrapQoderSSE(response, model) {
async function wrapQoderSSE(response, model) {
if (!response.ok || !response.body) return response;
const decoder = new TextDecoder();
const reader = response.body.getReader();
// 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" } }
);
}
// Normal flow: re-process every byte the peek consumed, then continue.
let buffer = peek.consumed || "";
const upstreamDrained = peek.upstreamDone === true;
const encoder = new TextEncoder();
let buffer = "";
let doneEmitted = false;
// Process one already-extracted SSE line (no trailing newline). Returns
// false when the line indicated end-of-stream so the caller can stop
// forwarding any remaining chunks after [DONE].
// Process one already-extracted SSE line (no trailing newline).
const processLine = (line, controller) => {
const trimmed = line.replace(/\r$/, "").trim();
if (!trimmed) return;
if (!trimmed.startsWith("data:")) return;
if (doneEmitted) return; // never forward chunks past stream end
if (doneEmitted) return;
const data = trimmed.slice(5).trimStart();
if (data === "[DONE]") {
@@ -271,47 +342,81 @@ function wrapQoderSSE(response, model) {
doneEmitted = true;
return;
}
// Inner is an OpenAI-shaped chunk. Strip any embedded newlines so the
// SSE frame stays a single event (a literal "\n" inside `inner` would
// otherwise split the frame across multiple data: lines and downstream
// parsers would reassemble them as separate events).
// 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 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);
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(() => {});
}
},
flush(controller) {
// Finalize the decoder so any pending multi-byte sequence is
// released into `buffer` instead of being silently dropped.
buffer += decoder.decode();
// Drain any trailing line that arrived without a terminating newline
// (e.g. upstream closed the socket immediately after the last write,
// or a CDN stripped the final CRLF). Without this, the chunk that
// carries finish_reason is silently lost.
if (buffer.length > 0) {
processLine(buffer, controller);
buffer = "";
}
if (!doneEmitted) {
controller.enqueue(encoder.encode(SSE_DONE));
doneEmitted = true;
}
cancel() {
return reader.cancel().catch(() => {});
},
});
const transformed = response.body.pipeThrough(transform);
// Build a Response with passable headers; the streaming handler reads
// `.body` as a ReadableStream regardless of Content-Type.
return new Response(transformed, {
return new Response(stream, {
status: response.status,
statusText: response.statusText,
headers: {
@@ -326,7 +431,13 @@ export class QoderExecutor extends BaseExecutor {
super("qoder", PROVIDERS.qoder);
}
buildUrl() {
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`;
}
return QODER_CHAT_URL_ENCODED;
}
@@ -336,8 +447,24 @@ 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 }) {
const url = this.buildUrl();
// 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(credentials);
const psd = credentials?.providerSpecificData || {};
if (!psd.userId) {
// No user id → no way to sign. Surface a 401 so the dashboard nudges
@@ -410,7 +537,7 @@ export class QoderExecutor extends BaseExecutor {
};
// Abort if upstream doesn't return response headers within connect timeout.
const timeoutMs = this.config?.timeoutMs || FETCH_CONNECT_TIMEOUT_MS;
const timeoutMs = await resolveProviderTimeoutMs(this.provider, this.config?.timeoutMs, FETCH_CONNECT_TIMEOUT_MS);
const connectCtrl = new AbortController();
const connectTimer = setTimeout(() => connectCtrl.abort(new Error("fetch connect timeout")), timeoutMs);
const mergedSignal = signal ? AbortSignal.any([signal, connectCtrl.signal]) : connectCtrl.signal;
@@ -431,7 +558,7 @@ export class QoderExecutor extends BaseExecutor {
return { response, url, headers, transformedBody: payload };
}
const wrapped = wrapQoderSSE(response, `qoder/${qoderKey}`);
const wrapped = await wrapQoderSSE(response, `qoder/${qoderKey}`);
return { response: wrapped, url, headers, transformedBody: payload };
}
@@ -455,4 +582,5 @@ export const __test__ = {
normalizeMessages,
wrapQoderSSE,
buildQoderRequestBody,
isBillingBlock,
};

View File

@@ -1,129 +0,0 @@
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;

339
open-sse/executors/trae.js Normal file
View File

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

View File

@@ -0,0 +1,588 @@
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;

304
open-sse/executors/zed.js Normal file
View File

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

View File

@@ -1,18 +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 } from "../translator/formats/claude.js";
import { COLORS } from "../utils/stream.js";
import { normalizeClaudePassthrough, anchorClaudeCache } from "../translator/formats/claude.js";
import { createStreamController } from "../utils/streamHandler.js";
import { refreshWithRetry } from "../services/tokenRefresh.js";
import { createRequestLogger } from "../utils/requestLogger.js";
import { getModelTargetFormat, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
import { getModelTargetFormat, getModelSupportedFormats, 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 } from "../config/runtimeConfig.js";
import { HTTP_STATUS, TOKEN_SAVER_HEADER } 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 { handleForcedSSEToJson } from "./chatCore/sseToJsonHandler.js";
import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js";
@@ -23,9 +24,11 @@ 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 { 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
@@ -34,9 +37,38 @@ import { prefetchRemoteImages } from "../translator/concerns/prefetch.js";
* @param {object} options.credentials - Provider credentials
* @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses")
*/
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, sourceFormatOverride, providerThinking }) {
/**
* 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 }) {
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);
@@ -46,10 +78,20 @@ 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
// 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);
const runtimeTransport = resolveTransport(provider, sourceFormat);
const targetFormat = modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider);
if (runtimeTransport && credentials) credentials.runtimeTransport = runtimeTransport;
// 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 stripList = getModelStrip(alias, model);
const upstreamModel = getModelUpstreamId(alias, model);
@@ -121,11 +163,24 @@ 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: upstreamModel };
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;
}
}
// Normalize newer Cowork/CC beta shapes (adaptive thinking, mid-conversation system) the API rejects
if (clientTool === "claude") normalizeClaudePassthrough(translatedBody, upstreamModel);
if (clientTool === "claude") normalizeClaudePassthrough(translatedBody, translatedBody.model);
} else {
translatedBody = translateRequest(sourceFormat, targetFormat, upstreamModel, body, stream, credentials, provider, reqLogger, stripList, connectionId, clientTool);
if (!translatedBody) {
@@ -134,7 +189,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
}
toolNameMap = translatedBody._toolNameMap;
delete translatedBody._toolNameMap;
translatedBody.model = upstreamModel;
customToolNames = translatedBody._customToolNames;
delete translatedBody._customToolNames;
translatedBody.model = stripThinkingSuffix(upstreamModel);
stripContinuityFields(translatedBody);
}
// Dedupe duplicate built-in tools when equivalent MCP tools are present (Claude clients only).
@@ -150,41 +208,87 @@ 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, rtkEnabled);
const rtkStats = compressMessages(translatedBody, tokenSaverEnabled && 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: headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, diagnostics: headroomDiagnostics });
const headroomStats = await compressWithHeadroom(translatedBody, { enabled: tokenSaverEnabled && headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, diagnostics: headroomDiagnostics });
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 | ${headroomSizeLine}`);
log?.warn?.("HEADROOM", `reported token delta, but outbound JSON shrank <5%; provider may bill near-original payload | ${formatHeadroomSizeLog(headroomDiagnostics)}`);
}
} else if (headroomEnabled) log?.warn?.("HEADROOM", `skipped: ${headroomDiagnostics.reason || "compression unavailable"}${headroomDiagnostics.endpoint ? ` (${headroomDiagnostics.endpoint})` : ""}`);
} 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 = [];
// Caveman: inject terse-style system prompt
if (cavemanEnabled && cavemanLevel) {
if (tokenSaverEnabled && cavemanEnabled && cavemanLevel) {
injectCaveman(translatedBody, finalFormat, cavemanLevel);
log?.debug?.("CAVEMAN", `${cavemanLevel} | ${finalFormat}`);
xf.push(`CAVEMAN:${cavemanLevel}`);
}
// Ponytail: inject lazy-senior-dev system prompt
if (ponytailEnabled && ponytailLevel) {
if (tokenSaverEnabled && ponytailEnabled && ponytailLevel) {
injectPonytail(translatedBody, finalFormat, ponytailLevel);
log?.debug?.("PONYTAIL", `${ponytailLevel} | ${finalFormat}`);
xf.push(`PONYTAIL:${ponytailLevel}`);
}
// 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(() => { });
@@ -198,7 +302,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
if (onDisconnect) onDisconnect(reason);
},
onError: () => trackPendingRequest(model, provider, connectionId, false),
log, provider, model
log, provider, model, reqTag
});
const proxyOptions = {
@@ -236,12 +340,16 @@ 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;
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;
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
} catch (error) {
trackPendingRequest(model, provider, connectionId, false, true);
@@ -253,6 +361,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
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(() => { });
@@ -261,23 +370,40 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
return createErrorResult(499, "Request aborted");
}
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
if (log?.errorLine) {
log.errorLine(reqTag, "✗", `ERROR 502 · ${provider}/${model} · ${Date.now() - requestStartTime}ms\n ${errMsg}${error.stack ? `\n ${error.stack}` : ""}`);
}
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 {
const newCredentials = await refreshWithRetry(() => executor.refreshCredentials(credentials, log), 3, log);
// 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);
if (newCredentials?.accessToken || newCredentials?.copilotToken) {
log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed`);
if (log?.line) log.line(reqTag, "🔑", `TOKEN REFRESHED · ${provider}/${model}`);
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; }
if (retryResult.response.ok) {
providerResponse = retryResult.response;
providerUrl = retryResult.url;
providerResponseFormat = retryResult.responseFormat || targetFormat;
}
} catch { log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); }
} else {
log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`);
@@ -299,35 +425,39 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
request: extractRequestConfig(body, stream),
providerRequest: finalBody || translatedBody || null,
response: { error: message, status: statusCode, thinking: null },
pxpipe: pxpipeSummary,
status: "error"
})).catch(() => { });
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
if (log?.errorLine) {
const urlStr = providerUrl ? `\n URL: ${providerUrl}` : "";
log.errorLine(reqTag, "✗", `ERROR ${statusCode} · ${provider}/${model} · ${Date.now() - requestStartTime}ms${urlStr}\n ${errMsg}`);
}
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 };
const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, pxpipe: pxpipeSummary, reqTag, log };
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, trackDone, appendLog });
const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, customToolNames, trackDone, appendLog });
if (result) { streamController.handleComplete(); return result; }
}
// True non-streaming response
if (!stream) {
const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, reqLogger, toolNameMap, trackDone, appendLog });
const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, reqLogger, toolNameMap, customToolNames, trackDone, appendLog });
streamController.handleComplete();
return result;
}
// Streaming response
const { onStreamComplete } = buildOnStreamComplete({ ...sharedCtx });
return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete });
const { onStreamComplete, streamDetailId } = buildOnStreamComplete({ ...sharedCtx });
return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, userAgent, reqLogger, toolNameMap, customToolNames, streamController, onStreamComplete, streamDetailId });
}
export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) {

View File

@@ -1,19 +1,157 @@
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 } from "./requestDetail.js";
import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats, formatDoneLine } 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) {
if (targetFormat === sourceFormat || targetFormat === FORMATS.OPENAI) return responseBody;
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;
// Gemini / Antigravity
if (targetFormat === FORMATS.GEMINI || targetFormat === FORMATS.ANTIGRAVITY || targetFormat === FORMATS.GEMINI_CLI || targetFormat === FORMATS.VERTEX) {
@@ -143,7 +281,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, trackDone, appendLog }) {
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 }) {
trackDone();
const contentType = providerResponse.headers.get("content-type") || "";
let responseBody;
@@ -180,11 +318,16 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
const usage = extractUsageFromResponse(responseBody);
appendLog({ tokens: usage, status: "200 OK" });
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint });
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 } }));
const translatedResponse = needsTranslation(targetFormat, sourceFormat)
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat, customToolNames)
: 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]) {
@@ -197,13 +340,17 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
}
// Ensure OpenAI-required fields
if (!translatedResponse.object) translatedResponse.object = "chat.completion";
if (!translatedResponse.created) translatedResponse.created = Math.floor(Date.now() / 1000);
if (!isClaudeMessageResponse && !isResponsesResponse) {
if (!translatedResponse.object) translatedResponse.object = "chat.completion";
if (!translatedResponse.created) translatedResponse.created = Math.floor(Date.now() / 1000);
}
// Strip Azure-specific fields
delete translatedResponse.prompt_filter_results;
if (translatedResponse?.choices) {
for (const choice of translatedResponse.choices) delete choice.content_filter_results;
if (!isClaudeMessageResponse && !isResponsesResponse) {
delete translatedResponse.prompt_filter_results;
if (translatedResponse?.choices) {
for (const choice of translatedResponse.choices) delete choice.content_filter_results;
}
}
if (translatedResponse?.usage) {
@@ -213,7 +360,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 (translatedResponse?.choices) {
if (!isClaudeMessageResponse && !isResponsesResponse && translatedResponse?.choices) {
for (const choice of translatedResponse.choices) {
if (choice?.message?.reasoning_content && choice.message.content) {
delete choice.message.reasoning_content;
@@ -225,7 +372,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
const totalLatency = Date.now() - requestStartTime;
saveRequestDetail(buildRequestDetail({
provider, model, connectionId,
provider, model, connectionId, apiKey,
latency: { ttft: totalLatency, total: totalLatency },
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
request: extractRequestConfig(body, stream),
@@ -236,6 +383,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
thinking: translatedResponse?.choices?.[0]?.message?.reasoning_content || translatedResponse?.reasoning_content || null,
finish_reason: translatedResponse?.choices?.[0]?.finish_reason || "unknown"
},
pxpipe,
status: "success"
}, { endpoint: clientRawRequest?.endpoint || null })).catch(err => {
console.error("[RequestDetail] Failed to save:", err.message);

View File

@@ -1,5 +1,6 @@
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",
@@ -43,23 +44,35 @@ export function extractUsageFromResponse(responseBody) {
};
}
// Gemini format
if (responseBody.usageMetadata) {
// Gemini format. Antigravity / gemini-cli wrap the payload in { response: {...} }.
const usageMetadata = responseBody.usageMetadata || responseBody.response?.usageMetadata;
if (usageMetadata) {
return {
prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0,
completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0,
reasoning_tokens: responseBody.usageMetadata.thoughtsTokenCount
prompt_tokens: usageMetadata.promptTokenCount || 0,
completion_tokens: usageMetadata.candidatesTokenCount || 0,
cached_tokens: usageMetadata.cachedContentTokenCount || 0,
reasoning_tokens: usageMetadata.thoughtsTokenCount || 0
};
}
return null;
}
// Mask API keys before they reach the requestDetails data blob / DB column.
// Only the prefix is kept — enough to distinguish keys without leaking them.
export function maskApiKey(key) {
if (!key || typeof key !== "string") return undefined;
const trimmed = key.trim();
if (trimmed.length <= 8) return trimmed.charAt(0) + "***";
return trimmed.slice(0, 8) + "***";
}
export function buildRequestDetail(base, overrides = {}) {
return {
provider: base.provider || "unknown",
model: base.model || "unknown",
connectionId: base.connectionId || undefined,
apiKey: maskApiKey(base.apiKey),
timestamp: new Date().toISOString(),
latency: base.latency || { ttft: 0, total: 0 },
tokens: base.tokens || { prompt_tokens: 0, completion_tokens: 0 },
@@ -67,12 +80,31 @@ 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
};
}
export function saveUsageStats({ provider, model, tokens, connectionId, apiKey, endpoint, label = "USAGE" }) {
// 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 }) {
if (!tokens || typeof tokens !== "object") return;
const inTokens = tokens.input_tokens ?? tokens.prompt_tokens ?? 0;
@@ -80,12 +112,15 @@ export function saveUsageStats({ provider, model, tokens, connectionId, apiKey,
if (inTokens === 0 && outTokens === 0) return;
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}`);
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}`);
}
// Normalize to OpenAI token shape for storage
const normalized = {
// 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) || {
prompt_tokens: tokens.prompt_tokens ?? tokens.input_tokens ?? 0,
completion_tokens: tokens.completion_tokens ?? tokens.output_tokens ?? 0
};

View File

@@ -1,21 +1,24 @@
import { convertResponsesStreamToJson } from "../../transformer/streamToJsonConverter.js";
import { matchStreamErrorPatterns } from "../../utils/streamErrorPatterns.js";
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 } from "./requestDetail.js";
import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js";
import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.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;
const isResponsesProvider = (p) =>
PROVIDERS[p]?.format === FORMATS.OPENAI_RESPONSES;
import { saveRequestDetail, appendRequestLog } from "@/lib/usageDb.js";
function textFromResponsesMessageItem(item) {
if (!item?.content || !Array.isArray(item.content)) return "";
const byType = item.content.find((c) => c.type === "output_text");
if (typeof byType?.text === "string") return byType.text;
const anyText = item.content.find((c) => typeof c.text === "string");
if (typeof anyText?.text === "string") return anyText.text;
return "";
if (!item?.content || !Array.isArray(item.content)) return "";
const byType = item.content.find((c) => c.type === "output_text");
if (typeof byType?.text === "string") return byType.text;
const anyText = item.content.find((c) => typeof c.text === "string");
if (typeof anyText?.text === "string") return anyText.text;
return "";
}
/**
@@ -23,15 +26,85 @@ function textFromResponsesMessageItem(item) {
* Early message blocks often have empty output_text; the user-visible answer is usually in the last non-empty message.
*/
function pickAssistantMessageForChatCompletion(output) {
if (!Array.isArray(output)) return { msgItem: null, textContent: null };
const messages = output.filter((item) => item?.type === "message");
if (messages.length === 0) return { msgItem: null, textContent: null };
for (let i = messages.length - 1; i >= 0; i--) {
const text = textFromResponsesMessageItem(messages[i]);
if (text.length > 0) return { msgItem: messages[i], textContent: text };
if (!Array.isArray(output)) return { msgItem: null, textContent: null };
const messages = output.filter((item) => item?.type === "message");
if (messages.length === 0) return { msgItem: null, textContent: null };
for (let i = messages.length - 1; i >= 0; i--) {
const text = textFromResponsesMessageItem(messages[i]);
if (text.length > 0) return { msgItem: messages[i], textContent: text };
}
const last = messages[messages.length - 1];
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 last = messages[messages.length - 1];
return { msgItem: last, textContent: textFromResponsesMessageItem(last) };
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),
},
};
}
/**
@@ -39,197 +112,414 @@ function pickAssistantMessageForChatCompletion(output) {
* Used when provider forces streaming but client wants non-streaming.
*/
export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
const chunks = [];
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 { chunks.push(JSON.parse(payload)); } catch { /* ignore malformed lines */ }
}
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 */
}
}
if (chunks.length === 0) return null;
if (streamError) return { error: streamError };
if (chunks.length === 0) return null;
const first = chunks[0];
const contentParts = [];
const reasoningParts = [];
const toolCallMap = new Map(); // index -> { id, type, function: { name, arguments } }
let finishReason = "stop";
let usage = null;
const first = chunks[0];
const contentParts = [];
const reasoningParts = [];
const toolCallMap = new Map(); // index -> { id, type, function: { name, arguments } }
let finishReason = "stop";
let usage = null;
for (const chunk of chunks) {
const choice = chunk?.choices?.[0];
const delta = choice?.delta || {};
if (typeof delta.content === "string" && delta.content.length > 0) contentParts.push(delta.content);
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) reasoningParts.push(delta.reasoning_content);
if (choice?.finish_reason) finishReason = choice.finish_reason;
if (chunk?.usage && typeof chunk.usage === "object") usage = chunk.usage;
for (const chunk of chunks) {
const choice = chunk?.choices?.[0];
const delta = choice?.delta || {};
if (typeof delta.content === "string" && delta.content.length > 0)
contentParts.push(delta.content);
if (
typeof delta.reasoning_content === "string" &&
delta.reasoning_content.length > 0
)
reasoningParts.push(delta.reasoning_content);
if (choice?.finish_reason) finishReason = choice.finish_reason;
if (chunk?.usage && typeof chunk.usage === "object") usage = chunk.usage;
// Accumulate tool_calls from streaming deltas
if (Array.isArray(delta.tool_calls)) {
for (const tc of delta.tool_calls) {
const idx = tc.index ?? 0;
if (!toolCallMap.has(idx)) {
toolCallMap.set(idx, { id: tc.id || "", type: "function", function: { name: "", arguments: "" } });
}
const existing = toolCallMap.get(idx);
if (tc.id) existing.id = tc.id;
if (tc.function?.name) existing.function.name += tc.function.name;
if (tc.function?.arguments) existing.function.arguments += tc.function.arguments;
}
}
}
// Accumulate tool_calls from streaming deltas
if (Array.isArray(delta.tool_calls)) {
for (const tc of delta.tool_calls) {
const idx = tc.index ?? 0;
if (!toolCallMap.has(idx)) {
toolCallMap.set(idx, {
id: tc.id || "",
type: "function",
function: { name: "", arguments: "" },
});
}
const existing = toolCallMap.get(idx);
if (tc.id) existing.id = tc.id;
if (tc.function?.name) existing.function.name += tc.function.name;
if (tc.function?.arguments)
existing.function.arguments += tc.function.arguments;
}
}
}
const message = { role: "assistant", content: contentParts.join("") || (toolCallMap.size > 0 ? null : "") };
if (reasoningParts.length > 0) message.reasoning_content = reasoningParts.join("");
if (toolCallMap.size > 0) {
message.tool_calls = [...toolCallMap.entries()].sort((a, b) => a[0] - b[0]).map(([, tc]) => tc);
}
const message = {
role: "assistant",
content: contentParts.join("") || (toolCallMap.size > 0 ? null : ""),
};
if (reasoningParts.length > 0)
message.reasoning_content = reasoningParts.join("");
if (toolCallMap.size > 0) {
message.tool_calls = [...toolCallMap.entries()]
.sort((a, b) => a[0] - b[0])
.map(([, tc]) => tc);
}
const result = {
id: first.id || `chatcmpl-${Date.now()}`,
object: "chat.completion",
created: first.created || Math.floor(Date.now() / 1000),
model: first.model || fallbackModel || "unknown",
choices: [{ index: 0, message, finish_reason: finishReason }]
};
if (usage) result.usage = usage;
return result;
const result = {
id: first.id || `chatcmpl-${Date.now()}`,
object: "chat.completion",
created: first.created || Math.floor(Date.now() / 1000),
model: first.model || fallbackModel || "unknown",
choices: [{ index: 0, message, finish_reason: finishReason }],
};
if (usage) result.usage = usage;
return result;
}
/**
* 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, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, 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
export async function handleForcedSSEToJson({
providerResponse,
sourceFormat,
targetFormat,
provider,
model,
body,
stream,
translatedBody,
finalBody,
requestStartTime,
connectionId,
apiKey,
clientRawRequest,
onRequestSuccess,
customToolNames,
trackDone,
appendLog,
reqTag,
log,
streamErrorPatterns,
}) {
const contentType = providerResponse.headers.get("content-type") || "";
const isSSE =
contentType.includes("text/event-stream") ||
(contentType === "" && isResponsesProvider(provider));
if (!isSSE) return null; // not handled here
trackDone();
trackDone();
const ctx = {
provider, model, connectionId,
request: extractRequestConfig(body, stream),
providerRequest: finalBody || translatedBody || null
};
const ctx = {
provider,
model,
connectionId,
request: extractRequestConfig(body, stream),
providerRequest: finalBody || translatedBody || null,
};
// Codex/Responses API SSE path
const isCodexResponsesApi = isResponsesProvider(provider) || sourceFormat === FORMATS.OPENAI_RESPONSES;
if (isCodexResponsesApi) {
try {
const jsonResponse = await convertResponsesStreamToJson(providerResponse.body);
if (onRequestSuccess) await onRequestSuccess();
// 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;
if (isCodexResponsesApi) {
try {
const jsonResponse = await convertResponsesStreamToJson(
providerResponse.body,
);
if (onRequestSuccess) await onRequestSuccess();
const usage = jsonResponse.usage || {};
appendLog({ tokens: usage, status: "200 OK" });
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint });
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 },
}),
);
const { msgItem, textContent } = pickAssistantMessageForChatCompletion(jsonResponse.output);
const totalLatency = Date.now() - requestStartTime;
// 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: 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(() => {});
saveRequestDetail(
buildRequestDetail(
{
...ctx,
apiKey,
latency: { ttft: totalLatency, total: totalLatency },
tokens: {
prompt_tokens: inTokensForLog,
completion_tokens: usage.output_tokens || 0,
},
response: {
content: textContent,
thinking: null,
finish_reason: jsonResponse.status || "unknown",
},
status: "success",
},
{ endpoint: clientRawRequest?.endpoint || null },
),
).catch(() => {});
// Client is Responses API → return as-is
if (sourceFormat === FORMATS.OPENAI_RESPONSES) {
return { success: true, response: new Response(JSON.stringify(jsonResponse), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
}
// Client is Responses API → return as-is
if (sourceFormat === FORMATS.OPENAI_RESPONSES) {
return {
success: true,
response: new Response(JSON.stringify(jsonResponse), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
}),
};
}
// Build client-format response
const inTokens = usage.input_tokens || 0;
const outTokens = usage.output_tokens || 0;
let finalResp;
// 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;
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)
const funcCallItems = (jsonResponse.output || []).filter(item => item.type === "function_call");
const toolCalls = funcCallItems.map((item, idx) => ({
id: item.call_id || `call_${item.name}_${Date.now()}_${idx}`,
type: "function",
function: {
name: item.name,
arguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments || {})
}
}));
const hasToolCalls = toolCalls.length > 0;
// Extract tool calls from Responses API output (function_call items)
const funcCallItems = (jsonResponse.output || []).filter(
(item) => item.type === "function_call",
);
const toolCalls = funcCallItems.map((item, idx) => ({
id: item.call_id || `call_${item.name}_${Date.now()}_${idx}`,
type: "function",
function: {
name: item.name,
arguments:
typeof item.arguments === "string"
? item.arguments
: JSON.stringify(item.arguments || {}),
},
}));
const hasToolCalls = toolCalls.length > 0;
if (sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI) {
finalResp = {
response: {
candidates: [{ content: { role: "model", parts: [{ text: textContent || "" }] }, finishReason: "STOP", index: 0 }],
usageMetadata: { promptTokenCount: inTokens, candidatesTokenCount: outTokens, totalTokenCount: inTokens + outTokens },
modelVersion: model,
responseId: jsonResponse.id || `resp_${Date.now()}`
}
};
} else {
const message = { role: "assistant", content: textContent || (hasToolCalls ? null : "") };
if (hasToolCalls) message.tool_calls = toolCalls;
const responseDone = jsonResponse.status === "completed" || jsonResponse.status === "done";
const finishReason = hasToolCalls ? "tool_calls" : (responseDone ? "stop" : (jsonResponse.status || "stop"));
finalResp = {
id: jsonResponse.id || `chatcmpl-${Date.now()}`,
object: "chat.completion",
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 }
};
}
if (
sourceFormat === FORMATS.ANTIGRAVITY ||
sourceFormat === FORMATS.GEMINI ||
sourceFormat === FORMATS.GEMINI_CLI
) {
finalResp = {
response: {
candidates: [
{
content: {
role: "model",
parts: [{ text: textContent || "" }],
},
finishReason: "STOP",
index: 0,
},
],
usageMetadata: {
promptTokenCount: inTokens,
candidatesTokenCount: outTokens,
totalTokenCount: inTokens + outTokens,
},
modelVersion: model,
responseId: jsonResponse.id || `resp_${Date.now()}`,
},
};
} else {
const message = {
role: "assistant",
content: textContent || (hasToolCalls ? null : ""),
};
if (hasToolCalls) message.tool_calls = toolCalls;
const responseDone =
jsonResponse.status === "completed" || jsonResponse.status === "done";
const finishReason = hasToolCalls
? "tool_calls"
: responseDone
? "stop"
: jsonResponse.status || "stop";
finalResp = {
id: jsonResponse.id || `chatcmpl-${Date.now()}`,
object: "chat.completion",
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,
},
};
}
return { success: true, response: new Response(JSON.stringify(finalResp), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
} catch (err) {
console.error("[ChatCore] Responses API SSE→JSON failed:", err);
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON");
}
}
return {
success: true,
response: new Response(JSON.stringify(finalResp), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
}),
};
} catch (err) {
console.error("[ChatCore] Responses API SSE→JSON failed:", err);
return createErrorResult(
HTTP_STATUS.BAD_GATEWAY,
"Failed to convert streaming response to JSON",
);
}
}
// Standard Chat Completions SSE path
try {
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");
// Standard Chat Completions SSE path
try {
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();
// Config-driven in-stream error detection: the request "succeeded" at the
// HTTP level, but the content signals an upstream failure — treat it as an
// error so account/combo fallback and FAILED logging kick in.
const matchedPattern = matchStreamErrorPatterns(
streamErrorPatterns?.[provider],
parsed.choices?.[0]?.message?.content || "",
);
if (matchedPattern) {
return createErrorResult(
HTTP_STATUS.BAD_GATEWAY,
`Stream error pattern matched: ${matchedPattern}`,
);
}
const usage = parsed.usage || {};
appendLog({ tokens: usage, status: "200 OK" });
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint });
if (onRequestSuccess) await onRequestSuccess();
const totalLatency = Date.now() - requestStartTime;
saveRequestDetail(buildRequestDetail({
...ctx,
latency: { ttft: totalLatency, total: totalLatency },
tokens: usage,
response: {
content: parsed.choices?.[0]?.message?.content || null,
thinking: parsed.choices?.[0]?.message?.reasoning_content || null,
finish_reason: parsed.choices?.[0]?.finish_reason || "unknown"
},
status: "success"
}, { endpoint: clientRawRequest?.endpoint || null })).catch(() => {});
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 },
}),
);
// 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.
// Previously this was unconditional, which broke Qwen3.5, Claude extended thinking, etc.
if (parsed?.choices) {
for (const choice of parsed.choices) {
if (choice?.message?.reasoning_content && choice.message.content) {
delete choice.message.reasoning_content;
}
}
}
// 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;
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");
}
// 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.
// Previously this was unconditional, which broke Qwen3.5, Claude extended thinking, etc.
if (parsed?.choices) {
for (const choice of parsed.choices) {
if (choice?.message?.reasoning_content && choice.message.content) {
delete choice.message.reasoning_content;
}
}
}
// 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": "*",
},
}),
};
} catch (err) {
console.error("[ChatCore] Chat Completions SSE→JSON failed:", err);
return createErrorResult(
HTTP_STATUS.BAD_GATEWAY,
"Failed to convert streaming response to JSON",
);
}
}

View File

@@ -1,122 +1,343 @@
import { FORMATS } from "../../translator/formats.js";
import { needsTranslation } from "../../translator/index.js";
import { createSSETransformStreamWithLogger, createPassthroughStreamWithLogger } from "../../utils/stream.js";
import {
createSSETransformStreamWithLogger,
createPassthroughStreamWithLogger,
} from "../../utils/stream.js";
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 } from "./requestDetail.js";
import {
buildRequestDetail,
extractRequestConfig,
saveUsageStats,
formatDoneLine,
} from "./requestDetail.js";
import { streamStatusForContent } from "../../utils/streamErrorPatterns.js";
import { saveRequestDetail } from "@/lib/usageDb.js";
import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.js";
// Codex returns Responses API SSE → which client format to translate INTO, by request sourceFormat.
// Gemini-family all map to ANTIGRAVITY decoder; unknown sources fall back to OPENAI.
const CODEX_SOURCE_TO_TARGET = {
[FORMATS.OPENAI_RESPONSES]: FORMATS.OPENAI_RESPONSES,
[FORMATS.CLAUDE]: FORMATS.CLAUDE,
[FORMATS.ANTIGRAVITY]: FORMATS.ANTIGRAVITY,
[FORMATS.GEMINI]: FORMATS.ANTIGRAVITY,
[FORMATS.GEMINI_CLI]: FORMATS.ANTIGRAVITY,
[FORMATS.OPENAI_RESPONSES]: FORMATS.OPENAI_RESPONSES,
[FORMATS.CLAUDE]: FORMATS.CLAUDE,
[FORMATS.ANTIGRAVITY]: FORMATS.ANTIGRAVITY,
[FORMATS.GEMINI]: FORMATS.ANTIGRAVITY,
[FORMATS.GEMINI_CLI]: FORMATS.ANTIGRAVITY,
};
/**
* Determine which SSE transform stream to use based on provider/format.
*/
function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }) {
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;
const needsCodexTranslation = isResponsesProvider && targetFormat === FORMATS.OPENAI_RESPONSES && !isDroidCLI;
function buildTransformStream({
provider,
sourceFormat,
targetFormat,
userAgent,
reqLogger,
toolNameMap,
customToolNames,
model,
connectionId,
body,
onStreamComplete,
apiKey,
}) {
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;
const needsCodexTranslation =
isResponsesProvider &&
targetFormat === FORMATS.OPENAI_RESPONSES &&
!isDroidCLI;
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);
}
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,
);
}
if (needsTranslation(targetFormat, sourceFormat)) {
return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey);
}
if (needsTranslation(targetFormat, sourceFormat)) {
return createSSETransformStreamWithLogger(
targetFormat,
sourceFormat,
provider,
reqLogger,
toolNameMap,
model,
connectionId,
body,
onStreamComplete,
apiKey,
customToolNames,
);
}
return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey);
return createPassthroughStreamWithLogger(
provider,
reqLogger,
model,
connectionId,
body,
onStreamComplete,
apiKey,
);
}
/**
* Handle streaming response — pipe provider SSE through transform stream to client.
*/
export function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete }) {
if (onRequestSuccess) {
Promise.resolve()
.then(onRequestSuccess)
.catch(err => {
console.error("[ChatCore] onRequestSuccess failed:", err?.message || err);
});
}
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,
);
});
}
// Warn when upstream returns unexpected Content-Type for a streaming response.
// This often means the provider returned an HTML error page or plain-text error
// that the SSE transform stream would forward as garbage to the client.
const upstreamContentType = (providerResponse.headers.get('content-type') || '').toLowerCase();
if (upstreamContentType && !upstreamContentType.includes('text/event-stream') && !upstreamContentType.includes('application/json')) {
console.warn('[STREAM] ' + provider + ' | ' + model + ' | unexpected Content-Type: ' + upstreamContentType);
}
// 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, model, connectionId, body, onStreamComplete, apiKey });
const transformStream = buildTransformStream({
provider,
sourceFormat,
targetFormat,
userAgent,
reqLogger,
toolNameMap,
customToolNames,
model,
connectionId,
body,
onStreamComplete,
apiKey,
});
// 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;
const onAbortTerminal = isResponsesPassthrough ? buildAbortedResponsesTerminalBytes : null;
const stallTimeoutMs = PROVIDERS[provider]?.stallTimeoutMs || STREAM_STALL_TIMEOUT_MS;
const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController, onAbortTerminal, stallTimeoutMs);
// 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;
const onAbortTerminal = isResponsesPassthrough
? buildAbortedResponsesTerminalBytes
: null;
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,
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" },
status: "success"
}, { id: streamDetailId })).catch(err => {
console.error("[RequestDetail] Failed to save streaming request:", err.message);
});
saveRequestDetail(
buildRequestDetail(
{
provider,
model,
connectionId,
apiKey,
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,
);
});
return {
success: true,
response: new Response(transformedBody, { headers: SSE_HEADERS })
};
return {
success: true,
response: new Response(transformedBody, { headers: SSE_HEADERS }),
};
}
/**
* Build onStreamComplete callback for streaming usage tracking.
*/
export function buildOnStreamComplete({ provider, model, connectionId, apiKey, requestStartTime, body, stream, finalBody, translatedBody, clientRawRequest }) {
const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
export function buildOnStreamComplete({
provider,
model,
connectionId,
apiKey,
requestStartTime,
body,
stream,
finalBody,
translatedBody,
clientRawRequest,
pxpipe,
reqTag,
log,
streamErrorPatterns,
}) {
const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const onStreamComplete = (contentObj, usage, ttftAt) => {
const latency = {
ttft: ttftAt ? ttftAt - requestStartTime : Date.now() - requestStartTime,
total: Date.now() - requestStartTime
};
const safeContent = contentObj?.content || "[Empty streaming response]";
const safeThinking = contentObj?.thinking || null;
const onStreamComplete = (contentObj, usage, ttftAt) => {
const latency = {
ttft: ttftAt ? ttftAt - requestStartTime : Date.now() - requestStartTime,
total: Date.now() - requestStartTime,
};
const safeContent = contentObj?.content || "[Empty streaming response]";
const safeThinking = contentObj?.thinking || null;
saveRequestDetail(buildRequestDetail({
provider, model, connectionId,
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" },
status: "success"
}, { id: streamDetailId })).catch(err => {
console.error("[RequestDetail] Failed to update streaming content:", err.message);
});
saveRequestDetail(
buildRequestDetail(
{
provider,
model,
connectionId,
apiKey,
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: streamStatusForContent(
streamErrorPatterns?.[provider],
safeContent,
),
},
{ id: streamDetailId },
),
).catch((err) => {
console.error(
"[RequestDetail] Failed to update streaming content:",
err.message,
);
});
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, label: "STREAM USAGE" });
};
// 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 };
return { onStreamComplete, streamDetailId };
}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { HTTP_STATUS, FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js";
import { getExecutor } from "../executors/index.js";
import { refreshWithRetry } from "../services/tokenRefresh.js";
import { getEmbeddingAdapter } from "./embeddingProviders/index.js";
@@ -38,13 +38,24 @@ export async function handleEmbeddingsCore({
}
const ctx = { input };
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,
});
// 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}`);
}
log?.debug?.("EMBEDDINGS", `${provider.toUpperCase()} | ${model} | input_type=${Array.isArray(input) ? `array[${input.length}]` : "string"}`);
@@ -54,6 +65,9 @@ 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);
@@ -116,6 +130,7 @@ export async function handleEmbeddingsCore({
return {
success: true,
usage: normalized.usage || null,
response: new Response(JSON.stringify(normalized), {
headers: {
"Content-Type": "application/json",

View File

@@ -49,7 +49,10 @@ function truncate(text, max) {
}
function parseJinaTitle(text) {
const m = String(text || "").match(/^\s*#\s+(.+)$/m);
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);
return m ? m[1].trim() : null;
}
@@ -151,11 +154,14 @@ 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(target, {
method: "GET",
headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {}
const r = await tryFetch("https://r.jina.ai/", {
method: "POST",
headers: {
"content-type": "application/json",
...(apiKey ? { authorization: `Bearer ${apiKey}` } : {})
},
body: JSON.stringify({ url })
}, timeoutMs);
if (!r.ok) {

View File

@@ -96,7 +96,7 @@ export async function handleImageGenerationCore({
let requestBody;
try {
url = adapter.buildUrl(model, credentials);
url = adapter.buildUrl(model, credentials, body);
requestBody = await adapter.buildBody(model, body);
headers = adapter.buildHeaders(credentials, requestBody, model, body);
} catch (error) {
@@ -140,7 +140,7 @@ export async function handleImageGenerationCore({
try {
const retryBody = await adapter.buildBody(model, body);
const retryHeaders = adapter.buildHeaders(credentials, retryBody, model, body);
const retryUrl = adapter.buildUrl(model, credentials);
const retryUrl = adapter.buildUrl(model, credentials, body);
providerResponse = await fetch(retryUrl, {
method: "POST",
headers: retryHeaders,

View File

@@ -12,6 +12,7 @@ import blackForestLabs from "./blackForestLabs.js";
import runwayml from "./runwayml.js";
import cloudflareAi from "./cloudflareAi.js";
import antigravity from "./antigravity.js";
import xai from "./xai.js";
const ADAPTERS = {
openai: createOpenAIAdapter("openai"),
@@ -19,7 +20,7 @@ const ADAPTERS = {
openrouter: createOpenAIAdapter("openrouter"),
recraft: createOpenAIAdapter("recraft"),
"vercel-ai-gateway": createOpenAIAdapter("vercel-ai-gateway"),
xai: createOpenAIAdapter("xai"),
xai,
gemini,
codex,
sdwebui,

View File

@@ -0,0 +1,137 @@
// xAI Grok Imagine — text-to-image + single/multi image editing
// Docs:
// https://docs.x.ai/developers/model-capabilities/images/generation
// https://docs.x.ai/developers/model-capabilities/images/editing
// https://docs.x.ai/developers/model-capabilities/images/multi-image-editing
import { sizeToAspectRatio } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const IMG_CFG = PROVIDER_MEDIA["xai"]?.imageConfig || {};
const GENERATIONS_URL = IMG_CFG.baseUrl || "https://api.x.ai/v1/images/generations";
const EDITS_URL = IMG_CFG.editsUrl || "https://api.x.ai/v1/images/edits";
const ASPECT_RATIOS = new Set([
"auto",
"1:1",
"16:9",
"9:16",
"4:3",
"3:2",
"2:3",
"9:19.5",
"20:9",
]);
function hasEditInput(body) {
if (!body || typeof body !== "object") return false;
if (body.image) return true;
return Array.isArray(body.images) && body.images.some(Boolean);
}
/** Normalize client image input → xAI image ref object */
function toXaiImageRef(input) {
if (!input) return null;
if (typeof input === "object") {
// Already xAI-shaped or partial
if (input.file_id) {
return {
type: input.type || "image_url",
file_id: input.file_id,
...(input.url ? { url: input.url } : {}),
};
}
if (input.url) {
return { type: input.type || "image_url", url: input.url };
}
return null;
}
if (typeof input !== "string") return null;
const trimmed = input.trim();
if (!trimmed) return null;
// Public URL or data URI
if (/^https?:\/\//i.test(trimmed) || /^data:image\//i.test(trimmed)) {
return { type: "image_url", url: trimmed };
}
// Raw base64 → data URI
return { type: "image_url", url: `data:image/png;base64,${trimmed}` };
}
function collectImageRefs(body) {
const refs = [];
if (Array.isArray(body.images)) {
for (const item of body.images) {
const ref = toXaiImageRef(item);
if (ref) refs.push(ref);
}
}
if (body.image) {
const ref = toXaiImageRef(body.image);
if (ref) refs.push(ref);
}
// xAI multi-edit supports up to 3 source images
return refs.slice(0, 3);
}
function resolveAspectRatio(body) {
if (typeof body.aspect_ratio === "string" && body.aspect_ratio.trim()) {
const ratio = body.aspect_ratio.trim();
if (ASPECT_RATIOS.has(ratio)) return ratio;
// Pass through unknown ratio strings (upstream will validate)
return ratio;
}
// OpenAI-style size → aspect ratio (skip auto)
if (body.size && body.size !== "auto") {
return sizeToAspectRatio(body.size);
}
return undefined;
}
function resolveResolution(body) {
if (typeof body.resolution !== "string") return undefined;
const value = body.resolution.trim().toLowerCase();
if (!value || value === "auto") return undefined;
return value; // "1k" | "2k"
}
export default {
buildUrl: (_model, _credentials, body) => (hasEditInput(body) ? EDITS_URL : GENERATIONS_URL),
buildHeaders: (creds) => {
const headers = { "Content-Type": "application/json", ...(IMG_CFG.headers || {}) };
const key = creds?.apiKey || creds?.accessToken;
if (key) headers["Authorization"] = `Bearer ${key}`;
return headers;
},
buildBody: (model, body) => {
const req = {
model,
prompt: body.prompt,
};
if (body.n != null) req.n = body.n;
if (body.response_format) req.response_format = body.response_format;
const aspectRatio = resolveAspectRatio(body);
if (aspectRatio) req.aspect_ratio = aspectRatio;
const resolution = resolveResolution(body);
if (resolution) req.resolution = resolution;
const refs = collectImageRefs(body);
if (refs.length === 1) {
req.image = refs[0];
} else if (refs.length > 1) {
req.images = refs;
}
return req;
},
// xAI already returns OpenAI-compatible { created, data: [{ url | b64_json }] }
normalize: (responseBody) => responseBody,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,6 +51,25 @@ 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, {
@@ -166,4 +185,5 @@ export const FORMAT_HANDLERS = {
tortoise,
openai: openaiCompat,
"minimax-tts": minimaxTts,
"fish-audio": fishAudio,
};

View File

@@ -6,6 +6,8 @@ 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";
@@ -18,6 +20,8 @@ const SPECIAL_ADAPTERS = {
openai,
openrouter,
gemini,
"xiaomi-mimo": xiaomiMimo,
"selfhosted-tts": selfhostedTts,
};
export function getTtsAdapter(provider) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -71,7 +71,11 @@ export function capabilitiesFromServiceKind(kind) {
* otherwise mis-match. Only declare deltas vs DEFAULT.
*/
export const MODEL_CAPABILITIES = {
// Claude 4.6/4.7/4.8 have 1M context + adaptive thinking (override generic claude pattern)
// 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-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 },
@@ -82,6 +86,10 @@ export const MODEL_CAPABILITIES = {
"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 },
// Gemini image-gen / OpenAI image / xai image variants
"gpt-image-1": { imageOutput: true, tools: false },
@@ -92,12 +100,58 @@ 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
@@ -120,6 +174,11 @@ 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 },
},
};
/**
@@ -130,6 +189,7 @@ 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" } },
@@ -145,6 +205,7 @@ 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 } },
@@ -173,21 +234,29 @@ 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 (enable_thinking + thinking_budget; QwQ = thinking-only) ─
// ── Qwen (3.5+ = native vision/video; coder & max = text-only; QwQ = thinking-only) ─
{ pattern: "*qwen*vl*", caps: { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 262144 } },
{ pattern: "*qwen*max*", caps: { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
{ 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*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*k2.7*code*", caps: { vision: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 262144 } },
{ 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*", caps: { vision: true, reasoning: true, thinkingFormat: "kimi", contextWindow: 262144, maxOutput: 262144 } },
{ pattern: "*kimi*", caps: { reasoning: true, thinkingFormat: "kimi", contextWindow: 262144 } },
@@ -211,7 +280,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, contextWindow: 1048576, maxOutput: 131072 } },
{ pattern: "*mimo*v2.5*", caps: { vision: true, audioInput: true, videoInput: 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 } },
@@ -233,6 +302,13 @@ 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 } },
@@ -252,13 +328,17 @@ 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 && PROVIDER_CAPABILITIES[provider]?.[model]) {
return { ...DEFAULT_CAPABILITIES, ...PROVIDER_CAPABILITIES[provider][model] };
if (provider) {
const providerCaps = PROVIDER_CAPABILITIES[provider];
if (providerCaps?.[model]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[model] };
if (providerCaps?.[baseModel]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[baseModel] };
}
// 2. Canonical exact (strip vendor prefix: "anthropic/claude-opus-4.7" -> "claude-opus-4.7")
const baseModel = model.includes("/") ? model.split("/").pop() : model;
// 2. Canonical exact
if (MODEL_CAPABILITIES[baseModel]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[baseModel] };
if (MODEL_CAPABILITIES[model]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[model] };

View File

@@ -1,5 +1,14 @@
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",
@@ -29,3 +38,11 @@ export function modelStrip(model) {
export function modelTargetFormat(model) {
return model?.targetFormat || MODEL_DEFAULTS.targetFormat;
}
// Per-model declared upstream formats (e.g. ["openai", "claude"]). Guards the
// sourceFormat-matched transport for multi-endpoint providers whose models differ
// in endpoint support (opencode-go: kimi/glm only do /chat/completions, minimax/qwen
// also do /messages, deepseek also does /responses).
export function modelSupportedFormats(model) {
return model?.supportedFormats || null;
}

View File

@@ -28,6 +28,7 @@ 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 },
@@ -36,27 +37,37 @@ 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: 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": { 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.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: 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.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.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-flash-preview": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 },
"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-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 },
@@ -74,10 +85,18 @@ 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 ===
@@ -122,10 +141,126 @@ export const MODEL_PRICING = {
* Keyed by provider alias (cc, cx, gc, gh, ...) or provider id (openai, anthropic, ...).
*/
export const PROVIDER_PRICING = {
// GitHub Copilot (gh) — gpt-5.3-codex has different rate than canonical
// GitHub Copilot (gh) — explicit override, matches canonical gpt-5.3-codex rate
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 },
},
};
/**
@@ -140,11 +275,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: 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-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-spark", pricing: { input: 3.00, output: 12.00, cached: 0.30, reasoning: 12.00, cache_creation: 3.00 } },
{ 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 } },
{ 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 } },
// --- Claude ---
{ pattern: "claude-opus-*", pricing: { input: 5.00, output: 25.00, cached: 0.50, reasoning: 25.00, cache_creation: 6.25 } },
@@ -161,11 +296,12 @@ 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.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-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-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 } },
@@ -183,6 +319,7 @@ 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 } },
@@ -279,7 +416,10 @@ 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 nonCachedInput = Math.max(0, inputTokens - cachedTokens);
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);
cost += nonCachedInput * (pricing.input / 1000000);
@@ -295,7 +435,6 @@ export function calculateCostFromTokens(tokens, pricing) {
cost += reasoningTokens * ((pricing.reasoning || pricing.output) / 1000000);
}
const cacheCreationTokens = tokens.cache_creation_input_tokens || 0;
if (cacheCreationTokens > 0) {
cost += cacheCreationTokens * ((pricing.cache_creation || pricing.input) / 1000000);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import { platform, arch } from "os";
import { ANTIGRAVITY_OAUTH_CLIENT } from "../shared.js";
import { ANTIGRAVITY_IDE_BASE_URL, ANTIGRAVITY_IDE_USER_AGENT, ANTIGRAVITY_OAUTH_CLIENT } from "../shared.js";
export default {
id: "antigravity",
@@ -20,13 +19,10 @@ export default {
category: "oauth",
serviceKinds: ["llm", "image"],
transport: {
baseUrls: [
"https://daily-cloudcode-pa.googleapis.com",
"https://daily-cloudcode-pa.sandbox.googleapis.com",
],
baseUrls: [ANTIGRAVITY_IDE_BASE_URL],
format: "antigravity",
headers: {
"User-Agent": "antigravity/1.107.0 darwin/arm64",
"User-Agent": ANTIGRAVITY_IDE_USER_AGENT,
},
retry: {
"429": {
@@ -40,6 +36,7 @@ export default {
},
},
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",
@@ -48,6 +45,13 @@ 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)" },
@@ -71,12 +75,11 @@ export default {
"https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs",
],
apiEndpoint: "https://cloudcode-pa.googleapis.com",
apiEndpoint: "https://daily-cloudcode-pa.googleapis.com",
apiVersion: "v1internal",
loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
onboardUserEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
loadCodeAssistUserAgent: "google-api-nodejs-client/9.15.1",
loadCodeAssistApiClient: "google-cloud-sdk vscode_cloudshelleditor/0.1",
loadCodeAssistUserAgent: ANTIGRAVITY_IDE_USER_AGENT,
refreshLeadMs: 300000,
},
features: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,9 +23,24 @@ export default {
format: "commandcode",
forceStream: true,
headers: {
"x-command-code-version": "0.25.7",
"x-command-code-version": "1.10.0",
"x-cli-environment": "cli",
"User-Agent": "cli",
},
// Quota/billing endpoints (same alpha API the official CLI /usage calls).
// whoami resolves orgId; credits+subscription+usage/summary then report the
// 5-hour/weekly windows, plan, and period credits. See services/usage/commandcode.js.
usage: {
baseUrl: "https://api.commandcode.ai",
whoamiUrl: "/alpha/whoami",
creditsUrl: "/alpha/billing/credits",
subscriptionsUrl: "/alpha/billing/subscriptions",
summaryUrl: "/alpha/usage/summary",
},
},
features: {
usage: true,
usageApikey: true,
},
models: [
{ id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro" },

View File

@@ -23,7 +23,7 @@ export default {
"Content-Type": "application/connect+proto",
"User-Agent": "connect-es/1.6.1",
},
clientVersion: "3.1.0",
clientVersion: "3.12.17",
},
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: "/aiserver.v1.AiService/GetDefaultModelNudgeData",
modelsEndpoint: "/agent.v1.AgentService/GetUsableModels",
api3Endpoint: "https://api3.cursor.sh",
agentEndpoint: "https://agent.api5.cursor.sh",
agentNonPrivacyEndpoint: "https://agentn.api5.cursor.sh",
clientVersion: "3.1.0",
clientVersion: "3.12.17",
clientType: "ide",
dbKeys: {
accessToken: "cursorAuth/accessToken",

View File

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

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