Compare commits
402 Commits
feature/sy
...
gitea/new_
| Author | SHA1 | Date | |
|---|---|---|---|
| 81a47f36c6 | |||
| a84ba559f3 | |||
| 302795a613 | |||
| 02f097880d | |||
| 38ff11ee16 | |||
| e3ba5d2207 | |||
| ddfa789a31 | |||
| 6f52d7020c | |||
| 59f17b3725 | |||
| a835771c97 | |||
|
|
eb712ca821 | ||
|
|
11222eff0f | ||
|
|
e214fb1c30 | ||
|
|
f615a83cb2 | ||
|
|
e74db4d0a6 | ||
|
|
77e6a227fe | ||
|
|
1442cc73ce | ||
|
|
fb9fab0206 | ||
|
|
28cfd9facf | ||
|
|
1a3d446831 | ||
|
|
97f3ab97b1 | ||
|
|
0da803eef4 | ||
|
|
81f4f93082 | ||
|
|
cec672d9d9 | ||
|
|
ed963931b4 | ||
|
|
f388b5e56b | ||
|
|
b84681d5a4 | ||
|
|
2ab6a4c949 | ||
|
|
c08efdbe2b | ||
|
|
4eda76e2ab | ||
|
|
e0ffc7e2a1 | ||
|
|
6ab9ca9eb1 | ||
|
|
6efb97904b | ||
|
|
831001c322 | ||
|
|
e7dd72a8d7 | ||
|
|
5caa72f5fb | ||
|
|
e014cb537f | ||
|
|
d1d4e0f02b | ||
|
|
ac98dd9d32 | ||
|
|
a58902e4a7 | ||
|
|
98579f98c1 | ||
|
|
15687d1913 | ||
|
|
acb5c34cdc | ||
|
|
b870b5d41b | ||
|
|
1f190bd00b | ||
|
|
70f15aa50b | ||
|
|
1fe996db6a | ||
|
|
c24a854278 | ||
|
|
1fc2a81d65 | ||
|
|
f6c59d30b0 | ||
|
|
ac9120fde3 | ||
|
|
ee7a961633 | ||
|
|
f68d2f5ee5 | ||
|
|
ed1bd0c528 | ||
|
|
925cb4aade | ||
|
|
b9c92cb83c | ||
|
|
44e4b80bbe | ||
|
|
9d3f7646d1 | ||
|
|
009cac6326 | ||
|
|
38f031f4c9 | ||
|
|
90b52e06ff | ||
|
|
2203cd8f2b | ||
|
|
2fd99eae5d | ||
|
|
df85e16d7a | ||
|
|
dff648496c | ||
|
|
88676b3037 | ||
|
|
bb3cb43e09 | ||
|
|
4a371d1d9f | ||
|
|
d91e8b85e0 | ||
|
|
993c6eb469 | ||
|
|
28d005772a | ||
|
|
2a9213c5bd | ||
|
|
ec6692808b | ||
|
|
e5a13c3ab7 | ||
|
|
67d9182e1a | ||
|
|
9dbdca0e5e | ||
|
|
5a86f6a8d2 | ||
|
|
eb312bd470 | ||
|
|
fcfcced4ab | ||
|
|
56a40765e9 | ||
|
|
cadef6c4ff | ||
|
|
ab044e6d6d | ||
|
|
14401c433c | ||
|
|
e08ac6dada | ||
|
|
f9d82c6575 | ||
|
|
2f17352cc2 | ||
|
|
90a0005845 | ||
|
|
e79ae6e7c5 | ||
|
|
a68ada1c83 | ||
|
|
c4af43faa3 | ||
|
|
d7f7d70dd5 | ||
|
|
9c45b27cd7 | ||
|
|
e6f5724b4b | ||
|
|
d01724556a | ||
|
|
40eed18688 | ||
|
|
0532f00d84 | ||
|
|
9c650e1d54 | ||
|
|
1a3db1efae | ||
|
|
f0a6d35818 | ||
|
|
548e32aacf | ||
|
|
abb20d9f39 | ||
| 86112cee6d | |||
| 5c6048759c | |||
| d0f202a75d | |||
| f0adfb205a | |||
| 1d56e2dbc5 | |||
| 55f10c11e5 | |||
| eedad6c5ea | |||
| 99752a397c | |||
| bb8d67ba9c | |||
| 144dda2ac2 | |||
| bc9719fac7 | |||
| 6770f6ba0b | |||
| c61dc6de46 | |||
| b5c0f10610 | |||
| 1256f29d92 | |||
| de9e00c66d | |||
|
|
699edac327 | ||
|
|
540ebbe682 | ||
|
|
e1115e2839 | ||
|
|
27f3710c8b | ||
|
|
59d858b639 | ||
|
|
92259214db | ||
|
|
b04c03c6b5 | ||
|
|
8b2b2fefb5 | ||
|
|
86694ed8d0 | ||
|
|
8af5e752da | ||
|
|
8ed9da7165 | ||
|
|
7e5f5a8813 | ||
|
|
345cdcf6a5 | ||
|
|
65197ad11c | ||
|
|
e02bde4a70 | ||
|
|
30fec4318e | ||
|
|
67271d859e | ||
|
|
b566b20ade | ||
|
|
6d30ce6de5 | ||
|
|
5b417f9bf2 | ||
|
|
b57c041345 | ||
|
|
8a527fec91 | ||
|
|
70ba0024b0 | ||
|
|
80afb59907 | ||
|
|
10a923da11 | ||
|
|
01858feca0 | ||
|
|
e2a4fe048f | ||
|
|
b44bb09f72 | ||
|
|
456f2a2635 | ||
|
|
cd4003bc8b | ||
|
|
71dcdc1053 | ||
| a3182a7265 | |||
| 386b25ff7f | |||
|
|
15223724c3 | ||
|
|
35f86e5828 | ||
|
|
41588bea01 | ||
|
|
03f8487cc7 | ||
|
|
99639c0540 | ||
|
|
e41d85037d | ||
|
|
02c66fe2bd | ||
|
|
dcdd4628b3 | ||
|
|
6498b3122f | ||
|
|
8e59093db7 | ||
|
|
cd13d904d7 | ||
|
|
fe547f4dc0 | ||
|
|
b480892952 | ||
|
|
3fab15ae3e | ||
|
|
d06e0d26c6 | ||
|
|
b11be8be0a | ||
|
|
42c691b3ea | ||
|
|
a7941ddab4 | ||
|
|
646b3b9ba3 | ||
|
|
baebc9a06e | ||
|
|
25e4bf1c6c | ||
|
|
c570fe33ae | ||
|
|
d0751bcff7 | ||
|
|
948dd8f89b | ||
|
|
86131b9ca4 | ||
|
|
651df2f0e2 | ||
|
|
da8691f866 | ||
|
|
13ed14568d | ||
|
|
1eb37db32d | ||
|
|
d433c0b295 | ||
|
|
3292dfc102 | ||
|
|
9138c99391 | ||
|
|
41606a37a3 | ||
|
|
2abe8b855c | ||
|
|
c06cc08453 | ||
|
|
d6df6576c5 | ||
|
|
786b3013ba | ||
|
|
0648e9e420 | ||
|
|
ae4f76c433 | ||
|
|
918b3c87a1 | ||
|
|
0e5da70cb1 | ||
| 2a37a4085e | |||
| e2f8323ab1 | |||
| 9bd7adc556 | |||
| 34a78f579e | |||
| c8b96a61e7 | |||
| e438a03f96 | |||
| 008e0ef311 | |||
| 5058a402f1 | |||
| 9b27ee2611 | |||
| c5ce1ef140 | |||
| fcd3dcb409 | |||
| 067f18aaa1 | |||
|
|
f260a1817b | ||
| 88faba150a | |||
| 0dbae80930 | |||
|
|
6fcd27337a | ||
|
|
9be6588cc8 | ||
|
|
1319dea620 | ||
|
|
31df0635aa | ||
|
|
baf3356583 | ||
|
|
24fd165b0d | ||
|
|
a8313cd322 | ||
|
|
f8e8039446 | ||
|
|
e3e3e235f6 | ||
|
|
0afe949387 | ||
|
|
5e59790824 | ||
|
|
16cb40fda1 | ||
|
|
44c7b34837 | ||
|
|
15dfd86416 | ||
|
|
8b0fcf4b16 | ||
|
|
6d96e24bd9 | ||
|
|
3b14bf4a49 | ||
|
|
9c9dd7b191 | ||
|
|
f17a68aaee | ||
|
|
6eaa9f8369 | ||
|
|
65ac9b3cec | ||
|
|
72ec06a81d | ||
|
|
de2da19a9e | ||
|
|
aa0448f7e2 | ||
|
|
41c9e6be87 | ||
|
|
8e04fe1734 | ||
|
|
783e271c16 | ||
|
|
3c17d3406b | ||
|
|
007d372724 | ||
|
|
e45bd73d6e | ||
|
|
c85a5c57ba | ||
|
|
57b3b2c175 | ||
|
|
53a8b5ed55 | ||
|
|
039c4dbc72 | ||
|
|
79918c7830 | ||
|
|
6994cd1f70 | ||
|
|
4f48ab8c7f | ||
|
|
c97963c4fb | ||
|
|
cef5dd4d61 | ||
|
|
d587b2a487 | ||
|
|
7c7fae3955 | ||
|
|
9ba8f37486 | ||
|
|
55628eea02 | ||
|
|
c4a120af8f | ||
|
|
eb00222c4f | ||
|
|
43d4abbcf2 | ||
|
|
e0ba667450 | ||
|
|
0513bf393f | ||
| d826e39008 | |||
| 2897cc3972 | |||
|
|
ccb0842d0a | ||
|
|
68566f53dc | ||
|
|
bc252ea802 | ||
|
|
de680e789f | ||
|
|
6acc3bb965 | ||
|
|
8b9cac180e | ||
|
|
30d0f6d3d8 | ||
|
|
59b7828237 | ||
|
|
d6761c6fb0 | ||
|
|
02ccdc2d22 | ||
|
|
9c58ba645e | ||
|
|
70e8dc4974 | ||
|
|
b94685b80d | ||
|
|
0248dd5348 | ||
|
|
27b37705b3 | ||
|
|
7dfb346667 | ||
|
|
a6a41dfb3c | ||
|
|
2629218b04 | ||
|
|
c9926897ba | ||
|
|
88a8c72d2d | ||
|
|
ba508f2506 | ||
|
|
a077ee85bd | ||
|
|
e567ba800f | ||
|
|
542a088c04 | ||
|
|
9173c29b66 | ||
|
|
eceac9d7ae | ||
| ab9a3c1d43 | |||
|
|
837cfec5a9 | ||
| b1d368d960 | |||
|
|
f89ba32d79 | ||
|
|
9845a1702f | ||
|
|
a625ea9fd8 | ||
|
|
b61c50cbb7 | ||
|
|
baafc74c1f | ||
|
|
bb314118f2 | ||
|
|
2d515c8abc | ||
|
|
74d5fedf79 | ||
|
|
dcf1927f22 | ||
|
|
e1f3399b73 | ||
|
|
f1f9d27061 | ||
|
|
90df008f0c | ||
|
|
d2599ebf17 | ||
|
|
5cdcf67484 | ||
|
|
b25e10160d | ||
|
|
0270f6ea70 | ||
|
|
ce6bdf7fc2 | ||
|
|
a11937cdd6 | ||
|
|
c73c419d09 | ||
|
|
a3b267a5cb | ||
|
|
65c65a0f56 | ||
|
|
7610f28f42 | ||
|
|
0d4d4bc261 | ||
|
|
3a7a878f91 | ||
|
|
e79f9eddb4 | ||
|
|
b9e2611045 | ||
|
|
ddd5509e97 | ||
|
|
288940960a | ||
|
|
d75471bbbc | ||
|
|
0c55d49ab6 | ||
|
|
cfbdf06047 | ||
|
|
a4c5fa4e14 | ||
|
|
20b442b708 | ||
|
|
71cd5b2f23 | ||
|
|
b10b807063 | ||
|
|
081c6f2aff | ||
|
|
19281b5524 | ||
|
|
bbae990b92 | ||
|
|
8c068a1f5c | ||
|
|
97a6708651 | ||
|
|
a3cd7c82bc | ||
|
|
bf7da67859 | ||
|
|
da0149de97 | ||
|
|
1885ad7f64 | ||
|
|
481e7e467b | ||
|
|
008de32c06 | ||
|
|
b6454d84da | ||
|
|
46e6c01a01 | ||
|
|
5041494e1c | ||
|
|
4dadab9d5f | ||
|
|
7f436e2792 | ||
|
|
54e3245ace | ||
|
|
960f8a0379 | ||
|
|
5cc4f222f8 | ||
|
|
cd557a2552 | ||
|
|
ced51ed62f | ||
|
|
cb0135b695 | ||
|
|
abc0add031 | ||
|
|
9102c4c6d8 | ||
|
|
ce6120ce7b | ||
|
|
7afaecd617 | ||
|
|
a5363b83b5 | ||
|
|
b08751c4ea | ||
|
|
76752a4396 | ||
|
|
602ee4054b | ||
|
|
8f81f17b99 | ||
|
|
182c849979 | ||
|
|
0b3c794075 | ||
|
|
a9785a5f70 | ||
|
|
373850ee36 | ||
|
|
749c2e3f9c | ||
|
|
7fa2e7f029 | ||
|
|
8d1db46beb | ||
|
|
9e3866658a | ||
|
|
8a664d619d | ||
|
|
2d94fffe3b | ||
|
|
319caa2d7b | ||
|
|
95bfc64f06 | ||
|
|
eff81b1242 | ||
|
|
b66b5c68ce | ||
|
|
fc8722e897 | ||
|
|
713c563765 | ||
|
|
3d20a4ccd2 | ||
|
|
526235872a | ||
|
|
cce47dd809 | ||
|
|
90b336d9dd | ||
|
|
4a54824f7f | ||
|
|
639f1204d0 | ||
|
|
2deacf69b1 | ||
|
|
8ac631d615 | ||
|
|
3a866fe18d | ||
|
|
940a35e009 | ||
|
|
a4f44e3e12 | ||
|
|
49a3ec7a72 | ||
|
|
6e9c7bf448 | ||
|
|
ab5ec52f28 | ||
|
|
eb9728d084 | ||
|
|
d4d11357ab | ||
|
|
fb543a1f39 | ||
|
|
c7933de79c | ||
|
|
d8c2298d07 | ||
|
|
520f5049bf | ||
|
|
f46811c75e | ||
|
|
d1e98d9a60 | ||
|
|
77b3856402 | ||
|
|
dae69a3916 | ||
|
|
1980178d02 | ||
|
|
e544bfceae | ||
|
|
c842dc8f07 | ||
|
|
4d9da5db26 | ||
|
|
ce844899ed | ||
|
|
644bff4cdd | ||
|
|
c22f11de38 | ||
|
|
0d21668917 | ||
|
|
ec096d2add | ||
|
|
c4f80d30d8 | ||
|
|
cb65a45e1f |
@@ -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
|
||||
|
||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -75,3 +75,25 @@ 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/*
|
||||
|
||||
# Kiro local workspace state
|
||||
.kiro/
|
||||
|
||||
# CommandCode CLI local state (auth/taste/projects)
|
||||
.commandcode/
|
||||
|
||||
# Pi subagent run artifacts
|
||||
.pi-subagents/
|
||||
# Vitest local artifacts
|
||||
.vitest/
|
||||
|
||||
637
CHANGELOG.md
637
CHANGELOG.md
@@ -1,3 +1,569 @@
|
||||
# v0.5.70 (2026-09-08)
|
||||
|
||||
## Features
|
||||
- **Providers**: creating a compatible / custom-embedding node now registers the endpoint only — the API key is added afterwards from the node's page, like the built-in providers. The create dialogs drop the API Key / Model ID / Check fields, and `POST /api/provider-nodes` no longer accepts credentials at all, so a node can never be half-created
|
||||
- **Providers**: compatible nodes now use the same model rows as built-in providers — capability badges, copy, per-model test, alias handling and the Add/Edit Model modal with vision + reasoning toggles, replacing the weaker read-only list
|
||||
- **Providers**: compatible nodes get the built-in bulk toolbars: Test All / Disable / Active / Select All over connections, and Test All Models / Disable All / Active All over models, with per-row disable and a restore strip for disabled models
|
||||
- **Providers**: wire the dead "Fetch Models" button on compatible nodes to the live upstream catalog, de-duplicating against already-added models
|
||||
|
||||
## Fixes
|
||||
- **Usage**: nested combos (comboA lists comboB, comboC, …) stay one slot each — the inner combo always runs as fallback to produce a single answer, failed hops are not written to Details/usage, and streaming no longer inserts a 0-token placeholder. One user message against a nested fallback combo is one request row with real tokens; fusion of nested combos is N panel slots + judge, not every nested leaf
|
||||
- **Models**: persist per-model capability assertions for custom and compatible providers and honor them everywhere — unsupported media is stripped on the chat path, `/v1/models` and `/api/models` report what the user asserted, and thinking translation follows it (asserting `reasoning:false` now actually strips thinking fields, `reasoning:true` emits them)
|
||||
- **Models**: partial capability edits merge instead of overwriting, so toggling vision off no longer erases a stored reasoning assertion
|
||||
- **Capabilities**: keep server-injected readers (synced catalog, user-asserted capabilities) in process-wide state — Next.js compiles startup and each API route into separate bundles with their own module instances, so a boot-time install was invisible to every request handler and the models.dev catalog contributed nothing to upstream requests since 0532f00d
|
||||
- **Dashboard**: thinking-level picker and model-row suffix reflect user-asserted reasoning on compatible nodes
|
||||
- **Providers**: `Default Model` is optional when adding an API key to a compatible node — the node's own model list (and the picker in the test modals) already determine what gets probed, and the built-in fallback still covers connection checks
|
||||
- **Providers**: restore the `useCopyToClipboard` import dropped from the provider detail page, which crashed the route with `ReferenceError` for every provider
|
||||
- **DB**: restore `getModelAliases` / `setModelAlias` / `deleteModelAlias` re-exports dropped from the `localDb` shim by 86112cee, which broke `GET /api/models` and `GET /v1/models` at import time
|
||||
- **Providers**: remove dead `PassthroughModelsSection` (never passed props, superseded by the shared model rows)
|
||||
- **Media Providers**: creating a custom embedding node reports that a key still has to be added, instead of claiming a key was saved; the edit dialog keeps its API Key + Check affordance since a stored key already exists there
|
||||
- **Build**: self-host Inter instead of fetching it through `next/font/google` at build time — a Docker / mirrored builder with no route to `fonts.googleapis.com` failed the entire image build on `Failed to fetch 'Inter' from Google Fonts`. The seven `@font-face` rules and their `unicode-range`s copy what `next/font` emitted (a `latin`-only file would have dropped Vietnamese diacritics) and the latin subset is preloaded as before, so rendered metrics are unchanged
|
||||
|
||||
# v0.5.69 (2026-09-05)
|
||||
|
||||
## Features
|
||||
- **Codex**: add GPT 6.0 Astra (`gpt-6-astra`) with vision, thinking and search capabilities
|
||||
- **Usage**: add Claude Fable quota tracker support with weekly window normalization (`weekly fable (7d)`)
|
||||
- **Dashboard**: group Antigravity Gemini and Claude quotas in Quota Tracker, prune stale hidden keys
|
||||
- **OpenCode Go**: add `muse-spark-1.3-contributor` model and support parallel tool calls on Responses path (#3819)
|
||||
- **Providers & Models**: align CodeBuddy-CN catalog/capabilities with server config; add GPT-5.6 Sol, Terra, Luna image aliases on Codex (#3806); refresh Qoder catalog with capability mapping and image pass-through
|
||||
- **CLI tools**: replace Copilot MITM with VS Code extension setup guide
|
||||
- **Gemini**: persist and replay `thoughtSignature` scoped by session namespace
|
||||
|
||||
## Fixes
|
||||
- **Claude**: normalize adaptive auto effort (`output_config.effort`) (#3792)
|
||||
- **Antigravity**: prevent Google anti-abuse rate limits during multi-account refresh (#3813)
|
||||
- **Anthropic-compatible**: forward Claude beta flags to nodes fronting Anthropic (#3797)
|
||||
- **Dashboard**: dynamic mode label for local/remote detection (#3801)
|
||||
- **Codex**: format reset credit API errors cleanly (#3778)
|
||||
- **Security**: guard cowork MCP tools probe against SSRF (#3783)
|
||||
- **OpenCode Go**: track OpenCode Go quota (#3791) and send stable session headers (#3800)
|
||||
- **Logger**: suppress noisy background token refresh logs
|
||||
- **CLI**: export packed `.tgz` directly into workspace root instead of parent directory
|
||||
|
||||
# v0.5.65 (2026-09-03)
|
||||
|
||||
## Features
|
||||
- **Fetch**: add Ollama Cloud web fetch provider
|
||||
- **Gemini / Antigravity**: add Gemini 3.8 Flash support and bump IDE fingerprint to 2.11.0
|
||||
- **Claude**: add Claude Fable 5.1 support (adaptive thinking with `output_config.effort`), bump Claude Code fingerprint to 2.1.258 for new-model access
|
||||
- **Providers**: add client-side status filter (All / Active / Inactive / No connection) on the Providers dashboard; add max height and scroll for connection list
|
||||
- **Providers & Models**: streamline tokenrouter model catalog down to 22 flagship/newest models and add missing provider icons; refresh Codebuddy-CN catalog (add hy4-preview/hy3/glm-5.3/kimi-k3-1, drop EOL glm-5.0/glm-4.7)
|
||||
- **Models**: capability toggles (vision, reasoning) when adding custom models with upsert and live caps refresh
|
||||
- **CLI tools**: support saving and managing custom API key presets
|
||||
- **Quota**: add usage and rate-limit tracking for Groq via `x-ratelimit-*` headers
|
||||
- **i18n**: complete Indonesian translation (1391 keys)
|
||||
|
||||
## Fixes
|
||||
- **Security**: close SSRF guard bypasses in `ssrfGuard.js` (alternate IPv6 encodings, hostname trailing dots, wildcard DNS resolution check, safe redirect handling) (#3714)
|
||||
- **Model markers**: strip the `[1m]` context marker Claude Code appends to model names (`claude-opus-5[1m]`) preventing model resolution failures (#3690)
|
||||
- **Claude**: drop `server_tool_use` blocks carrying foreign IDs to avoid Anthropic 400 rejections; never anchor cache breakpoints on `defer_loading` tools (#3567)
|
||||
- **Antigravity**: strike-break optimistic quota readings that keep 429ing by blocking the connection+model pair for 15m after 3 strikes (#3681); preserve client identity on model catalog requests (#3414)
|
||||
- **Auth**: protect root `/responses` rewrite requiring API key validation in dashboardGuard
|
||||
- **Chat & Docker**: return 503 Service Unavailable when all credentials are rate-limited; explicitly bundle `node-machine-id` into standalone Docker runtime image
|
||||
- **OpenCode**: route Muse Spark models to `/zen/v1/responses` and declare vision support; filter inactive free model
|
||||
- **Kiro**: preserve inline images as OpenAI-compatible `image_url` parts in OpenAI MITM; remove redundant top-level `systemPrompt` from payload
|
||||
- **Usage**: read Responses-shape `cached_tokens` in `extractUsageFromResponse` for non-streaming traffic
|
||||
- **Models**: support single model lookup with provider-prefixed IDs (e.g. `cc/claude-sonnet-5`)
|
||||
- **Translator**: route Gemini thinking through `reasoning_effort` on OpenAI-compatible wire; convert `prefixItems` and ensure array items in Gemini schema sanitizer
|
||||
- **UI**: apply persisted theme before first paint to prevent flash on reload; translate combo vision adapter label
|
||||
|
||||
# v0.5.59 (2026-08-29)
|
||||
|
||||
## Features
|
||||
- **Search**: new web search providers — Antigravity (Google Search grounding
|
||||
on the existing OAuth account pool, citations keyed and merged by URL) and
|
||||
Xquik (X search with `x-api-key` auth, cursor pagination, credit-based
|
||||
usage), both on `POST /v1/search`. Based on #3437 by @Nautilaceae
|
||||
- **Search**: ollama-search and zai-search borrow a chat provider's API key
|
||||
instead of requiring their own connection, driven by a new
|
||||
`credentialFallback` registry field. zai-search later folded into the `glm`
|
||||
provider itself so the web search page shows the shared connection
|
||||
- **Models**: daily background sync of model capabilities from models.dev —
|
||||
modalities keyed by model id (majority of sources must declare one),
|
||||
context/output limits keyed by provider + model, strictly additive and
|
||||
sitting below the hand-written tables. ETag + mtime cache, 60s startup
|
||||
delay, `MODEL_CATALOG_SYNC=off` to disable
|
||||
- **Models**: add GLM-5.3-Flash (1M context, natively multimodal), DeepSeek
|
||||
V4 Vision, Grok 4.5/4.6 (500k context); correct glm-4.6v/4.5v video input
|
||||
and output limits, backfill glm-4.6v on glm-cn
|
||||
- **Usage**: show the Zed plan quota on the dashboard — plan, edit
|
||||
predictions, hosted model requests and billing-cycle reset; unlimited rows
|
||||
render as "N used · Unlimited"
|
||||
- **Usage**: track GPT-5.3-Codex-Spark quota windows (spark_session /
|
||||
spark_weekly) from the Codex usage response (#3431)
|
||||
- **Antigravity**: quota-aware routing — on 409/429 fetch live quota for the
|
||||
exact per-model resetAt and skip only the exhausted account/model pair;
|
||||
report the earliest reset when every account is blocked (#3561)
|
||||
- **Antigravity**: map image `size` to the aspect-ratio model suffix (-WxH);
|
||||
add the Gemini 3.7 Flash tiers to MITM defaultModels so they show up in
|
||||
the dashboard model-mapping table
|
||||
- **Dashboard**: bulk import Grok CLI accounts from JSON — paste an array or
|
||||
drag-drop multiple .json files, all OAuth connections created in a single
|
||||
call, mirroring the codex flow
|
||||
- **CLI tools**: endpoint presets shared across every tool card through one
|
||||
live-resyncing store, instead of per-card localStorage copies that never
|
||||
saw each other's saved endpoints
|
||||
- **Token Saver**: configurable compression timeout (`headroomTimeoutMs`) —
|
||||
the fixed 3000 ms made busy machines time out and send inconsistently
|
||||
compressed bodies, hurting prompt caching
|
||||
- **i18n**: pt-BR expanded to 1132 terms
|
||||
|
||||
## Fixes
|
||||
- **Claude Code**: add Claude Fable 5.1 and advertise Claude Code 2.1.258 in
|
||||
both the request header and billing identity; use its permanent adaptive-thinking
|
||||
mode with `output_config.effort`
|
||||
- **Stream**: record usage when a client closes on the terminal event — the
|
||||
Responses API has no [DONE] sentinel, so codex closed the socket on
|
||||
`response.completed` and cancelled the reader before flush() ran its usage
|
||||
side effects; the tail now lives in a once-guarded finalizeStream(). Also
|
||||
stop logging a disconnect for every completed Responses call
|
||||
- **Stream**: parse the trailing NDJSON line an Ollama stream leaves behind
|
||||
without a closing newline — the final chunk carrying `done_reason` and the
|
||||
token counts was dropped
|
||||
- **Session**: read the Claude Code session id from the
|
||||
`x-claude-code-session-id` header — `metadata.user_id` is dropped by
|
||||
Responses translation, splitting one conversation across several
|
||||
`prompt_cache_key` values and missing the upstream prefix cache
|
||||
- **Usage**: preserve nested `cached_tokens` — the top-level-only read
|
||||
persisted `cached_tokens: 0` for every Responses-format provider (codex,
|
||||
grok-cli, …), billing cache hits at the full input rate
|
||||
- **Usage**: GLM quotas accept CREDIT_LIMIT plans and multi-interval windows
|
||||
(5h session / 7d weekly) instead of overwriting a single "session" key
|
||||
- **Models**: the catalog sync no longer erases its own output — deltas were
|
||||
measured against the previous run's writes (the second run cut `providers`
|
||||
from 20 entries to 5); one vote per provider in the modality tally, ETag
|
||||
restored from file on startup, and the worker thread dropped after the
|
||||
bundler rewrote its path into a module-not-found error
|
||||
- **Executor**: CommandCode returns errors as a `type:"error"` event inside
|
||||
an HTTP 200 NDJSON stream — peek the first events before committing, abort
|
||||
and return a real 4xx/5xx so combo/account fallback triggers instead of
|
||||
streaming the error text as content
|
||||
- **Search**: scope failure locks on the credential-fallback path — a failing
|
||||
search locked `modelLock___all` and took the shared glm key offline for
|
||||
chat as well; locks are now attributed to the connection's owner and
|
||||
scoped to `websearch:<provider>`
|
||||
- **Providers**: connection tests get a 15s AbortSignal timeout instead of
|
||||
hanging and exhausting the browser socket pool; guard undefined provider
|
||||
names on the providers page
|
||||
- **Antigravity**: sanitize competing-client branding via a config-driven
|
||||
rule table (Zed's Claude-agent prompt, opencode → antigravity) — upstream
|
||||
answers 429 Quota Exhausted. Applied in the executor so the shared
|
||||
openai-to-gemini translator leaves gemini/vertex/zed untouched
|
||||
- **MiniMax**: preserve images on the sourceFormat-matched OpenAI transport
|
||||
— MiniMax-M3 resolved a Claude-shaped body posted to the OpenAI endpoint,
|
||||
silently dropping `image_url` blocks (#3418)
|
||||
- **Claude**: decloak tool names in same-format streaming passthrough —
|
||||
OAuth-cloaked names (CLAUDE_TOOL_SUFFIX) leaked to the client and every
|
||||
tool call was rejected as unknown
|
||||
- **Tools**: default a missing `tools[].type` to "custom" on Claude-format
|
||||
requests — strict Anthropic-compatible gateways (MiniMax) reject the
|
||||
request with 400 otherwise
|
||||
- **Translator**: zai thinkingFormat sends the top-level `reasoning_effort`
|
||||
object GLM-5.2+ requires — every GLM-5.x request ran at the model default
|
||||
(max); gated on GLM-5.2+ since older GLM does not read it (#2721)
|
||||
- **RTK**: system prompt injection matches each target wire format
|
||||
(Chat/Responses/Claude/Gemini/Kiro) and is exact-idempotent across retries,
|
||||
so distinct prompts sharing a long prefix are no longer collapsed (#3202).
|
||||
Also set the diagnostic before the silent null return on Responses
|
||||
translation failure so the panel is no longer blank
|
||||
- **OpenCode**: route muse-spark through /zen/v1/responses (it 500s on
|
||||
chat/completions), normalizing the Chat fields the Responses API rejects
|
||||
and clamping max/ultra effort to xhigh
|
||||
- **CLI**: install better-sqlite3 without build tools on Node 22+ (N-API
|
||||
13.0.3 ships per-platform prebuilds, `--ignore-scripts` skips the implicit
|
||||
node-gyp build); Node < 22 stays on 12.6.2, working installs untouched
|
||||
- **CLI tools**: send the API key Codex actually reads —
|
||||
`[model_providers.9router.http_headers]` instead of auth.json (which left
|
||||
every request 401 and clobbered an existing ChatGPT login); subagent model
|
||||
moved to `agents.default_subagent_model`
|
||||
- **OAuth**: refresh Cline tokens with the extension JSON contract
|
||||
- **Dashboard**: clamp the API key mask length — keys shorter than 8 chars
|
||||
threw RangeError and crashed the media-provider detail page
|
||||
- **UI**: wait for the Material Symbols font itself before revealing icons —
|
||||
`document.fonts.ready` resolved before the 4MB woff2 even started loading,
|
||||
leaving icons blank until a second load
|
||||
|
||||
# v0.5.55 (2026-08-14)
|
||||
|
||||
## Features
|
||||
- **Auth**: native SAML 2.0 SSO alongside OIDC — AuthnRequest generation, ACS
|
||||
assertion handling, SP metadata export, admin config test, replay-protected
|
||||
via a `saml_state` cookie matched against `InResponseTo`
|
||||
- **Providers**: add Alibaba Token Plan (`token-plan.ap-southeast-1`) — the
|
||||
fourth Alibaba key type, Singapore-only and OpenAI-compatible transport only
|
||||
- **Providers**: add `glm-5.3` to GLM Coding and GLM (China)
|
||||
- **Providers**: Kimchi accepts API keys as well as OAuth (dual auth), with a
|
||||
working Test Connection for both modes
|
||||
- **Antigravity**: add Gemini 3.7 Flash and its tiered high/medium/low variants
|
||||
(also in the Gemini registry) with pricing and quota tracking
|
||||
- **TTS**: add Fish Audio — model id travels in an HTTP `model` header, voice
|
||||
is a `reference_id` (preset or cloned voice model)
|
||||
- **OpenCode-Go**: route by request format via declared transports instead of
|
||||
forcing every client into `/messages` — Codex/OpenAI clients no longer pay a
|
||||
lossy Responses→OpenAI→Claude double translation. Per-model `supportedFormats`
|
||||
guard; the bespoke executor is gone (its shared `_lastModel` cache could cross
|
||||
auth headers between concurrent requests)
|
||||
- **Usage**: dedup + cache Claude quota calls (120s TTL keyed by access token,
|
||||
in-flight promise dedup, last-good read on soft failure) to stop multiple
|
||||
tabs tripping 429; manual refresh (↻) sends `force=1` to bypass the cache
|
||||
|
||||
## Fixes
|
||||
- **Docker**: ship `sql.js` in the image so the pure-JS DB fallback can start —
|
||||
file tracing carried the package's JS without `dist/sql-wasm.wasm`, so a
|
||||
container with no native driver aborted with ENOENT and never got a database
|
||||
(#3248)
|
||||
- **Usage**: read Gemini `usageMetadata` out of the antigravity `{ response }`
|
||||
envelope — every non-streaming antigravity request logged `IN 0 | OUT 0`
|
||||
(#3260)
|
||||
- **Claude**: re-anchor passthrough cache breakpoints — the client's own
|
||||
`cache_control` markers point at pre-normalization offsets, so the tail was
|
||||
re-cached every request. Last system block and last tool pinned at 1h TTL,
|
||||
last assistant turn at 5m, mid-conversation system messages folded into the
|
||||
neighbouring user turn instead of hoisted into `body.system`
|
||||
- **Combos**: detect images from Hermes and attachment payloads (`images[]`,
|
||||
`experimental_attachments`, message-level `image_url`/`audio_url`, inline
|
||||
`data:` URIs) so the Vision Adapter auto-switch fires for Hermes/Ollama/
|
||||
Vercel AI SDK shapes
|
||||
- **Kiro**: intercept chat via `x-amz-target` — Kiro IDE 1.0.228+ moved
|
||||
`GenerateAssistantResponse` to `POST /` + header, bypassing MITM. Also emit
|
||||
the now-mandatory initial-response frame and map the `auto` model slot
|
||||
- **Kiro**: report real output tokens and stop discarding usable turns
|
||||
- **Qoder**: detect billing blocks at stream start and return a synthetic 403
|
||||
so combo/account fallback triggers instead of leaking the error into chat
|
||||
- **Antigravity**: strip competitive system prompts (Zed IDE's Claude-agent
|
||||
prompt) that Antigravity flags with a 429 Quota Exhausted
|
||||
- **OpenCode**: send the official client fingerprint on free-tier requests so
|
||||
the Console stops classifying traffic as unidentified and rate-limiting it;
|
||||
session id resolves conversation-stable to preserve prompt caching
|
||||
- **Responses**: don't close the message on an empty `tool_calls` array — some
|
||||
providers attach one to every chunk, and the truthy check ended the message
|
||||
on the first content token (#3234)
|
||||
- **Translator**: preserve `prompt_cache_key` when converting chat to responses
|
||||
- **Models**: expose snake_case token limits on `/v1/models`
|
||||
- **Combos**: strip `stream_options` from the Fusion panel fan-out to avoid a
|
||||
DeepSeek 400 (#3024); raise the dashboard model-test probe budget to 1024 and
|
||||
soft-pass reasoning-only responses (#3010)
|
||||
- **Headroom**: the toggle reflects the `headroomEnabled` setting even when the
|
||||
proxy is down — it previously showed OFF while the engine kept calling
|
||||
`/v1/compress`; proxy status stays visible via the status chip
|
||||
- **Hermes**: add the `api_key` parameter to the model block in YAML config
|
||||
- **Providers**: add llm7 to provider test support
|
||||
|
||||
## Docs
|
||||
- **i18n**: add Spanish, French, and Brazilian Portuguese README translations
|
||||
|
||||
## Security
|
||||
- **Real IP**: `x-9r-real-ip` and the Host fallback were trusted from
|
||||
client-controlled headers whenever `custom-server.js` was not in the request
|
||||
path (`npm run start`, `start:bun`), letting a remote caller pose as local to
|
||||
skip API key auth and reach `LOCAL_ONLY_PATHS` (`/api/mcp/*`,
|
||||
`/api/tunnel/enable`, `/api/auth/reset-password`). The server now stamps a
|
||||
per-process `x-9r-peer-token` on every request it sanitizes and only trusts
|
||||
`x-9r-real-ip` behind it — falling back to Host in development and failing
|
||||
closed in production (GHSA-pjm4-8fpg-f9p6). Also fixes IPv6 loopback
|
||||
detection (`::1`, `::ffff:127.0.0.1`) and routes `npm run start` /
|
||||
`start:bun` through `custom-server.js`
|
||||
- **Search**: `resolveBaseUrl()` rejects client-supplied non-public baseUrls
|
||||
(SSRF guard on `/v1/search`)
|
||||
- **Login**: fresh-install remote login with the default password returns 403
|
||||
without issuing a JWT
|
||||
- **Usage**: `/api/usage/request-details` redacts request/response payloads
|
||||
|
||||
# v0.5.50 (2026-08-05)
|
||||
|
||||
## Features
|
||||
- **Providers**: add TokenRouter (300+ models via OpenAI-compatible gateway) with
|
||||
exact per-model pricing for 110 models and `reasoning_effort` thinking config
|
||||
- **Providers**: add Self-hosted STT / TTS / Embedding — point 9Router at your own
|
||||
OpenAI-compatible speech and embedding servers (whisper.cpp, faster-whisper,
|
||||
Kokoro-FastAPI, llama-server, vLLM, Infinity). Unlike the named cloud providers
|
||||
these read `baseUrl` per connection, so one provider can front several machines
|
||||
- **Combos**: default-enable vision/audio capacity adapter (auto-routes to a
|
||||
vision/audio-capable model when the target lacks that capability, falling back
|
||||
to `oc/mimo-v2.5-free`), wired into chat handler routing
|
||||
- **Endpoint**: auto-provision a "Default Key" for first-time users so `/v1`
|
||||
works without a manual dashboard step
|
||||
- **Codex**: support GPT-5.6 Max/Ultra reasoning-level overrides (cx/ routes only)
|
||||
- **Qoder**: support PAT (Personal Access Token) connections end-to-end, alongside
|
||||
OAuth device flow
|
||||
- **CLI tools**: add OpenDesign (manalkaff/opendesign) support
|
||||
- **Headroom**: report effective payload savings (tool schema/history bytes broken
|
||||
out, byte-savings % reflects actual outbound reduction)
|
||||
- **Ollama**: Cloud quota tracker (session + weekly) + proactive background OAuth
|
||||
token refresh scheduler for all providers
|
||||
|
||||
## Fixes
|
||||
- **Providers**: remove Qwen (OAuth flow stopped working reliably)
|
||||
- **Passthrough**: detect codex-tui/Codex Desktop as native Codex client — they
|
||||
were falling through to the translator and losing fields like `reasoning.summary`
|
||||
- **OAuth**: scope antigravity header fixes to loadCodeAssist/onboardUser only
|
||||
- **OAuth**: keep `open` external in the build so xAI/Grok token refresh works on
|
||||
Windows
|
||||
- **OAuth**: declare missing `searchParams` in register-session handler (was a
|
||||
500 instead of JSON on error)
|
||||
- **DB**: `ENABLE_REQUEST_LOGS` env var now overrides the UI setting correctly;
|
||||
observability defaults to off (opt-in)
|
||||
- **Translator**: preserve Codex Responses Lite tool use across chat-native
|
||||
OpenAI-compatible providers
|
||||
- **Translator**: don't drop image-only user messages in `prepareClaudeRequest`
|
||||
- **Translator**: drop JSON Schema keywords Gemini rejects (`uniqueItems`,
|
||||
`contains`, `multipleOf`, `unevaluatedProperties`, `unevaluatedItems`,
|
||||
`contentSchema`)
|
||||
- **Claude**: remove global header cache that leaked one client's identity
|
||||
headers onto another client/account sharing the server; gate `anthropic-beta`
|
||||
by model instead
|
||||
- **Antigravity**: drop retired Gemini 3.0 quota tiers, show Gemini 3.6 Flash
|
||||
usage bars
|
||||
- **Cloudflare AI**: declare API key authentication (dashboard showed "No
|
||||
connections" despite an active key)
|
||||
- **GitHub Copilot**: hold monthly-exhausted accounts until UTC month reset
|
||||
instead of only cooling down 120s
|
||||
- **CodeBuddy**: dodge Tencent CN content filter, add usage tracking, normalize
|
||||
codebuddy-intl messages
|
||||
- **Usage**: stop losing cached prompt tokens in the forced-SSE→JSON path
|
||||
- **Grok CLI**: display the public subscription tier from the OAuth token claim
|
||||
- **Providers**: count apikey connections for Ollama free-tier card; free-tier/
|
||||
apikey providers without `authModes` now default to apikey (were treated
|
||||
oauth-only)
|
||||
- **Build**: include static/public assets in standalone output (login page hung
|
||||
on 404s when run via PM2)
|
||||
- **Server**: support IntelliJ IDEA OpenAI-compatible clients over HTTP (h2c
|
||||
upgrade handling)
|
||||
- **Auth**: redirect already-logged-in sessions away from `/login`
|
||||
- **CLI tools**: enable Apply button for dynamic OpenAI/Anthropic-compatible
|
||||
provider connections
|
||||
- **CLI**: include complete API artifacts in the CLI package
|
||||
- **TTS**: a bare self-hosted model name is the MODEL, not the voice — `kokoro`
|
||||
was parsed as a voice against a default model, 404ing or synthesising with the
|
||||
wrong one
|
||||
- **Embeddings**: self-hosted embeddings no longer fall back to `api.openai.com`
|
||||
when a connection has no `baseUrl` — that silently sent the input text and API
|
||||
key to OpenAI under a provider named "Self-hosted"
|
||||
- **Embeddings**: an adapter that rejects a misconfigured connection now returns
|
||||
400 with the reason instead of escaping the handler uncaught
|
||||
- **Embeddings**: bound the upstream fetch with `FETCH_CONNECT_TIMEOUT_MS` — an
|
||||
endpoint that drops packets never returns headers, so the request previously
|
||||
hung indefinitely
|
||||
|
||||
## Docs
|
||||
- **i18n**: fix port typo, add RTK Token Saver feature descriptions
|
||||
|
||||
# v0.5.45 (2026-07-30)
|
||||
|
||||
## Features
|
||||
- **TTS**: add Xiaomi MiMo text-to-speech (preset voices 冰糖/茉莉/苏打/白桦/Mia/Chloe/Milo/Dean, style control, language hint dropdown with Auto-detect, i18n for Style label/placeholder)
|
||||
- **Providers**: add Poolside (OpenAI-compatible)
|
||||
- **Providers**: add api-airforce, baidu, bazaarlink, bluesminds, kilo-gateway, llm7, morph, sambanova, tencent
|
||||
- **OAuth**: zed / trae / windsurf providers + harden callback proxies
|
||||
- **CLI tools**: set Claude Code max context tokens
|
||||
- **Qoder**: PAT auth + refresh model list
|
||||
- **Gemini**: Gemini 3.6 Flash tier routing + Gemini 3.5 Flash Lite
|
||||
- **Claude**: bump default Opus to `claude-opus-5`
|
||||
- **Kiro**: add Claude Opus 5 models
|
||||
- **Usage**: Kimi and DeepSeek usage handlers
|
||||
- **Usage**: SuperGrok weekly pool via gRPC-web
|
||||
|
||||
## Fixes
|
||||
- **Refresh**: rotate `refresh_token` between retry attempts
|
||||
- **Kiro**: canonicalize tool history and route API keys correctly
|
||||
- **Kiro**: normalize dashboard thinking intensity models
|
||||
- **Cursor**: stop leaking agent tool errors as text
|
||||
- **Gemini**: fill empty tool schemas after `$ref` strip
|
||||
- **Antigravity**: strip `stream_options` from non-stream requests
|
||||
- **Jina-reader**: recover after transient errors, use JSON POST API
|
||||
- **Usage**: record exact embedding tokens
|
||||
- **Tunnel**: preserve successor cloudflared PID
|
||||
- **Console-log**: initialize capture at server boot + prevent SSE proxy buffering
|
||||
- **Dashboard**: count dual-auth, free-tier OAuth and API-key connections correctly
|
||||
- **Dashboard**: flex quota rows, thin global scrollbars, no hidden-row overflow
|
||||
|
||||
## Docs
|
||||
- **i18n**: expand pt-BR translation to 986 terms
|
||||
- README: Indonesian translation
|
||||
|
||||
# v0.5.40 (2026-07-20)
|
||||
|
||||
## Features
|
||||
- **i18n**: add Khmer (km) translations
|
||||
- **CLI tools**: configure Grok Build subagent models
|
||||
- **Kimi**: merge OAuth into dual-auth provider, add K3 / K2.7 models
|
||||
- **Dashboard**: ProviderTopology flow animation
|
||||
|
||||
## Fixes
|
||||
- **DB**: resolve better-sqlite3 parameter binding crash
|
||||
- **Translator**: pass `service_tier` through OpenAI → Responses conversion
|
||||
- **Kiro**: map GPT-5.6 reasoning effort fields
|
||||
- **Kiro**: validate terminal streams before emitting output
|
||||
- **Kiro**: map GPT reasoning effort fields
|
||||
- **Codex**: current `client_version` + refresh-aware model sync
|
||||
- **Alicode-intl**: split into Coding Plan + Model Studio providers
|
||||
- **Cursor**: HTTP/2 AgentService support + version bump 3.12.17
|
||||
- **Dashboard**: cut duplicate API/icon spam, lazy-load provider assets
|
||||
|
||||
|
||||
# v0.5.35 (2026-07-16)
|
||||
|
||||
## Features
|
||||
- **xAI**: Grok Imagine video generation (`/v1/videos`) + CLI
|
||||
- **CLI tools**: Grok Build setup — choose separate main/general-purpose/explore/plan models and preserve each model's context window
|
||||
- **GitHub Copilot**: route Claude models through Copilot's native `/v1/messages`
|
||||
- **Kiro**: add GPT-5.6 model family (#2596)
|
||||
- **RTK**: `X-9Router-Token-Saver` header to bypass token savers per request
|
||||
- **Providers**: quota visibility settings
|
||||
- **Translator**: drop temperature for all Claude models
|
||||
- **i18n**: Thai (th) + Persian (fa) translations / README
|
||||
|
||||
## Fixes
|
||||
- **Providers**: bulk-add API keys no longer overwrite existing keys (gap-fill `Key N`)
|
||||
- **Anthropic**: lowercase `anthropic-version` header to prevent duplication on `/v1/messages`
|
||||
- **Alicode-intl**: use DashScope compatible-mode endpoint so standard keys work
|
||||
- **Grok CLI**: align Grok Build with current subscription protocol (#2590)
|
||||
- **Grok CLI**: surface `expiresAt` so proactive token refresh fires (#2546)
|
||||
- **Kiro**: improve direct session cache reuse
|
||||
- **Models**: populate capabilities for live-catalog LLM models
|
||||
- **Models**: list compatible provider models in `/v1/models`
|
||||
- **Thinking**: send explicit `thinking:{type:adaptive}` alongside `output_config.effort`
|
||||
- **Translator**: strip `client_metadata` when converting openai-responses → openai
|
||||
|
||||
## Improvements
|
||||
- **Perf**: skip inactive background services on startup
|
||||
|
||||
## Docs
|
||||
- README: Persian YouTube tutorial
|
||||
|
||||
# v0.5.30 (2026-07-10)
|
||||
|
||||
## Features
|
||||
- **Perplexity**: add Agent API provider (#2492)
|
||||
- **Grok CLI**: add Grok CLI / Grok Build provider with OAuth device-code flow (#2502)
|
||||
- **Featherless**: add OpenAI-compatible provider presets
|
||||
- **SearXNG**: configure endpoint via SEARXNG_URL env (#2499)
|
||||
- **Providers**: add max thinking level for gpt-5.6-sol (#2500)
|
||||
- **Headroom**: add extras detection and install UI (#2403)
|
||||
- **Headroom**: activate/uninstall extras + fix interpreter detection
|
||||
- **PXPipe**: PXPIPE token saver — multimodal prompt compression (#2465)
|
||||
- **Proxy-Pools**: auto-rotate strategy for no-auth providers (#2409)
|
||||
|
||||
## Fixes
|
||||
- **Cloudflare-AI**: support accountId in bulk key import (#2449)
|
||||
- **DB**: backup on schema change, MCP child cleanup, codex models, usage providers OOM
|
||||
- **Codex**: avoid bare-email OAuth dedup (#2477)
|
||||
- **CLI**: allow staged app bundle builds (#2479)
|
||||
- **Headroom**: compress Kiro conversation state (#2488)
|
||||
- **Gemini-CLI**: raise output floor for thinking and add validated toolConfig (#2486)
|
||||
- **GitHub**: label Copilot profiles by account identity (#2498)
|
||||
- **OpenAI-to-Claude**: unwrap bare {function:{…}} tools without parent type (#2473)
|
||||
- **Translator**: clamp thinking effort max->xhigh for OpenAI format (#2466)
|
||||
- **RTK/find**: detect and group Windows backslash-style find output (#2448)
|
||||
- **Codex**: handle fast tier and capacity SSE (#2452)
|
||||
- **Volcengine-ark**: clamp Kimi max_tokens to 32768 endpoint cap
|
||||
- **Antigravity**: align provider fingerprint with IDE Desktop 2.1.1 (#2389)
|
||||
- **Pricing**: update Claude/Codex model rates and add new models
|
||||
|
||||
## Improvements
|
||||
- **i18n(zh-CN)**: complete Chinese translations for all UI strings (#2436)
|
||||
- **API**: caching for tunnel and version status endpoints
|
||||
- **Perf**: faster dev startup and lighter bundle
|
||||
|
||||
# v0.5.20 (2026-07-07)
|
||||
|
||||
## Features
|
||||
- **Thinking**: per-model thinking level picker on provider page — appends `(level)` suffix to copied model names for forced reasoning effort across all formats (openai, claude, gemini, deepseek, kimi, qwen, zai, minimax, hunyuan, step)
|
||||
- **RTK**: add JS-native git-log filter (#2423)
|
||||
- **Caveman**: add targeted upstream-aligned style rules (#2424)
|
||||
- **i18n**: add Farsi (fa) language support (#2385)
|
||||
|
||||
## Fixes
|
||||
- **Thinking**: strip `(level)` suffix from upstream `body.model` so providers no longer reject requests
|
||||
- **Translator**: preserve developer instructions in openai-responses conversion (#2434)
|
||||
- **count_tokens**: count structured Anthropic blocks (#2419)
|
||||
- **Volcengine-ark**: clamp GLM-5 max_tokens to model output ceiling (#2428)
|
||||
- **Kimi**: normalize reasoning_effort to backend enum (#2427)
|
||||
- **Claude**: reconcile max_tokens vs thinking budget and lift per-model ceiling (#2381)
|
||||
- **Kiro**: deliver system prompt natively, add Opus 4.5/4.7/4.8, tolerate dash version ids (#2366)
|
||||
- **Headroom**: proxy dashboard through app (#2372)
|
||||
- **MITM**: recover from stale lock file on server start
|
||||
|
||||
# v0.5.18 (2026-07-03)
|
||||
|
||||
## Features
|
||||
- **Usage**: track cached tokens + correct input/output/cache cost (#2209) — hodtien
|
||||
- **Codex**: show reset credit expiry details (#2290) — Rafli Ahmad Zulfikar
|
||||
- **NVIDIA**: add new models and capabilities — decolua
|
||||
- **ClinePass**: add provider support — sternelee
|
||||
|
||||
## Fixes
|
||||
- **Usage**: dedupe streaming request-details log entries — Qin Li
|
||||
- **Claude**: drop foreign thinking signatures in passthrough — decolua
|
||||
- Prevent non-SSE stream pipe crash and cross-IdP account overwrites (#2244) — KunN-21
|
||||
- **Kiro**: route IdC auth to regional CodeWhisperer surface (#2297) — Volodymyr Saakian
|
||||
- **Kiro**: add Claude Sonnet 5 model support (#2264) — Edison42
|
||||
- **Xiaomi-tokenplan**: region selector, key validation, multi-connection (#2251) — MiQieR
|
||||
- **Translator**: strict Anthropic content block compliance (#2225) — Sahrul Ramadhan Hardiansyah
|
||||
- **Kimchi**: strip reasoning_content echo to bound multi-turn input tokens — KunN-21
|
||||
- **Kimchi**: bump User-Agent to kimchi/0.1.40 (#2256) — Ansh7473
|
||||
- **Codebuddy-cn**: strip empty tool_calls arrays to preserve reasoning — zmf
|
||||
- **Antigravity**: preserve Claude tool delta index (#2223) — Sutarto Jordan Chrisfivo
|
||||
- **MITM**: generate root CA on server startup (#2228) — Sutarto Jordan Chrisfivo
|
||||
|
||||
# v0.5.15 (2026-06-29)
|
||||
|
||||
## Features
|
||||
- Add Kimchi OAuth provider — Nant361
|
||||
- Refine Qwen vision/video + thinking model patterns — decolua
|
||||
- Opt-in Codex auto-ping quota keep-alive — Emirhan
|
||||
|
||||
## Fixes
|
||||
- **Responses**: handle response.done terminal events (#2142) — rifuki
|
||||
- **Headroom**: skip unsafe responses tool history (#2132) — Sutarto Jordan Chrisfivo
|
||||
- **Translator**: map mid-conversation system message to user (claude→openai) — decolua
|
||||
- **Gemini**: normalize contents to prevent 400 invalid_argument (#2192) — warelik
|
||||
- **Gemini**: backfill thoughtSignature + suppress stream done sentinel — WARELIK
|
||||
- **Alicode**: preserve cache_control for DashScope providers (#2069) — Rex
|
||||
- **Antigravity**: strip deprecated/readOnly/writeOnly from tool schemas — iletai, Yudhistira-Official
|
||||
- **CodeBuddy CN**: show bonus packs as one-time, not monthly-replenishing — whale9820
|
||||
- **Kiro**: strip leaked <thinking> tags from content stream (#2158) — hamsa0x7
|
||||
- **Tray**: make Windows context menu DPI-aware — Emirhan
|
||||
- **Kilocode**: expose full gateway catalog in combo model picker — jellylarper
|
||||
- **OpenCode**: fix Go GLM — decolua
|
||||
|
||||
# v0.5.12 (2026-06-26)
|
||||
|
||||
## Features
|
||||
- Add token-saver dashboard page — decolua
|
||||
- Add bulk delete for provider connections — teddytkz
|
||||
- Resolve GitHub Copilot model catalog from upstream — caiqinzhou
|
||||
- Add Venice AI provider — Brokenc0de
|
||||
- Add Kiro external_idp import for Microsoft SSO (CLIProxyAPI) — Stevanus Pangau
|
||||
- Overhaul Blackbox provider catalog + WebUI test support — suryacagur
|
||||
|
||||
## Fixes
|
||||
- Provider thinking compatibility (DeepSeek/Gemini) — Mink Nguyen
|
||||
- Stop double-counting streaming usage at source — decolua
|
||||
- Usage logging dedupe to reduce stats churn — Mink Nguyen
|
||||
- Prevent non-JSON SSE lines / duplicate [DONE] from breaking clients (PR #2046) — qianze
|
||||
- Resolve Gemini TTS models from catalog — nguyenha935
|
||||
- Support Kiro IDC (organization) token import — quanturbo
|
||||
- Preserve forced streaming for JSON clients (#2031) — Joseph Yaksich
|
||||
- Preserve Responses text format (Codex) — tenglong
|
||||
- Support Gemini native TTS generateContent endpoint — nguyenha935
|
||||
- Add missing zh-CN endpoint key label (i18n) — weimaozhen
|
||||
- CodeBuddy: only send reasoning params when client requests reasoning (#2071) — Rex
|
||||
- CodeBuddy CN: show one-shot bonus packs as expiring, not monthly-replenishing
|
||||
- Show custom provider models in combo picker — Sapto
|
||||
- Docker: add docker-compose.yml with headroom enabled by default — nitsuahlabs
|
||||
- Clarify token diagnostics vs provider billing (headroom, #1998) — Sutarto Jordan Chrisfivo
|
||||
- Translate openai-responses input through OpenAI for compression (#1998) — Ankit
|
||||
- Kiro: report 1M context window for claude-opus-4.8 — EdisonPVE
|
||||
- Avoid stale redirects after auth changes (#2100) — Emirhan
|
||||
- Mark Claude Opus 4.7 (dashed id) as 1M context — Brokenc0de
|
||||
- Preserve reasoning effort through Codex translations — ntdung6868
|
||||
- Token-saver: full width card layout — decolua
|
||||
- Antigravity: retry transient upstream failures — Sutarto Jordan Chrisfivo
|
||||
- Param-support: handle strip rules without match/drop (#1960) — Joseph Yaksich
|
||||
- Translator: resolve custom provider prefix in debug endpoint (#1083) — hamsa0x7
|
||||
|
||||
# v0.5.8 (2026-06-21)
|
||||
|
||||
## Features
|
||||
@@ -246,74 +812,3 @@
|
||||
|
||||
## 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)
|
||||
|
||||
# v0.4.38 (2026-05-13)
|
||||
|
||||
## Features
|
||||
- Add DeepSeek TUI as CLI tool in dashboard (#1088)
|
||||
|
||||
## Fixes
|
||||
- Fix broken Docker image in v0.4.36/v0.4.37 (#1096, #1097)
|
||||
|
||||
## Improvements
|
||||
- Clean Docker tags + clearer pulls badge
|
||||
|
||||
# v0.4.37 (2026-05-13)
|
||||
|
||||
## Improvements
|
||||
- Security hardening — upgrade recommended
|
||||
|
||||
# v0.4.36 (2026-05-13)
|
||||
|
||||
## Features
|
||||
- Add MiniMax TTS provider support (#1043)
|
||||
- Docker images now published on both Docker Hub (`decolua/9router`) and GHCR — pull from your preferred registry
|
||||
|
||||
## Improvements
|
||||
- Replace browser confirm dialogs with custom ConfirmModal (#1060)
|
||||
|
||||
## Fixes
|
||||
- Fix Docker `Cannot find module 'next'` error in standalone build
|
||||
- Restore /app/server.js in Docker standalone build (#1064, #1067)
|
||||
- Fix CLI TUI menu arrow-key escape sequences leaking (^[[A^[[B)
|
||||
- Switch macOS/Linux tray to systray2 fork (fixes Kaspersky AV false-positive) (#1080)
|
||||
- Fix zoom controls contrast in topology view (#1066)
|
||||
101
CLAUDE.md
Normal file
101
CLAUDE.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# 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(...)`).
|
||||
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
12
Dockerfile
12
Dockerfile
@@ -2,17 +2,20 @@
|
||||
ARG NODE_IMAGE=node:22-alpine
|
||||
FROM ${NODE_IMAGE} AS base
|
||||
WORKDIR /app
|
||||
# CN mirror for apk (used by builder and runner stages)
|
||||
RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
RUN apk --no-cache upgrade && apk --no-cache add python3 make g++ linux-headers
|
||||
|
||||
COPY package.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install
|
||||
RUN npm install --registry=https://registry.npmmirror.com
|
||||
|
||||
COPY . ./
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Inter is self-hosted (public/fonts + src/app/fonts-inter.css), so this build needs no
|
||||
# route to fonts.googleapis.com / fonts.gstatic.com — only the npm mirror above is required.
|
||||
RUN npm run build
|
||||
|
||||
FROM ${NODE_IMAGE} AS runner
|
||||
@@ -37,6 +40,11 @@ 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
|
||||
# node-machine-id is createRequire-loaded at runtime; tracing omits it.
|
||||
COPY --from=builder /app/node_modules/node-machine-id ./node_modules/node-machine-id
|
||||
|
||||
RUN mkdir -p /app/data && chown -R node:node /app && \
|
||||
mkdir -p /app/data-home && chown node:node /app/data-home && \
|
||||
|
||||
327
README.md
327
README.md
@@ -13,11 +13,12 @@
|
||||
[](https://github.com/decolua/9router/pkgs/container/9router)
|
||||
[](https://github.com/decolua/9router/blob/main/LICENSE)
|
||||
|
||||
<a href="https://trendshift.io/repositories/22628" target="_blank"><img src="https://trendshift.io/api/badge/repositories/22628" alt="decolua%2F9router | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com)
|
||||
<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!
|
||||
|
||||
[](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)**  — Rust token-saver. 9Router ports its compression pipeline to JS → **−20-40% input tokens** on every request.
|
||||
- **[Caveman](https://github.com/JuliusBrussee/caveman)**  by **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral *"why use many token when few token do trick"*. 9Router adapts its prompt → **−65% output tokens**.
|
||||
- **[Ponytail](https://github.com/DietrichGebert/ponytail)**  by **[@DietrichGebert](https://github.com/DietrichGebert)** — *"lazy senior dev"* skill. 9Router injects its YAGNI-first ladder → **fewer tokens, less code, shorter diffs**.
|
||||
- **[Caveman](https://github.com/JuliusBrussee/caveman)**  by **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral _"why use many token when few token do trick"_. 9Router adapts its prompt → **−65% output tokens**.
|
||||
- **[Ponytail](https://github.com/DietrichGebert/ponytail)**  by **[@DietrichGebert](https://github.com/DietrichGebert)** — _"lazy senior dev"_ skill. 9Router injects its YAGNI-first ladder → **fewer tokens, less code, shorter diffs**.
|
||||
|
||||
Huge thanks to these authors — without their work, 9Router's token-saving features wouldn't exist. ⭐ them on GitHub!
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ npm install -g 9router
|
||||
|
||||
**2. 连接免费提供商(无需注册):**
|
||||
|
||||
控制面板 → 提供商 → 连接 **Kiro AI**(免费 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`
|
||||
|
||||
84
cli/cli.js
84
cli/cli.js
@@ -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) => {
|
||||
|
||||
@@ -6,7 +6,13 @@ const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const BETTER_SQLITE3_VERSION = "12.6.2";
|
||||
// Gate the pinned version by Node major, mirroring src/lib/db/driver.js gating
|
||||
// style: 13.x is N-API and ships per-platform prebuilds inside the package, so
|
||||
// it needs no ABI-specific download. It requires Node >= 22; older runtimes stay
|
||||
// on 12.6.2, which fetches an ABI-specific binary via prebuild-install.
|
||||
const [NODE_MAJOR] = process.versions.node.split(".").map(Number);
|
||||
const USE_NAPI_BUILD = NODE_MAJOR >= 22;
|
||||
const BETTER_SQLITE3_VERSION = USE_NAPI_BUILD ? "13.0.3" : "12.6.2";
|
||||
const SQL_JS_VERSION = "1.14.1";
|
||||
|
||||
function getDataDir() {
|
||||
@@ -45,9 +51,23 @@ function hasModule(name) {
|
||||
return fs.existsSync(path.join(getRuntimeNodeModules(), name, "package.json"));
|
||||
}
|
||||
|
||||
function isGlibcRuntime() {
|
||||
try { return Boolean(process.report?.getReport()?.header?.glibcVersionRuntime); } catch { return true; }
|
||||
}
|
||||
|
||||
// 12.x compiles/downloads into build/Release; 13.x ships prebuilds/<platform>-<arch>.node.
|
||||
function getBetterSqliteBinary() {
|
||||
const root = path.join(getRuntimeNodeModules(), "better-sqlite3");
|
||||
const platform = process.platform === "linux" && !isGlibcRuntime() ? "linuxmusl" : process.platform;
|
||||
return [
|
||||
path.join(root, "build", "Release", "better_sqlite3.node"),
|
||||
path.join(root, "prebuilds", `${platform}-${process.arch}.node`),
|
||||
].find((file) => fs.existsSync(file));
|
||||
}
|
||||
|
||||
function isBetterSqliteBinaryValid() {
|
||||
const binary = path.join(getRuntimeNodeModules(), "better-sqlite3", "build", "Release", "better_sqlite3.node");
|
||||
if (!fs.existsSync(binary)) return false;
|
||||
const binary = getBetterSqliteBinary();
|
||||
if (!binary) return false;
|
||||
try {
|
||||
const fd = fs.openSync(binary, "r");
|
||||
const buf = Buffer.alloc(4);
|
||||
@@ -91,6 +111,7 @@ function runNpmInstall({ cwd, pkgs, extraArgs = [], timeout = 180000 }) {
|
||||
function npmInstall(pkgs, opts = {}) {
|
||||
const cwd = ensureRuntimeDir();
|
||||
const extra = opts.optional ? ["--no-save"] : [];
|
||||
if (opts.ignoreScripts) extra.push("--ignore-scripts");
|
||||
if (!opts.silent) console.log("⏳ Installing SQLite engine (first run)...");
|
||||
const res = runNpmInstall({ cwd, pkgs, extraArgs: extra, timeout: opts.timeout || 180000 });
|
||||
if (!res.ok && !opts.silent) {
|
||||
@@ -129,7 +150,10 @@ function ensureSqliteRuntime({ silent = false } = {}) {
|
||||
return { betterSqlite: true, sqlJs: sqlJsOk };
|
||||
}
|
||||
|
||||
const ok = npmInstall([`better-sqlite3@${BETTER_SQLITE3_VERSION}`], { optional: true, silent });
|
||||
// npm injects an implicit `node-gyp rebuild` for any package carrying a
|
||||
// binding.gyp, which would demand build tools even though 13.x already bundles
|
||||
// the binary — skip scripts so the bundled prebuild is used as-is.
|
||||
const ok = npmInstall([`better-sqlite3@${BETTER_SQLITE3_VERSION}`], { optional: true, silent, ignoreScripts: USE_NAPI_BUILD });
|
||||
return {
|
||||
betterSqlite: ok && hasModule("better-sqlite3") && isBetterSqliteBinaryValid(),
|
||||
sqlJs: sqlJsOk,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "9router",
|
||||
"version": "0.5.8",
|
||||
"version": "0.5.70",
|
||||
"description": "9Router CLI - Start and manage 9Router server",
|
||||
"bin": {
|
||||
"9router": "./cli.js"
|
||||
@@ -16,7 +16,7 @@
|
||||
"scripts": {
|
||||
"dev": "nodemon -I --watch cli.js --watch src --watch hooks --ext js,json cli.js",
|
||||
"build": "node scripts/build-cli.js",
|
||||
"pack:cli": "npm run build && npm pack --pack-destination ../..",
|
||||
"pack:cli": "npm run build && npm pack --pack-destination ..",
|
||||
"publish:cli": "npm run build && npm publish",
|
||||
"postinstall": "node hooks/postinstall.js",
|
||||
"prepublishOnly": "npm run build"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
300
cli/src/cli/commands/xaiVideo.js
Normal file
300
cli/src/cli/commands/xaiVideo.js
Normal 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 };
|
||||
@@ -53,6 +53,15 @@ const PROVIDER_MODELS = {
|
||||
{ id: "glm-4.7" },
|
||||
],
|
||||
ag: [
|
||||
{ id: "gemini-3.8-flash-high" },
|
||||
{ id: "gemini-3.8-flash-medium" },
|
||||
{ id: "gemini-3.8-flash-low" },
|
||||
{ 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 +87,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 +104,10 @@ const PROVIDER_MODELS = {
|
||||
{ id: "claude-3-5-sonnet-20241022" },
|
||||
],
|
||||
gemini: [
|
||||
{ id: "gemini-3.8-flash" },
|
||||
{ id: "gemini-3.7-flash" },
|
||||
{ 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 +144,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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
111
custom-server.js
111
custom-server.js
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
30
docker-compose.yml
Normal file
30
docker-compose.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
services:
|
||||
9router:
|
||||
image: decolua/9router:latest
|
||||
container_name: 9router
|
||||
restart: always
|
||||
ports:
|
||||
- "20128:20128"
|
||||
volumes:
|
||||
- 9router-data:/app/data
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATA_DIR: /app/data
|
||||
PORT: "20128"
|
||||
HOSTNAME: "0.0.0.0"
|
||||
NODE_ENV: production
|
||||
HEADROOM_URL: http://headroom:8787
|
||||
depends_on:
|
||||
- headroom
|
||||
|
||||
headroom:
|
||||
image: ghcr.io/chopratejas/headroom:latest
|
||||
container_name: headroom
|
||||
restart: always
|
||||
ports:
|
||||
- "8787:8787"
|
||||
|
||||
volumes:
|
||||
9router-data:
|
||||
name: 9router-data
|
||||
BIN
docs/images/saml-admin-dashboard.png
Normal file
BIN
docs/images/saml-admin-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
BIN
docs/images/saml-login-screen.png
Normal file
BIN
docs/images/saml-login-screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -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.
|
||||
261
docs/superpowers/plans/2026-09-04-opencode-go-session-header.md
Normal file
261
docs/superpowers/plans/2026-09-04-opencode-go-session-header.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# OpenCode Go Session Header 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:** Send a stable, conversation-scoped `x-opencode-session` header on every OpenCode Go request and install the patched CLI locally.
|
||||
|
||||
**Architecture:** Add a dedicated `OpenCodeGoExecutor` extending `DefaultExecutor`. `chatCore` passes the provider-scoped session resolved from the original request plus the detected client tool; the executor derives a request-local upstream session and delegates all existing transport, authentication, retry, and proxy behavior to `DefaultExecutor`.
|
||||
|
||||
**Tech Stack:** Node.js ESM, Vitest, Next.js, npm CLI packaging, GitHub CLI.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Apply the header to OpenCode Go chat completions, Claude Messages, and OpenAI Responses transports.
|
||||
- Preserve a valid native `x-opencode-session`; hash all translated non-OpenCode identities to `ses_<32 lowercase hex>`.
|
||||
- Namespace translated identities by detected client tool, using `generic` when unknown.
|
||||
- Do not keep mutable per-request session state on the executor singleton or mutate the caller's credentials object.
|
||||
- Do not change OpenCode Go models, routing, reasoning, tool behavior, dependencies, or unrelated providers.
|
||||
- Reuse upstream issue #3759 instead of creating a duplicate issue.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Failing OpenCode Go Session Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/unit/opencode-go-session.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getExecutor(provider)` and `DefaultExecutor.buildHeaders(credentials, stream, url, model)`.
|
||||
- Produces: the required public behavior for `OpenCodeGoExecutor.prepareRequestCredentials({ body, credentials, providerSessionId, clientTool })` and `OpenCodeGoExecutor.execute(args)`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create a Vitest suite that mocks `proxyAwareFetch`, obtains `getExecutor("opencode-go")`, and asserts:
|
||||
|
||||
```js
|
||||
const prepared = executor.prepareRequestCredentials({
|
||||
body: { messages: [{ role: "user", content: "hello" }] },
|
||||
credentials: { apiKey: "test-key", connectionId: "conn-a", rawHeaders: {} },
|
||||
providerSessionId: "conversation-a",
|
||||
clientTool: "claude",
|
||||
});
|
||||
|
||||
expect(prepared).not.toBe(credentials);
|
||||
expect(prepared._opencodeGoSession).toMatch(/^ses_[0-9a-f]{32}$/);
|
||||
expect(credentials).not.toHaveProperty("_opencodeGoSession");
|
||||
```
|
||||
|
||||
Cover native header preservation, stable values across all three runtime transports, different conversation IDs, different client tools using the same ID, connection fallback, no singleton state, no header on `DefaultExecutor("openai")`, and the final fetch headers returned by `execute()`.
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx vitest run --config tests/vitest.config.js tests/unit/opencode-go-session.test.js
|
||||
```
|
||||
|
||||
Expected: FAIL because `getExecutor("opencode-go")` still returns `DefaultExecutor` and `prepareRequestCredentials` does not exist.
|
||||
|
||||
- [ ] **Step 3: Commit the failing test**
|
||||
|
||||
```bash
|
||||
git add tests/unit/opencode-go-session.test.js
|
||||
git commit -m "test: cover OpenCode Go session headers"
|
||||
```
|
||||
|
||||
### Task 2: Implement the Dedicated Executor
|
||||
|
||||
**Files:**
|
||||
- Create: `open-sse/executors/opencode-go.js`
|
||||
- Modify: `open-sse/executors/index.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `DefaultExecutor`, `resolveSessionId()`, request `credentials.rawHeaders`, `providerSessionId`, and `clientTool`.
|
||||
- Produces: `OpenCodeGoExecutor`, `prepareRequestCredentials()`, and an `execute()` override that delegates with cloned credentials.
|
||||
|
||||
- [ ] **Step 1: Add the minimal executor implementation**
|
||||
|
||||
Implement these rules:
|
||||
|
||||
```js
|
||||
function translatedSessionId(sessionId, clientTool) {
|
||||
const digest = crypto
|
||||
.createHash("sha256")
|
||||
.update(`opencode-go\0${clientTool || "generic"}\0${sessionId}`)
|
||||
.digest("hex")
|
||||
.slice(0, 32);
|
||||
return `ses_${digest}`;
|
||||
}
|
||||
```
|
||||
|
||||
`prepareRequestCredentials()` must read a case-insensitive native
|
||||
`x-opencode-session` with the same non-empty, 256-character cap used by the
|
||||
session manager. Otherwise it uses `providerSessionId` or calls
|
||||
`resolveSessionId({ headers, body, connectionId, scope: "opencode-go" })`, then
|
||||
returns `{ ...credentials, _opencodeGoSession: value }`.
|
||||
|
||||
`execute(args)` must call `prepareRequestCredentials(args)` and delegate using
|
||||
`super.execute({ ...args, credentials: prepared })`. `buildHeaders()` must call
|
||||
`super.buildHeaders()` and add the prepared session, with a connection-scoped
|
||||
fallback for direct callers.
|
||||
|
||||
Register `new OpenCodeGoExecutor()` under `"opencode-go"` and export the class.
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify partial GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx vitest run --config tests/vitest.config.js tests/unit/opencode-go-session.test.js
|
||||
```
|
||||
|
||||
Expected: executor-level tests pass; any chatCore-context assertion remains failing until Task 3.
|
||||
|
||||
- [ ] **Step 3: Commit the executor**
|
||||
|
||||
```bash
|
||||
git add open-sse/executors/opencode-go.js open-sse/executors/index.js tests/unit/opencode-go-session.test.js
|
||||
git commit -m "fix(opencode-go): add stable session header executor"
|
||||
```
|
||||
|
||||
### Task 3: Pass Original Request Session Context
|
||||
|
||||
**Files:**
|
||||
- Modify: `open-sse/handlers/chatCore.js`
|
||||
- Modify: `tests/unit/opencode-go-session.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing `sessionSeed` and `clientTool` variables in `handleChatCore()`.
|
||||
- Produces: `providerSessionId` and `clientTool` fields on both initial and refreshed-credential calls to `executor.execute()`.
|
||||
|
||||
- [ ] **Step 1: Add or enable the failing integration assertion**
|
||||
|
||||
Use a mocked executor or source request containing a body-only `session_id` and
|
||||
assert the executor receives the provider-scoped session resolved before
|
||||
translation.
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx vitest run --config tests/vitest.config.js tests/unit/opencode-go-session.test.js
|
||||
```
|
||||
|
||||
Expected: FAIL because `handleChatCore()` does not pass `providerSessionId` or
|
||||
`clientTool` to `executor.execute()`.
|
||||
|
||||
- [ ] **Step 3: Pass the request context**
|
||||
|
||||
Add the same fields to both executor calls:
|
||||
|
||||
```js
|
||||
executor.execute({
|
||||
model,
|
||||
body: translatedBody,
|
||||
stream,
|
||||
credentials,
|
||||
providerSessionId: sessionSeed,
|
||||
clientTool,
|
||||
signal: streamController.signal,
|
||||
log,
|
||||
proxyOptions,
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run focused and neighboring tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx vitest run --config tests/vitest.config.js \
|
||||
tests/unit/opencode-go-session.test.js \
|
||||
tests/unit/opencode-go-models.test.js \
|
||||
tests/unit/session-manager.test.js \
|
||||
tests/unit/executor-const-guard.test.js
|
||||
```
|
||||
|
||||
Expected: PASS with zero failed tests.
|
||||
|
||||
- [ ] **Step 5: Commit the context wiring**
|
||||
|
||||
```bash
|
||||
git add open-sse/handlers/chatCore.js tests/unit/opencode-go-session.test.js
|
||||
git commit -m "fix(chat): forward provider session context"
|
||||
```
|
||||
|
||||
### Task 4: Verify and Install the Local CLI Package
|
||||
|
||||
**Files:**
|
||||
- Generated: `9router-0.5.65.tgz`
|
||||
- Packaged output: `cli/app/server.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: completed source changes and existing CLI build scripts.
|
||||
- Produces: a globally installed patched `9router@0.5.65`.
|
||||
|
||||
- [ ] **Step 1: Run source verification**
|
||||
|
||||
```bash
|
||||
git diff --check origin/master...HEAD
|
||||
npx vitest run --config tests/vitest.config.js tests/unit/
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: every command exits zero. Record any pre-existing full-suite failures
|
||||
separately rather than hiding them.
|
||||
|
||||
- [ ] **Step 2: Build and package the CLI**
|
||||
|
||||
```bash
|
||||
npm --prefix cli run build
|
||||
npm --prefix cli pack -- --pack-destination ..
|
||||
```
|
||||
|
||||
Expected: `9router-0.5.65.tgz` exists and contains the patched bundled server.
|
||||
|
||||
- [ ] **Step 3: Replace the global npm installation**
|
||||
|
||||
```bash
|
||||
npm install -g ./9router-0.5.65.tgz
|
||||
```
|
||||
|
||||
Expected: `/opt/homebrew/lib/node_modules/9router/package.json` reports `0.5.65`
|
||||
and the installed bundle contains `x-opencode-session` plus the new executor.
|
||||
|
||||
- [ ] **Step 4: Commit any required package-source adjustment**
|
||||
|
||||
Do not commit generated tarballs or CLI build artifacts unless the repository
|
||||
already tracks and requires them.
|
||||
|
||||
### Task 5: Publish the Upstream Pull Request
|
||||
|
||||
**Files:**
|
||||
- No additional source files unless verification finds a required correction.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: verified branch commits and GitHub issue #3759.
|
||||
- Produces: a fork branch and a PR against `decolua/9router:master`.
|
||||
|
||||
- [ ] **Step 1: Create or repair the GitHub fork remote**
|
||||
|
||||
Use `gh repo fork decolua/9router --remote` if the current `fork` remote remains
|
||||
missing, then push `fix/opencode-go-session-header`.
|
||||
|
||||
- [ ] **Step 2: Create the PR**
|
||||
|
||||
Use title:
|
||||
|
||||
```text
|
||||
fix(opencode-go): send stable session header
|
||||
```
|
||||
|
||||
The body must include the root cause, downstream-session translation policy,
|
||||
three covered transports, concurrency behavior, verification evidence,
|
||||
`Fixes #3759`, and a note that this PR is intentionally narrower than #3780.
|
||||
|
||||
- [ ] **Step 3: Verify the published PR**
|
||||
|
||||
Run `gh pr view --json number,title,state,url,headRefName,baseRefName` and report
|
||||
the issue and PR URLs.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,114 @@
|
||||
# OpenCode Go Session Header Design
|
||||
|
||||
## Problem
|
||||
|
||||
OpenCode Go will begin rejecting some requests without an
|
||||
`x-opencode-session` header on September 6, 2026. In 9Router v0.5.65,
|
||||
`opencode-go` uses `DefaultExecutor`, whose generic header builder does not add
|
||||
that header. The specialized OpenCode Free executor already sends it, but that
|
||||
logic does not apply to the paid OpenCode Go provider or its three transports.
|
||||
|
||||
## Goals
|
||||
|
||||
- Add `x-opencode-session` to every OpenCode Go chat, Claude Messages, and
|
||||
OpenAI Responses request.
|
||||
- Translate a downstream conversation identity into a stable upstream identity.
|
||||
- Keep identities isolated across different downstream agents and conversations.
|
||||
- Avoid exposing non-OpenCode downstream session identifiers to OpenCode Go.
|
||||
- Avoid mutable session state on the shared executor singleton.
|
||||
- Leave OpenCode Free and all unrelated providers unchanged.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Inferring an exact conversation boundary when a downstream client provides no
|
||||
session or conversation identifier.
|
||||
- Adding or changing OpenCode Go models, routing, reasoning, or tool behavior.
|
||||
- Changing the general session-resolution policy for other providers.
|
||||
|
||||
## Architecture
|
||||
|
||||
Add a dedicated `OpenCodeGoExecutor` extending `DefaultExecutor`. The executor
|
||||
keeps the existing generic URL, authentication, translation, retry, and proxy
|
||||
behavior, and overrides only the OpenCode Go session-header concern.
|
||||
|
||||
`handleChatCore` already resolves a provider-scoped session from the original
|
||||
request before translation. It will pass that value and the detected client
|
||||
tool to `executor.execute()` as request context. `OpenCodeGoExecutor.execute()`
|
||||
will create a shallow request-local credentials object containing the resolved
|
||||
OpenCode Go session. It will then delegate to `DefaultExecutor.execute()`.
|
||||
This avoids storing request state on the executor singleton or mutating shared
|
||||
provider credentials.
|
||||
|
||||
## Session Resolution
|
||||
|
||||
The original downstream request remains the source of truth. Existing
|
||||
`resolveSessionId()` behavior recognizes Claude Code, Antigravity, generic
|
||||
session headers, and common body fields before request translation can discard
|
||||
them.
|
||||
|
||||
Resolution rules:
|
||||
|
||||
1. If the downstream request supplies `x-opencode-session`, treat it as an
|
||||
authoritative OpenCode identity after trimming and length validation.
|
||||
2. Otherwise use the provider-scoped session resolved from the original request.
|
||||
3. Namespace the resolved value with the detected downstream agent, falling back
|
||||
to `generic` when the agent is unknown.
|
||||
4. Convert the namespaced value to an opaque deterministic identifier:
|
||||
`ses_` plus the first 32 hexadecimal characters of SHA-256.
|
||||
5. If no explicit downstream identity exists, the existing provider connection
|
||||
fallback guarantees that a header is still sent. It is stable but cannot
|
||||
distinguish multiple conversations sharing that connection.
|
||||
|
||||
The same input conversation produces the same upstream identifier for all three
|
||||
OpenCode Go transports. Different agents using the same raw session value
|
||||
produce different identifiers.
|
||||
|
||||
## Header Injection
|
||||
|
||||
`OpenCodeGoExecutor.buildHeaders()` delegates to
|
||||
`DefaultExecutor.buildHeaders()` and adds only:
|
||||
|
||||
```text
|
||||
x-opencode-session: <stable-session-id>
|
||||
```
|
||||
|
||||
The implementation applies to:
|
||||
|
||||
- `https://opencode.ai/zen/go/v1/chat/completions`
|
||||
- `https://opencode.ai/zen/go/v1/messages`
|
||||
- `https://opencode.ai/zen/go/v1/responses`
|
||||
|
||||
## Error Handling
|
||||
|
||||
Session derivation must not make requests fail. Invalid or oversized native
|
||||
header values are ignored and the normal resolved-session fallback is used.
|
||||
Hashing uses Node's built-in `crypto` module and requires no new dependency.
|
||||
|
||||
## Testing
|
||||
|
||||
Add a focused unit suite that proves:
|
||||
|
||||
- all three OpenCode Go transports receive the header;
|
||||
- the same conversation remains stable across requests and transports;
|
||||
- different conversations produce different values;
|
||||
- different agents using the same raw ID remain isolated;
|
||||
- non-OpenCode session IDs are represented as opaque `ses_<32 hex>` values;
|
||||
- a valid native `x-opencode-session` remains stable;
|
||||
- headerless requests still receive a stable fallback;
|
||||
- OpenCode Free behavior is unchanged;
|
||||
- unrelated `DefaultExecutor` providers do not receive the header;
|
||||
- no request state is retained on the shared executor instance.
|
||||
|
||||
Run the focused unit tests first, then the neighboring executor/session tests,
|
||||
the full offline test suite, the application build, and the CLI package build.
|
||||
|
||||
## Delivery
|
||||
|
||||
Build the CLI with `npm --prefix cli run build`, create a package with
|
||||
`npm --prefix cli pack`, and install the generated tarball globally to replace
|
||||
the current npm-installed `9router@0.5.65`. Verify the installed package version
|
||||
and packaged source contains the new executor.
|
||||
|
||||
Upstream issue #3759 already tracks the problem, so no duplicate issue will be
|
||||
created. The pull request will be narrowly scoped to this fix, reference
|
||||
`Fixes #3759`, and explain how it differs from the broader open PR #3780.
|
||||
@@ -111,6 +111,27 @@ Model: cx/gpt-5.2-codex
|
||||
| `cx/gpt-5.2` | GPT 5.2 | General tasks |
|
||||
| `cx/gpt-5.1-codex` | GPT 5.1 Codex | Stable coding |
|
||||
|
||||
### Image Generation
|
||||
|
||||
The Codex image catalog includes `cx/gpt-5.6-sol-image`,
|
||||
`cx/gpt-5.6-terra-image`, and `cx/gpt-5.6-luna-image`, alongside the existing
|
||||
GPT 5.5, 5.4, and 5.3 image aliases. Select them under **Image → OpenAI Codex**
|
||||
in the dashboard, or discover them with `GET /v1/models/image` after connecting
|
||||
a Codex account.
|
||||
|
||||
```bash
|
||||
curl http://localhost:20128/v1/images/generations \
|
||||
-H "Authorization: Bearer $NINE_ROUTER_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"cx/gpt-5.6-sol-image","prompt":"A blue square","size":"1024x1024"}'
|
||||
```
|
||||
|
||||
These are 9Router aliases: the image adapter removes `-image` and sends the
|
||||
underlying model an `image_generation` tool through the Codex Responses API.
|
||||
The same endpoint accepts an `image` reference for edits. Image generation
|
||||
requires an eligible ChatGPT Plus or higher account; availability of each
|
||||
underlying model and its image tool depends on the connected account.
|
||||
|
||||
### Pro Tips
|
||||
|
||||
- **5-hour rolling quota** - Fresh quota every 5 hours
|
||||
|
||||
1445
i18n/README.es.md
Normal file
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
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
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
951
i18n/README.id-ID.md
Normal 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.**
|
||||
|
||||
[](https://www.npmjs.com/package/9router)
|
||||
[](https://www.npmjs.com/package/9router)
|
||||
[](https://github.com/decolua/9router/blob/main/LICENSE)
|
||||
|
||||
[🚀 Mulai Cepat](#-mulai-cepat) • [💡 Fitur](#-fitur-utama) • [📖 Setup](#-panduan-setup) • [🌐 Website](https://9router.com)
|
||||
|
||||
[🇻🇳 Tiếng Việt](./README.vi.md) • [🇨🇳 中文](./README.zh-CN.md) • [🇯🇵 日本語](./README.ja-JP.md) • [🇮🇩 Bahasa Indonesia](./README.id-ID.md)
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🤔 Kenapa 9Router?
|
||||
|
||||
**Berhenti buang-buang uang dan terhambat limit:**
|
||||
|
||||
- ❌ Kuota langganan hangus tiap bulan tanpa terpakai
|
||||
- ❌ Rate limit bikin ngoding berhenti di tengah jalan
|
||||
- ❌ API mahal ($20–50/bulan per provider)
|
||||
- ❌ Harus gonta-ganti provider secara manual
|
||||
|
||||
**9Router menyelesaikan itu semua:**
|
||||
|
||||
- ✅ **Maksimalkan langganan** - lacak kuota dan habiskan sebelum reset
|
||||
- ✅ **Fallback otomatis** - langganan → murah → gratis, tanpa downtime
|
||||
- ✅ **Multi-akun** - round-robin antar akun untuk tiap provider
|
||||
- ✅ **Universal** - mendukung Claude Code, Codex, Gemini CLI, Cursor, Cline, dan tool CLI apa pun
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Cara Kerja
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Tool CLI │ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...)
|
||||
│ kamu │
|
||||
└──────┬──────┘
|
||||
│ http://localhost:20128/v1
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 9Router (Smart Router) │
|
||||
│ • Konversi format (OpenAI ↔ Claude) │
|
||||
│ • Pelacakan kuota │
|
||||
│ • Refresh token otomatis │
|
||||
└──────┬──────────────────────────────────┘
|
||||
│
|
||||
├─→ [Tier 1: Langganan] Claude Code, Codex, Gemini CLI
|
||||
│ ↓ kuota habis
|
||||
├─→ [Tier 2: Murah] GLM ($0.6/1M), MiniMax ($0.2/1M)
|
||||
│ ↓ batas budget tercapai
|
||||
└─→ [Tier 3: Gratis] iFlow, Qwen, Kiro (unlimited)
|
||||
|
||||
Hasil: ngoding tanpa berhenti, biaya minimum
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Mulai Cepat
|
||||
|
||||
**1. Install secara global:**
|
||||
|
||||
```bash
|
||||
npm install -g 9router
|
||||
9router
|
||||
```
|
||||
|
||||
🎉 Dashboard terbuka di `http://localhost:20128`
|
||||
|
||||
**2. Hubungkan provider gratis (tanpa perlu daftar):**
|
||||
|
||||
Dashboard → Providers → hubungkan **Claude Code** atau **Antigravity** → login OAuth → selesai!
|
||||
|
||||
**3. Pakai di tool CLI kamu:**
|
||||
|
||||
```
|
||||
Konfigurasi Claude Code/Codex/Gemini CLI/OpenClaw/Cursor/Cline:
|
||||
Endpoint: http://localhost:20128/v1
|
||||
API Key: [salin dari dashboard]
|
||||
Model: if/kimi-k2-thinking
|
||||
```
|
||||
|
||||
**Cuma itu!** Mulai ngoding dengan model AI gratis.
|
||||
|
||||
**Alternatif: jalankan dari source (repo ini):**
|
||||
|
||||
Paket repo ini bersifat privat (`9router-app`), jadi menjalankan dari source/Docker adalah jalur yang diharapkan untuk pengembangan lokal.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
npm install
|
||||
PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
|
||||
```
|
||||
|
||||
Mode produksi:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start
|
||||
```
|
||||
|
||||
URL default:
|
||||
- Dashboard: `http://localhost:20128/dashboard`
|
||||
- API kompatibel OpenAI: `http://localhost:20128/v1`
|
||||
|
||||
---
|
||||
|
||||
## 🎥 Video Tutorial
|
||||
|
||||
<div align="center">
|
||||
|
||||
### 📺 Panduan Setup Lengkap - 9Router + Claude Code Gratis
|
||||
|
||||
[](https://www.youtube.com/watch?v=raEyZPg5xE0)
|
||||
|
||||
**🎬 Tonton tutorial langkah demi langkah:**
|
||||
- ✅ Install dan setup 9Router
|
||||
- ✅ Konfigurasi Claude Sonnet 4.5 gratis
|
||||
- ✅ Integrasi dengan Claude Code
|
||||
- ✅ Demo live coding
|
||||
|
||||
**⏱️ Durasi:** 20 menit | **👥 Dibuat oleh:** Developer Community
|
||||
|
||||
[▶️ Tonton di YouTube](https://www.youtube.com/watch?v=o3qYCyjrFYg)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Tool CLI yang Didukung
|
||||
|
||||
9Router bekerja mulus dengan semua tool AI coding utama:
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/claude.png" width="60" alt="Claude Code"/><br/>
|
||||
<b>Claude-Code</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/openclaw.png" width="60" alt="OpenClaw"/><br/>
|
||||
<b>OpenClaw</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/codex.png" width="60" alt="Codex"/><br/>
|
||||
<b>Codex</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/opencode.png" width="60" alt="OpenCode"/><br/>
|
||||
<b>OpenCode</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/cursor.png" width="60" alt="Cursor"/><br/>
|
||||
<b>Cursor</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/antigravity.png" width="60" alt="Antigravity"/><br/>
|
||||
<b>Antigravity</b>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/cline.png" width="60" alt="Cline"/><br/>
|
||||
<b>Cline</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/continue.png" width="60" alt="Continue"/><br/>
|
||||
<b>Continue</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/droid.png" width="60" alt="Droid"/><br/>
|
||||
<b>Droid</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/roo.png" width="60" alt="Roo"/><br/>
|
||||
<b>Roo</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/copilot.png" width="60" alt="Copilot"/><br/>
|
||||
<b>Copilot</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/kilocode.png" width="60" alt="Kilo Code"/><br/>
|
||||
<b>Kilo Code</b>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Provider yang Didukung
|
||||
|
||||
### 🔐 Provider OAuth
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/claude.png" width="60" alt="Claude Code"/><br/>
|
||||
<b>Claude-Code</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/antigravity.png" width="60" alt="Antigravity"/><br/>
|
||||
<b>Antigravity</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/codex.png" width="60" alt="Codex"/><br/>
|
||||
<b>Codex</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/github.png" width="60" alt="GitHub"/><br/>
|
||||
<b>GitHub</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/cursor.png" width="60" alt="Cursor"/><br/>
|
||||
<b>Cursor</b>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
### 🆓 Provider Gratis
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="150">
|
||||
<img src="../public/providers/iflow.png" width="70" alt="iFlow"/><br/>
|
||||
<b>iFlow AI</b><br/>
|
||||
<sub>8+ model • unlimited</sub>
|
||||
</td>
|
||||
<td align="center" width="150">
|
||||
<img src="../public/providers/qwen.png" width="70" alt="Qwen"/><br/>
|
||||
<b>Qwen Code</b><br/>
|
||||
<sub>3+ model • unlimited</sub>
|
||||
</td>
|
||||
<td align="center" width="150">
|
||||
<img src="../public/providers/gemini-cli.png" width="70" alt="Gemini CLI"/><br/>
|
||||
<b>Gemini CLI</b><br/>
|
||||
<sub>180 ribu request/bulan gratis</sub>
|
||||
</td>
|
||||
<td align="center" width="150">
|
||||
<img src="../public/providers/kiro.png" width="70" alt="Kiro"/><br/>
|
||||
<b>Kiro AI</b><br/>
|
||||
<sub>Claude • unlimited</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
### 🔑 Provider API Key (40+)
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/openrouter.png" width="50" alt="OpenRouter"/><br/>
|
||||
<sub>OpenRouter</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/glm.png" width="50" alt="GLM"/><br/>
|
||||
<sub>GLM</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/kimi.png" width="50" alt="Kimi"/><br/>
|
||||
<sub>Kimi</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/minimax.png" width="50" alt="MiniMax"/><br/>
|
||||
<sub>MiniMax</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/openai.png" width="50" alt="OpenAI"/><br/>
|
||||
<sub>OpenAI</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/anthropic.png" width="50" alt="Anthropic"/><br/>
|
||||
<sub>Anthropic</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/gemini.png" width="50" alt="Gemini"/><br/>
|
||||
<sub>Gemini</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/deepseek.png" width="50" alt="DeepSeek"/><br/>
|
||||
<sub>DeepSeek</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/groq.png" width="50" alt="Groq"/><br/>
|
||||
<sub>Groq</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/xai.png" width="50" alt="xAI"/><br/>
|
||||
<sub>xAI</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/mistral.png" width="50" alt="Mistral"/><br/>
|
||||
<sub>Mistral</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/perplexity.png" width="50" alt="Perplexity"/><br/>
|
||||
<sub>Perplexity</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/together.png" width="50" alt="Together"/><br/>
|
||||
<sub>Together AI</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/fireworks.png" width="50" alt="Fireworks"/><br/>
|
||||
<sub>Fireworks</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/cerebras.png" width="50" alt="Cerebras"/><br/>
|
||||
<sub>Cerebras</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/cohere.png" width="50" alt="Cohere"/><br/>
|
||||
<sub>Cohere</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/nvidia.png" width="50" alt="NVIDIA"/><br/>
|
||||
<sub>NVIDIA</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/siliconflow.png" width="50" alt="SiliconFlow"/><br/>
|
||||
<sub>SiliconFlow</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p><i>...dan 20+ provider lain seperti Nebius, Chutes, Hyperbolic, serta endpoint custom yang kompatibel dengan OpenAI/Anthropic</i></p>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 💡 Fitur Utama
|
||||
|
||||
| Fitur | Ringkasan | Manfaat |
|
||||
|-------|-----------|---------|
|
||||
| 🎯 **Smart Fallback 3 Tingkat** | Routing otomatis: langganan → murah → gratis | Ngoding tanpa berhenti, zero downtime |
|
||||
| 📊 **Pelacakan Kuota Real-time** | Hitungan token live + hitung mundur reset | Nilai langganan termanfaatkan maksimal |
|
||||
| 🔄 **Konversi Format** | OpenAI ↔ Claude ↔ Gemini mulus | Bekerja dengan tool CLI apa pun |
|
||||
| 👥 **Dukungan Multi-akun** | Beberapa akun per provider | Load balancing + redundansi |
|
||||
| 🔄 **Auto Refresh Token** | Token OAuth diperbarui otomatis | Tidak perlu login ulang manual |
|
||||
| 🎨 **Combo Kustom** | Buat kombinasi model tanpa batas | Fallback sesuai kebutuhanmu |
|
||||
| 📝 **Log Request** | Log lengkap request/response | Troubleshooting jadi mudah |
|
||||
| 💾 **Cloud Sync** | Sinkronkan pengaturan antar perangkat | Setup sama di mana pun |
|
||||
| 📊 **Analitik Penggunaan** | Lacak token, biaya, dan tren | Optimalkan pengeluaran |
|
||||
| 🌐 **Deploy di Mana Saja** | Localhost, VPS, Docker, Cloudflare Workers | Opsi deployment fleksibel |
|
||||
|
||||
<details>
|
||||
<summary><b>📖 Detail Fitur</b></summary>
|
||||
|
||||
### 🎯 Smart Fallback 3 Tingkat
|
||||
|
||||
Buat combo dengan fallback otomatis:
|
||||
|
||||
```
|
||||
Combo: "my-coding-stack"
|
||||
1. cc/claude-opus-4-6 (langganan)
|
||||
2. glm/glm-4.7 (backup murah, $0.6/1M)
|
||||
3. if/kimi-k2-thinking (fallback gratis)
|
||||
|
||||
→ Otomatis beralih saat kuota habis atau terjadi error
|
||||
```
|
||||
|
||||
### 📊 Pelacakan Kuota Real-time
|
||||
|
||||
- Konsumsi token per provider
|
||||
- Hitung mundur reset (5 jam, harian, mingguan)
|
||||
- Estimasi biaya untuk tier berbayar
|
||||
- Laporan pengeluaran bulanan
|
||||
|
||||
### 🔄 Konversi Format
|
||||
|
||||
Konversi mulus antar format:
|
||||
- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **OpenAI Responses**
|
||||
- Tool CLI mengirim dalam format OpenAI → 9Router mengonversi → provider menerima dalam format nativenya
|
||||
- Bekerja dengan semua tool yang mendukung custom OpenAI endpoint
|
||||
|
||||
### 👥 Dukungan Multi-akun
|
||||
|
||||
- Tambahkan beberapa akun per provider
|
||||
- Round-robin otomatis atau routing berbasis prioritas
|
||||
- Saat satu akun mencapai kuota, fallback ke akun berikutnya
|
||||
|
||||
### 🔄 Auto Refresh Token
|
||||
|
||||
- Token OAuth di-refresh otomatis sebelum kedaluwarsa
|
||||
- Tidak perlu autentikasi ulang manual
|
||||
- Pengalaman mulus di semua provider
|
||||
|
||||
### 🎨 Combo Kustom
|
||||
|
||||
- Buat kombinasi model tanpa batas
|
||||
- Campur tier langganan, murah, dan gratis
|
||||
- Beri nama combo agar mudah diakses
|
||||
- Bagikan combo antar perangkat lewat cloud sync
|
||||
|
||||
### 📝 Log Request
|
||||
|
||||
- Log lengkap request/response dalam mode debug
|
||||
- Lacak API call, header, dan payload
|
||||
- Troubleshoot masalah integrasi
|
||||
- Ekspor log untuk dianalisis
|
||||
|
||||
### 💾 Cloud Sync
|
||||
|
||||
- Sinkronkan provider, combo, dan pengaturan antar perangkat
|
||||
- Sinkronisasi latar belakang otomatis
|
||||
- Penyimpanan terenkripsi yang aman
|
||||
- Akses setup dari mana saja
|
||||
|
||||
#### Catatan tentang cloud runtime
|
||||
|
||||
- Untuk produksi, disarankan memakai variabel cloud sisi server:
|
||||
- `BASE_URL` (URL callback internal yang dipakai scheduler sinkronisasi)
|
||||
- `CLOUD_URL` (base URL endpoint cloud sync)
|
||||
- `NEXT_PUBLIC_BASE_URL` dan `NEXT_PUBLIC_CLOUD_URL` masih didukung untuk kompatibilitas/UI, tetapi runtime server memprioritaskan `BASE_URL`/`CLOUD_URL`.
|
||||
- Request cloud sync memakai timeout + perilaku fail-fast untuk menghindari UI menggantung saat DNS/jaringan cloud tidak tersedia.
|
||||
|
||||
### 📊 Analitik Penggunaan
|
||||
|
||||
- Lacak pemakaian token per provider dan per model
|
||||
- Estimasi biaya dan tren pengeluaran
|
||||
- Laporan dan insight bulanan
|
||||
- Optimalkan pengeluaran AI
|
||||
|
||||
> **💡 PENTING - tentang biaya di dashboard:**
|
||||
>
|
||||
> "Biaya" yang ditampilkan pada analitik penggunaan **hanya untuk pelacakan dan perbandingan**.
|
||||
> 9Router sendiri **tidak menagih apa pun**. Kamu hanya membayar langsung ke provider jika memakai layanan berbayar.
|
||||
>
|
||||
> **Contoh:** jika dashboard menampilkan "Total biaya $290" untuk pemakaian model iFlow,
|
||||
> itu adalah jumlah yang seharusnya kamu bayar bila memakai API berbayar secara langsung. Biaya sebenarnya = **$0** (iFlow gratis tanpa batas).
|
||||
>
|
||||
> Anggap saja ini "pelacak penghematan" yang menunjukkan berapa banyak yang kamu hemat lewat model gratis dan routing 9Router!
|
||||
|
||||
### 🌐 Deploy di Mana Saja
|
||||
|
||||
- 💻 **Localhost** - default, jalan offline
|
||||
- ☁️ **VPS/Cloud** - berbagi antar perangkat
|
||||
- 🐳 **Docker** - deploy satu perintah
|
||||
- 🚀 **Cloudflare Workers** - jaringan edge global
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 💰 Ringkasan Harga
|
||||
|
||||
| Tier | Provider | Biaya | Reset Kuota | Cocok Untuk |
|
||||
|------|----------|-------|-------------|-------------|
|
||||
| **💳 Langganan** | Claude Code (Pro) | $20/bulan | 5 jam + mingguan | Yang sudah punya langganan |
|
||||
| | Codex (Plus/Pro) | $20-200/bulan | 5 jam + mingguan | Pengguna OpenAI |
|
||||
| | Gemini CLI | **Gratis** | 180rb/bulan + 1rb/hari | Semua orang! |
|
||||
| | GitHub Copilot | $10-19/bulan | Bulanan | Pengguna GitHub |
|
||||
| **💰 Murah** | GLM-4.7 | $0.6/1M | Setiap hari jam 10.00 | Backup hemat |
|
||||
| | MiniMax M2.1 | $0.2/1M | Rolling 5 jam | Opsi paling murah |
|
||||
| | Kimi K2 | $9/bulan flat | 10 juta token/bulan | Biaya yang bisa diprediksi |
|
||||
| **🆓 Gratis** | iFlow | $0 | Unlimited | 8 model gratis |
|
||||
| | Qwen | $0 | Unlimited | 3 model gratis |
|
||||
| | Kiro | $0 | Unlimited | Claude gratis |
|
||||
|
||||
**💡 Tips pro:** combo Gemini CLI (180rb request/bulan gratis) + iFlow (gratis unlimited) = biaya $0!
|
||||
|
||||
---
|
||||
|
||||
### 📊 Tentang Biaya dan Penagihan 9Router
|
||||
|
||||
**Fakta soal penagihan 9Router:**
|
||||
|
||||
✅ **Software 9Router = gratis selamanya** (open source, tanpa tagihan)
|
||||
✅ **"Biaya" di dashboard = tampilan/pelacakan saja** (bukan tagihan sungguhan)
|
||||
✅ **Pembayaran langsung ke provider** (langganan atau biaya API)
|
||||
✅ **Provider gratis tetap gratis** (iFlow, Kiro, Qwen = $0 unlimited)
|
||||
❌ **9Router tidak mengirim invoice** atau menagih kartumu
|
||||
|
||||
**Cara kerja tampilan biaya:**
|
||||
|
||||
Dashboard menampilkan **estimasi biaya** seandainya kamu memakai API berbayar secara langsung. Ini **bukan tagihan**, melainkan alat pembanding yang menunjukkan penghematanmu.
|
||||
|
||||
**Contoh skenario:**
|
||||
```
|
||||
Tampilan dashboard:
|
||||
• Total request: 1.662
|
||||
• Total token: 47 juta
|
||||
• Biaya tertampil: $290
|
||||
|
||||
Kenyataannya:
|
||||
• Provider: iFlow (gratis unlimited)
|
||||
• Yang benar-benar dibayar: $0.00
|
||||
• Arti $290: jumlah yang kamu hemat dengan memakai model gratis!
|
||||
```
|
||||
|
||||
**Aturan pembayaran:**
|
||||
- **Provider langganan** (Claude Code, Codex): bayar langsung di website masing-masing
|
||||
- **Provider murah** (GLM, MiniMax): bayar langsung, 9Router hanya melakukan routing
|
||||
- **Provider gratis** (iFlow, Kiro, Qwen): benar-benar gratis selamanya, tanpa biaya tersembunyi
|
||||
- **9Router**: tidak menagih apa pun
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Studi Kasus
|
||||
|
||||
### Kasus 1: "Saya punya langganan Claude Pro"
|
||||
|
||||
**Masalah:** kuota hangus tanpa terpakai, kena rate limit saat ngoding berat
|
||||
|
||||
**Solusi:**
|
||||
```
|
||||
Combo: "maximize-claude"
|
||||
1. cc/claude-opus-4-6 (manfaatkan langganan semaksimal mungkin)
|
||||
2. glm/glm-4.7 (backup murah saat kuota habis)
|
||||
3. if/kimi-k2-thinking (fallback darurat gratis)
|
||||
|
||||
Biaya bulanan: $20 (langganan) + ~$5 (backup) = total $25
|
||||
vs. $20 + kena limit = frustrasi
|
||||
```
|
||||
|
||||
### Kasus 2: "Saya mau biaya nol"
|
||||
|
||||
**Masalah:** tidak mampu bayar langganan, tapi butuh AI coding yang andal
|
||||
|
||||
**Solusi:**
|
||||
```
|
||||
Combo: "free-forever"
|
||||
1. gc/gemini-3-flash (180rb request/bulan gratis)
|
||||
2. if/kimi-k2-thinking (gratis unlimited)
|
||||
3. qw/qwen3-coder-plus (gratis unlimited)
|
||||
|
||||
Biaya bulanan: $0
|
||||
Kualitas: model siap produksi
|
||||
```
|
||||
|
||||
### Kasus 3: "Ngoding 24/7 tanpa terputus"
|
||||
|
||||
**Masalah:** deadline mepet, downtime tidak dapat ditoleransi
|
||||
|
||||
**Solusi:**
|
||||
```
|
||||
Combo: "always-on"
|
||||
1. cc/claude-opus-4-6 (kualitas terbaik)
|
||||
2. cx/gpt-5.2-codex (langganan kedua)
|
||||
3. glm/glm-4.7 (murah, reset harian)
|
||||
4. minimax/MiniMax-M2.1 (paling murah, reset 5 jam)
|
||||
5. if/kimi-k2-thinking (gratis unlimited)
|
||||
|
||||
Hasil: 5 lapis fallback = zero downtime
|
||||
Biaya bulanan: $20-200 (langganan) + $10-20 (backup)
|
||||
```
|
||||
|
||||
### Kasus 4: "Saya mau pakai AI gratis di OpenClaw"
|
||||
|
||||
**Masalah:** butuh asisten AI di aplikasi pesan (WhatsApp, Telegram, Slack...), sepenuhnya gratis
|
||||
|
||||
**Solusi:**
|
||||
```
|
||||
Combo: "openclaw-free"
|
||||
1. if/glm-4.7 (gratis unlimited)
|
||||
2. if/minimax-m2.1 (gratis unlimited)
|
||||
3. if/kimi-k2-thinking (gratis unlimited)
|
||||
|
||||
Biaya bulanan: $0
|
||||
Cara akses: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
<details>
|
||||
<summary><b>📊 Kenapa dashboard menampilkan biaya yang besar?</b></summary>
|
||||
|
||||
Dashboard melacak pemakaian token dan menampilkan **estimasi biaya** seandainya kamu memakai API berbayar secara langsung. Ini **bukan tagihan nyata**, melainkan acuan untuk melihat berapa banyak yang kamu hemat dengan memakai model gratis atau langganan yang sudah ada lewat 9Router.
|
||||
|
||||
**Contoh:**
|
||||
- **Tampilan dashboard:** "Total biaya $290"
|
||||
- **Kenyataan:** sedang memakai iFlow (gratis unlimited)
|
||||
- **Biaya sebenarnya:** **$0.00**
|
||||
- **Arti $290:** jumlah yang **dihemat** karena memakai model gratis alih-alih API berbayar!
|
||||
|
||||
Tampilan biaya adalah "pelacak penghematan" untuk memahami pola pemakaian dan peluang optimasi.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>💳 Apakah 9Router menagih saya?</b></summary>
|
||||
|
||||
**Tidak.** 9Router adalah software open source gratis yang berjalan di komputermu sendiri. Tidak ada penagihan sama sekali.
|
||||
|
||||
**Kamu membayar ke:**
|
||||
- ✅ **Provider langganan** (Claude Code $20/bulan, Codex $20-200/bulan) → bayar langsung di website masing-masing
|
||||
- ✅ **Provider murah** (GLM, MiniMax) → bayar langsung, 9Router hanya me-routing request
|
||||
- ❌ **9Router sendiri** → **tidak menagih apa pun**
|
||||
|
||||
9Router adalah proxy/router lokal. Ia tidak menyimpan informasi kartu kredit, tidak bisa mengirim invoice, dan tidak punya sistem penagihan. Sepenuhnya software gratis.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🆓 Apakah provider gratis benar-benar unlimited?</b></summary>
|
||||
|
||||
**Ya!** Provider yang ditandai gratis (iFlow, Kiro, Qwen) benar-benar unlimited dan **tanpa biaya tersembunyi**.
|
||||
|
||||
Ini adalah layanan gratis yang disediakan masing-masing perusahaan:
|
||||
- **iFlow**: akses gratis unlimited ke 8+ model via OAuth
|
||||
- **Kiro**: model Claude gratis unlimited via AWS Builder ID
|
||||
- **Qwen**: akses gratis unlimited ke model Qwen via device authentication
|
||||
|
||||
9Router hanya me-routing request — tidak ada "jebakan" atau tagihan di kemudian hari. Layanannya memang gratis, dan 9Router membuatnya lebih mudah dipakai dengan dukungan fallback.
|
||||
|
||||
**Catatan:** beberapa provider langganan (Antigravity, GitHub Copilot) punya masa preview gratis dan bisa jadi berbayar nanti, tetapi hal itu diumumkan secara jelas oleh provider tersebut, bukan oleh 9Router.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>💰 Bagaimana cara menekan biaya AI seminimal mungkin?</b></summary>
|
||||
|
||||
**Strategi free-first:**
|
||||
|
||||
1. **Mulai dari combo 100% gratis:**
|
||||
```
|
||||
1. gc/gemini-3-flash (180rb/bulan gratis dari Google)
|
||||
2. if/kimi-k2-thinking (gratis unlimited dari iFlow)
|
||||
3. qw/qwen3-coder-plus (gratis unlimited dari Qwen)
|
||||
```
|
||||
**Biaya: $0/bulan**
|
||||
|
||||
2. **Tambahkan backup murah hanya bila perlu:**
|
||||
```
|
||||
4. glm/glm-4.7 ($0.6 per 1 juta token)
|
||||
```
|
||||
**Tambahan biaya: bayar sesuai pemakaian saja**
|
||||
|
||||
3. **Gunakan provider langganan paling akhir:**
|
||||
- Hanya jika kamu memang sudah punya
|
||||
- 9Router memaksimalkan nilainya lewat pelacakan kuota
|
||||
|
||||
**Hasil:** sebagian besar pengguna bisa jalan dengan $0/bulan hanya dengan tier gratis!
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>📈 Bagaimana kalau pemakaian tiba-tiba melonjak?</b></summary>
|
||||
|
||||
Smart fallback 9Router mencegah tagihan tak terduga:
|
||||
|
||||
**Skenario:** kuota habis di tengah sprint coding
|
||||
|
||||
**Tanpa 9Router:**
|
||||
- ❌ Kena rate limit → kerja berhenti → frustrasi
|
||||
- ❌ Atau: tagihan API mahal tanpa disengaja
|
||||
|
||||
**Dengan 9Router:**
|
||||
- ✅ Langganan mencapai batas → otomatis fallback ke tier murah
|
||||
- ✅ Tier murah jadi mahal → otomatis fallback ke tier gratis
|
||||
- ✅ Ngoding tidak berhenti → biaya tetap terprediksi
|
||||
|
||||
**Kamu yang pegang kendali:** atur batas pengeluaran per provider di dashboard, dan 9Router akan mematuhinya.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 📖 Panduan Setup
|
||||
|
||||
<details>
|
||||
<summary><b>🔐 Provider Langganan (maksimalkan nilainya)</b></summary>
|
||||
|
||||
### Claude Code (Pro/Max)
|
||||
|
||||
```bash
|
||||
Dashboard → Providers → hubungkan Claude Code
|
||||
→ login OAuth → refresh token otomatis
|
||||
→ pelacakan kuota 5 jam + mingguan
|
||||
|
||||
Model:
|
||||
cc/claude-opus-4-6
|
||||
cc/claude-sonnet-4-5-20250929
|
||||
cc/claude-haiku-4-5-20251001
|
||||
```
|
||||
|
||||
**Tips pro:** pakai Opus untuk tugas kompleks, Sonnet kalau mengutamakan kecepatan. 9Router melacak kuota per model!
|
||||
|
||||
### OpenAI Codex (Plus/Pro)
|
||||
|
||||
```bash
|
||||
Dashboard → Providers → hubungkan Codex
|
||||
→ login OAuth (port 1455)
|
||||
→ reset 5 jam + mingguan
|
||||
|
||||
Model:
|
||||
cx/gpt-5.2-codex
|
||||
cx/gpt-5.1-codex-max
|
||||
```
|
||||
|
||||
### Gemini CLI (180rb request/bulan gratis!)
|
||||
|
||||
```bash
|
||||
Dashboard → Providers → hubungkan Gemini CLI
|
||||
→ Google OAuth
|
||||
→ 180rb/bulan + 1rb/hari
|
||||
|
||||
Model:
|
||||
gc/gemini-3-flash-preview
|
||||
gc/gemini-2.5-pro
|
||||
```
|
||||
|
||||
**Value terbaik:** free tier-nya besar sekali! Pakai ini sebelum tier berbayar.
|
||||
|
||||
### GitHub Copilot
|
||||
|
||||
```bash
|
||||
Dashboard → Providers → hubungkan GitHub
|
||||
→ OAuth via GitHub
|
||||
→ reset bulanan (tanggal 1 tiap bulan)
|
||||
|
||||
Model:
|
||||
gh/gpt-5
|
||||
gh/claude-4.5-sonnet
|
||||
gh/gemini-3-pro
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>💰 Provider Murah (backup)</b></summary>
|
||||
|
||||
### GLM-4.7 (reset harian, $0.6/1M)
|
||||
|
||||
1. Daftar: [Zhipu AI](https://open.bigmodel.cn/)
|
||||
2. Ambil API key dari Coding Plan
|
||||
3. Dashboard → tambahkan API key:
|
||||
- Provider: `glm`
|
||||
- API Key: `your-key`
|
||||
|
||||
**Pemakaian:** `glm/glm-4.7`
|
||||
|
||||
**Tips pro:** Coding Plan memberi kuota 3x lipat dengan biaya 1/7! Reset setiap hari jam 10.00.
|
||||
|
||||
### MiniMax M2.1 (reset 5 jam, $0.20/1M)
|
||||
|
||||
1. Daftar: [MiniMax](https://www.minimax.io/)
|
||||
2. Ambil API key
|
||||
3. Dashboard → tambahkan API key
|
||||
|
||||
**Pemakaian:** `minimax/MiniMax-M2.1`
|
||||
|
||||
**Tips pro:** opsi termurah dengan konteks panjang (1 juta token)!
|
||||
|
||||
### Kimi K2 ($9/bulan flat)
|
||||
|
||||
1. Berlangganan: [Moonshot AI](https://platform.moonshot.ai/)
|
||||
2. Ambil API key
|
||||
3. Dashboard → tambahkan API key
|
||||
|
||||
**Pemakaian:** `kimi/kimi-latest`
|
||||
|
||||
**Tips pro:** $9/bulan flat untuk 10 juta token = biaya efektif $0.90/1M!
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🆓 Provider Gratis (backup darurat)</b></summary>
|
||||
|
||||
### iFlow (8 model gratis)
|
||||
|
||||
```bash
|
||||
Dashboard → hubungkan iFlow
|
||||
→ login OAuth iFlow
|
||||
→ pemakaian unlimited
|
||||
|
||||
Model:
|
||||
if/kimi-k2-thinking
|
||||
if/qwen3-coder-plus
|
||||
if/glm-4.7
|
||||
if/minimax-m2
|
||||
if/deepseek-r1
|
||||
```
|
||||
|
||||
### Qwen (3 model gratis)
|
||||
|
||||
```bash
|
||||
Dashboard → hubungkan Qwen
|
||||
→ autentikasi device code
|
||||
→ pemakaian unlimited
|
||||
|
||||
Model:
|
||||
qw/qwen3-coder-plus
|
||||
qw/qwen3-coder-flash
|
||||
```
|
||||
|
||||
### Kiro (Claude gratis)
|
||||
|
||||
```bash
|
||||
Dashboard → hubungkan Kiro
|
||||
→ AWS Builder ID atau Google/GitHub
|
||||
→ pemakaian unlimited
|
||||
|
||||
Model:
|
||||
kr/claude-sonnet-4.5
|
||||
kr/claude-haiku-4.5
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🎨 Membuat Combo</b></summary>
|
||||
|
||||
### Contoh 1: maksimalkan langganan → backup murah
|
||||
|
||||
```
|
||||
Dashboard → Combos → buat baru
|
||||
|
||||
Nama: premium-coding
|
||||
Model:
|
||||
1. cc/claude-opus-4-6 (langganan, utama)
|
||||
2. glm/glm-4.7 (backup murah, $0.6/1M)
|
||||
3. minimax/MiniMax-M2.1 (fallback termurah, $0.20/1M)
|
||||
|
||||
Pemakaian di CLI: premium-coding
|
||||
|
||||
Contoh biaya bulanan (100 juta token):
|
||||
80 juta lewat Claude (langganan): tambahan $0
|
||||
15 juta lewat GLM: $9
|
||||
5 juta lewat MiniMax: $1
|
||||
Total: $10
|
||||
```
|
||||
|
||||
### Contoh 2: combo 100% gratis
|
||||
|
||||
```
|
||||
Nama: free-forever
|
||||
Model:
|
||||
1. gc/gemini-3-flash (180rb request/bulan gratis)
|
||||
2. if/kimi-k2-thinking (gratis unlimited)
|
||||
3. qw/qwen3-coder-plus (gratis unlimited)
|
||||
4. kr/claude-sonnet-4.5 (gratis unlimited)
|
||||
|
||||
Biaya bulanan: $0
|
||||
```
|
||||
|
||||
### Tips membuat combo
|
||||
|
||||
- Urutkan dari kualitas/prioritas tertinggi ke fallback paling murah
|
||||
- Selalu taruh minimal satu provider gratis di posisi terakhir
|
||||
- Pakai nama combo yang deskriptif agar mudah dipilih dari CLI
|
||||
- Aktifkan cloud sync agar combo ikut tersedia di perangkat lain
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Deployment
|
||||
|
||||
<details>
|
||||
<summary><b>Docker</b></summary>
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name 9router \
|
||||
-p 20128:20128 \
|
||||
-v 9router-data:/app/data \
|
||||
-e PORT=20128 \
|
||||
-e BASE_URL=http://localhost:20128 \
|
||||
ghcr.io/decolua/9router:latest
|
||||
```
|
||||
|
||||
Dashboard: `http://localhost:20128/dashboard`
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>VPS / Cloud</b></summary>
|
||||
|
||||
```bash
|
||||
npm install -g 9router
|
||||
PORT=20128 HOSTNAME=0.0.0.0 BASE_URL=https://your-domain.com 9router
|
||||
```
|
||||
|
||||
Disarankan menaruhnya di belakang reverse proxy (Nginx/Caddy) dengan HTTPS, dan membatasi akses hanya untuk dirimu sendiri.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Cloudflare Workers</b></summary>
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npx wrangler deploy
|
||||
```
|
||||
|
||||
Atur `BASE_URL` dan `CLOUD_URL` sebagai environment variable di dashboard Cloudflare.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Troubleshooting
|
||||
|
||||
| Masalah | Kemungkinan Penyebab | Solusi |
|
||||
|---------|----------------------|--------|
|
||||
| Tool CLI tidak bisa konek | Endpoint salah | Pastikan `http://localhost:20128/v1` |
|
||||
| 401 / Unauthorized | API key salah | Salin ulang key dari dashboard |
|
||||
| Model tidak ditemukan | Prefix provider salah | Pakai format `provider/model`, mis. `if/kimi-k2-thinking` |
|
||||
| Selalu fallback ke gratis | Kuota langganan habis | Cek hitung mundur reset di dashboard |
|
||||
| OAuth gagal | Port callback terpakai | Tutup proses lain (mis. port 1455 untuk Codex) |
|
||||
| UI menggantung saat sync | DNS/jaringan cloud bermasalah | Cek `CLOUD_URL`; sync memakai timeout fail-fast |
|
||||
|
||||
Aktifkan mode debug di dashboard untuk melihat log lengkap request/response.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Kontribusi
|
||||
|
||||
Kontribusi sangat diterima!
|
||||
|
||||
1. Fork repo ini
|
||||
2. Buat branch fitur (`git checkout -b feature/nama-fitur`)
|
||||
3. Commit perubahanmu (`git commit -m 'feat: tambah fitur X'`)
|
||||
4. Push ke branch (`git push origin feature/nama-fitur`)
|
||||
5. Buka Pull Request
|
||||
|
||||
---
|
||||
|
||||
## 📄 Lisensi
|
||||
|
||||
MIT License — lihat [LICENSE](https://github.com/decolua/9router/blob/main/LICENSE) untuk detailnya.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**Kalau 9Router membantumu, kasih ⭐ di [GitHub](https://github.com/decolua/9router)!**
|
||||
|
||||
[🌐 Website](https://9router.com) • [📦 npm](https://www.npmjs.com/package/9router) • [🐛 Laporkan Bug](https://github.com/decolua/9router/issues)
|
||||
|
||||
</div>
|
||||
1526
i18n/README.pt-BR.md
Normal file
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
723
i18n/README.th.md
Normal 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>
|
||||
|
||||
[](https://www.npmjs.com/package/9router)
|
||||
[](https://www.npmjs.com/package/9router)
|
||||
[](https://github.com/decolua/9router/blob/main/LICENSE)
|
||||
|
||||
[🚀 เริ่มต้นใช้งาน](#-quick-start) • [💡 ฟีเจอร์](#-key-features) • [📖 การตั้งค่า](#-setup-guide) • [🌐 เว็บไซต์](https://9router.com)
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🤔 ทำไมต้อง 9Router?
|
||||
|
||||
**หยุดเสียเงินและเจอขีดจำกัด:**
|
||||
|
||||
- ❌ โควตาสมาชิกหมดอายุโดยไม่ได้ใช้ทุกเดือน
|
||||
- ❌ Rate Limit หยุดคุณระหว่างเขียนโค้ด
|
||||
- ❌ ค่า API แพง ($20-50/เดือน ต่อผู้ให้บริการแต่ละราย)
|
||||
- ❌ ต้องสลับผู้ให้บริการด้วยตนเอง
|
||||
|
||||
**9Router แก้ปัญหาเหล่านี้:**
|
||||
|
||||
- ✅ **ประหยัดโทเค็น RTK** - บีบอัดผลลัพธ์จากเครื่องมือ (`git diff`, `grep`, `ls`...) ก่อนส่งให้ LLM
|
||||
- ✅ **เพิ่มประสิทธิภาพสมาชิก** - ติดตามโควตา ใช้ทุกบิตก่อนรีเซ็ต
|
||||
- ✅ **สลับอัตโนมัติ** - สมาชิก → ถูก → ฟรี, ไม่มีเวลาหยุดทำงาน
|
||||
- ✅ **รองรับหลายบัญชี** - Round-robin ระหว่างบัญชีของผู้ให้บริการแต่ละราย
|
||||
- ✅ **ใช้งานได้ทุกที่** - ใช้ได้กับ Claude Code, Codex, Cursor, Cline, เครื่องมือ CLI ใดก็ได้
|
||||
|
||||
---
|
||||
|
||||
## 🔄 วิธีการทำงาน
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Your CLI │ (Claude Code, Codex, OpenClaw, Cursor, Cline...)
|
||||
│ Tool │
|
||||
└──────┬──────┘
|
||||
│ http://localhost:20128/v1
|
||||
↓
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 9Router (Smart Router) │
|
||||
│ • RTK Token Saver (ตัดโทเค็น tool_result) │
|
||||
│ • แปลงรูปแบบ (OpenAI ↔ Claude) │
|
||||
│ • ติดตามโควตา │
|
||||
│ • รีเฟรชโทเค็นอัตโนมัติ │
|
||||
└──────┬──────────────────────────────────────┘
|
||||
│
|
||||
├─→ [Tier 1: สมาชิก] Claude Code, Codex, GitHub Copilot
|
||||
│ ↓ โควตาหมด
|
||||
├─→ [Tier 2: ถูก] GLM ($0.6/1M), MiniMax ($0.2/1M)
|
||||
│ ↓ งบหมด
|
||||
└─→ [Tier 3: ฟรี] Kiro, OpenCode Free, Vertex ($300 เครดิตฟรี)
|
||||
|
||||
ผลลัพธ์: ไม่ต้องหยุดเขียนโค้ด ค่าใช้จ่ายน้อยที่สุด + ประหยัดโทเค็น 20-40% ด้วย RTK
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ เริ่มต้นใช้งาน
|
||||
|
||||
**1. ติดตั้งแบบ Global:**
|
||||
|
||||
```bash
|
||||
npm install -g 9router
|
||||
9router
|
||||
```
|
||||
|
||||
🎉 เปิดแดชบอร์ดที่ `http://localhost:20128`
|
||||
|
||||
**2. เชื่อมต่อผู้ให้บริการฟรี (ไม่ต้องสมัคร):**
|
||||
|
||||
แดชบอร์ด → Providers → เชื่อมต่อ **Kiro AI** (Claude ฟรีไม่จำกัด) หรือ **OpenCode Free** (ไม่ต้องยืนยันตัวตน) → เสร็จ!
|
||||
|
||||
**3. ใช้ในเครื่องมือ CLI ของคุณ:**
|
||||
|
||||
```
|
||||
ตั้งค่า Claude Code/Codex/OpenClaw/Cursor/Cline:
|
||||
Endpoint: http://localhost:20128/v1
|
||||
API Key: [คัดลอกจากแดชบอร์ด]
|
||||
Model: kr/claude-sonnet-4.5
|
||||
```
|
||||
|
||||
**เสร็จแล้ว!** เริ่มเขียนโค้ดด้วยโมเดล AI ฟรี
|
||||
|
||||
**วิธีอื่น: รันจากซอร์สโค้ด (เก็บรักษาไว้ใน repo นี้):**
|
||||
|
||||
Repo นี้เป็น private package (`9router-app`) ดังนั้นการรันจากซอร์ส/Docker คือเส้นทางพัฒนาท้องถิ่นที่คาดไว้
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
npm install
|
||||
PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
|
||||
```
|
||||
|
||||
โหมด Production:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start
|
||||
```
|
||||
|
||||
URL ค่าเริ่มต้น:
|
||||
- แดชบอร์ด: `http://localhost:20128/dashboard`
|
||||
- OpenAI-compatible API: `http://localhost:20128/v1`
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ เครื่องมือ CLI ที่รองรับ
|
||||
|
||||
9Router ทำงานได้อย่างราบรื่นกับเครื่องมือเขียนโค้ด AI ทุกประเภท:
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/claude.png" width="60" alt="Claude Code"/><br/>
|
||||
<b>Claude-Code</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/openclaw.png" width="60" alt="OpenClaw"/><br/>
|
||||
<b>OpenClaw</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/codex.png" width="60" alt="Codex"/><br/>
|
||||
<b>Codex</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/opencode.png" width="60" alt="OpenCode"/><br/>
|
||||
<b>OpenCode</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/cursor.png" width="60" alt="Cursor"/><br/>
|
||||
<b>Cursor</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/antigravity.png" width="60" alt="Antigravity"/><br/>
|
||||
<b>Antigravity</b>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/cline.png" width="60" alt="Cline"/><br/>
|
||||
<b>Cline</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/continue.png" width="60" alt="Continue"/><br/>
|
||||
<b>Continue</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/droid.png" width="60" alt="Droid"/><br/>
|
||||
<b>Droid</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/roo.png" width="60" alt="Roo"/><br/>
|
||||
<b>Roo</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/copilot.png" width="60" alt="Copilot"/><br/>
|
||||
<b>Copilot</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/kilocode.png" width="60" alt="Kilo Code"/><br/>
|
||||
<b>Kilo Code</b>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## ผู้ให้บริการที่รองรับ
|
||||
|
||||
### 🔐 ผู้ให้บริการ OAuth
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/claude.png" width="60" alt="Claude Code"/><br/>
|
||||
<b>Claude-Code</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/antigravity.png" width="60" alt="Antigravity"/><br/>
|
||||
<b>Antigravity</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/codex.png" width="60" alt="Codex"/><br/>
|
||||
<b>Codex</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/github.png" width="60" alt="GitHub"/><br/>
|
||||
<b>GitHub</b>
|
||||
</td>
|
||||
<td align="center" width="120">
|
||||
<img src="../public/providers/cursor.png" width="60" alt="Cursor"/><br/>
|
||||
<b>Cursor</b>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
### 🆓 ผู้ให้บริการฟรี
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="150">
|
||||
<img src="../public/providers/kiro.png" width="70" alt="Kiro"/><br/>
|
||||
<b>Kiro AI</b><br/>
|
||||
<sub>Claude 4.5 + GLM-5 + MiniMax • ไม่จำกัด ฟรี</sub>
|
||||
</td>
|
||||
<td align="center" width="150">
|
||||
<img src="../public/providers/opencode.png" width="70" alt="OpenCode"/><br/>
|
||||
<b>OpenCode Free</b><br/>
|
||||
<sub>ไม่ต้องยืนยันตัวตน • ดึงโมเดลอัตโนมัติ • ไม่จำกัด ฟรี</sub>
|
||||
</td>
|
||||
<td align="center" width="150">
|
||||
<img src="../public/providers/gemini.png" width="70" alt="Vertex AI"/><br/>
|
||||
<b>Vertex AI</b><br/>
|
||||
<sub>Gemini 3 Pro + GLM-5 + DeepSeek • เครดิตฟรี $300</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
> **หมายเหตุ:** iFlow, Qwen และ Gemini CLI หยุดให้บริการในปี 2026 แล้ว ใช้ Kiro / OpenCode Free / Vertex แทน
|
||||
|
||||
### 🔑 ผู้ให้บริการ API Key (40+)
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/openrouter.png" width="50" alt="OpenRouter"/><br/>
|
||||
<sub>OpenRouter</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/glm.png" width="50" alt="GLM"/><br/>
|
||||
<sub>GLM</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/kimi.png" width="50" alt="Kimi"/><br/>
|
||||
<sub>Kimi</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/minimax.png" width="50" alt="MiniMax"/><br/>
|
||||
<sub>MiniMax</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/openai.png" width="50" alt="OpenAI"/><br/>
|
||||
<sub>OpenAI</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/anthropic.png" width="50" alt="Anthropic"/><br/>
|
||||
<sub>Anthropic</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/gemini.png" width="50" alt="Gemini"/><br/>
|
||||
<sub>Gemini</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/deepseek.png" width="50" alt="DeepSeek"/><br/>
|
||||
<sub>DeepSeek</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/groq.png" width="50" alt="Groq"/><br/>
|
||||
<sub>Groq</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/xai.png" width="50" alt="xAI"/><br/>
|
||||
<sub>xAI</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/mistral.png" width="50" alt="Mistral"/><br/>
|
||||
<sub>Mistral</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/perplexity.png" width="50" alt="Perplexity"/><br/>
|
||||
<sub>Perplexity</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/together.png" width="50" alt="Together"/><br/>
|
||||
<sub>Together AI</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/fireworks.png" width="50" alt="Fireworks"/><br/>
|
||||
<sub>Fireworks</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/cerebras.png" width="50" alt="Cerebras"/><br/>
|
||||
<sub>Cerebras</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/cohere.png" width="50" alt="Cohere"/><br/>
|
||||
<sub>Cohere</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/nvidia.png" width="50" alt="NVIDIA"/><br/>
|
||||
<sub>NVIDIA</sub>
|
||||
</td>
|
||||
<td align="center" width="100">
|
||||
<img src="../public/providers/siliconflow.png" width="50" alt="SiliconFlow"/><br/>
|
||||
<sub>SiliconFlow</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p><i>...และผู้ให้บริการอีกกว่า 20 ราย รวมถึง Nebius, Chutes, Hyperbolic และ OpenAI/Anthropic compatible endpoints แบบกำหนดเอง</i></p>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 💡 ฟีเจอร์หลัก
|
||||
|
||||
| ฟีเจอร์ | ทำอะไร | ทำไมถึงสำคัญ |
|
||||
|---------|--------------|----------------|
|
||||
| 🚀 **RTK Token Saver** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | บีบอัดผลลัพธ์จากเครื่องมือ (`git diff`, `grep`, `ls`, `tree`...) ก่อนส่งให้ LLM | ประหยัด **โทเค็น input 20-40%** ต่อคำขอ |
|
||||
| 🧠 **Headroom Token Saver** ([Headroom](https://github.com/chopratejas/headroom)) | พร็อกซี `/v1/compress` ภายนอกก่อนเลือกผู้ให้บริการ | ประหยัดโทเค็นบริบทมากขึ้นโดยไม่ต้องเปลี่ยน client |
|
||||
| 🪨 **Caveman Mode** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | ฉีด caveman-speak prompt → LLM ตอบสั้นกระชับ เนื้อหาทางเทคนิคยังครบถ้วน | ประหยัด **โทเค็น output สูงสุด 65%** |
|
||||
| 🐴 **Ponytail** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | ฉีด prompt "lazy senior dev" → LLM เขียนโค้ดน้อยที่สุด YAGNI-first (Lite/Full/Ultra) | **โทเค็น output น้อยลง, ไม่ต้อง refactor มาก** |
|
||||
| 🎯 **Smart 3-Tier Fallback** | เลือกเส้นทางอัตโนมัติ: สมาชิก → ถูก → ฟรี | ไม่ต้องหยุดเขียนโค้ด, ไม่มีเวลาหยุดทำงาน |
|
||||
| 📊 **ติดตามโควตาแบบ Real-Time** | นับโทเค็นแบบ live + นับถอยหลังรีเซ็ต | เพิ่มประสิทธิภาพมูลค่าสมาชิก |
|
||||
| 🔄 **แปลงรูปแบบ** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | ใช้ได้กับเครื่องมือ CLI ทุกประเภท |
|
||||
| 👥 **รองรับหลายบัญชี** | หลายบัญชีต่อผู้ให้บริการ | Load balancing + สำรองข้อมูล |
|
||||
| 🔄 **รีเฟรชโทเค็นอัตโนมัติ** | OAuth token รีเฟรชอัตโนมัติ | ไม่ต้องล็อกอินซ้ำด้วยตนเอง |
|
||||
| 🎨 **Combo กำหนดเอง** | สร้างการผสมผสานโมเดลไม่จำกัด | ปรับแต่ง fallback ตามความต้องการ |
|
||||
| 📝 **บันทึก Request** | โหมด debug พร้อม log request/response ครบถ้วน | แก้ไขปัญหาได้ง่าย |
|
||||
| 💾 **ซิงค์คลาวด์** | ซิงค์การตั้งค่าระหว่างอุปกรณ์ | การตั้งค่าเดียวกันทุกที่ |
|
||||
| 📊 **วิเคราะห์การใช้งาน** | ติดตามโทเค็น, ค่าใช้จ่าย, แนวโน้มตามเวลา | ปรับแต่งค่าใช้จ่าย |
|
||||
| 🌐 **Deploy ได้ทุกที่** | Localhost, VPS, Docker, Cloudflare Workers | ตัวเลือก deploy ที่ยืดหยุ่น |
|
||||
|
||||
<details>
|
||||
<summary><b>📖 รายละเอียดฟีเจอร์</b></summary>
|
||||
|
||||
### 🚀 RTK Token Saver
|
||||
|
||||
ผลลัพธ์จากเครื่องมือ (`git diff`, `grep`, `find`, `ls`, `tree`, log dumps...) มักกินงบประมาณ prompt 30-50% RTK ตรวจสอบและบีบอัดอย่างชาญฉลาดแบบ lossless **ก่อน**คำขอถึง LLM:
|
||||
|
||||
- **ตัวกรอง:** `git-diff`, `git-status`, `grep`, `find`, `ls`, `tree`, `dedup-log`, `smart-truncate`, `read-numbered`, `search-list`
|
||||
- **ตรวจจับอัตโนมัติ:** ไม่ต้องตั้งค่า — RTK .peek 1KB แรกของแต่ละ `tool_result` และเลือกตัวกรองที่ถูกต้อง
|
||||
- **ปลอดภัยโดยการออกแบบ:** ถ้าตัวกรองล้มเหลว, ขว้าง error, หรือทำให้ผลลัพธ์ใหญ่ขึ้น RTK จะเก็บข้อความต้นฉบับไว้โดยเงียบๆ ไม่มี error ทำให้คำขอของคุณล้มเหลว
|
||||
- **ใช้ได้ทุกที่:** ใช้ได้กับทุกรูปแบบ (OpenAI, Claude, Gemini, Cursor, Kiro, OpenAI Responses) เพราะทำงาน **ก่อน**การแปลงรูปแบบใดๆ
|
||||
- **เปิดใช้งานเป็นค่าเริ่มต้น:** ปิด/เปิดได้ตลอดเวลาใน แดชบอร์ด → ตั้งค่า Endpoint
|
||||
|
||||
```
|
||||
ไม่ใช้ RTK: ส่ง 47K โทเค็นให้ LLM
|
||||
ใช้ RTK: ส่ง 28K โทเค็นให้ LLM (ประหยัด 40% · บริบทเดียวกัน · คำตอบเดียวกัน)
|
||||
```
|
||||
|
||||
### 🧠 Headroom Token Saver
|
||||
|
||||
Headroom เป็นตัวเลือกและทำงานแยกกัน 9Router เรียก endpoint `/v1/compress` ของ Headroom จากนั้นยังคงเลือกเส้นทาง, fallback, auth และติดตามการใช้งานตามปกติ:
|
||||
|
||||
```
|
||||
Client → 9Router → Headroom /v1/compress → 9Router → provider
|
||||
```
|
||||
|
||||
ตั้งค่าท้องถิ่น:
|
||||
|
||||
```bash
|
||||
pip install "headroom-ai[proxy]"
|
||||
headroom proxy --port 8787
|
||||
```
|
||||
|
||||
เปิดใช้งานใน แดชบอร์ด → Endpoint → Token Saver → Headroom URL ค่าเริ่มต้น: `http://localhost:8787`
|
||||
|
||||
ตัวอย่าง Docker:
|
||||
|
||||
```bash
|
||||
# Headroom service ใน Docker network เดียวกัน
|
||||
http://host.docker.internal:8787
|
||||
```
|
||||
|
||||
ถ้า Headroom ดับหรือคืน error, 9Router จะ fail open และส่งคำขอต้นฉบับ
|
||||
|
||||
### 🐴 Ponytail (Lazy Senior Dev)
|
||||
|
||||
Ponytail ฉีด prompt *"lazy senior dev"* เข้าไปในทุกคำขอ ทำให้ LLM เขียนโค้ดน้อยที่สุดแบบ YAGNI-first — ลบมากกว่าเพิ่ม, stdlib มากกว่า dep ใหม่, one-liner มากกว่า abstraction
|
||||
|
||||
- **Lite** — สร้างตามที่ขอ, บอกชื่อทางเลือกที่ lazy กว่า
|
||||
- **Full** — บังคับ YAGNI ladder: stdlib → native → existing deps → one-liner → minimal code
|
||||
- **Ultra** — YAGNI extremist: ลบก่อน, ส่ง one-liner, ตั้งคำถามกับ requirement ที่เหลือในคำตอบเดียวกัน
|
||||
|
||||
```
|
||||
ไม่ใช้ Ponytail: โค้ดเยอะ, abstraction เยอะ, "เผื่อไว้" scaffolding
|
||||
ใช้ Ponytail: diff สั้นที่สุดที่ทำงานได้, ไม่เพิ่ม abstraction ที่ไม่ได้ขอ, โทเค็นน้อยลง
|
||||
```
|
||||
|
||||
ไม่มีวันแลก: input validation, error handling ที่ป้องกัน data loss, security, accessibility หรือสิ่งที่ขอมาอย่างชัดเจน เปิดใช้งานใน แดชบอร์ด → Endpoint → Ponytail ใช้คู่กับ Caveman (ความกระชับ output) และ RTK (การบีบอัด input) ได้
|
||||
|
||||
### 🎯 Smart 3-Tier Fallback
|
||||
|
||||
สร้าง combo พร้อม fallback อัตโนมัติ:
|
||||
|
||||
```
|
||||
Combo: "my-coding-stack"
|
||||
1. cc/claude-opus-4-6 (สมาชิกของคุณ)
|
||||
2. glm/glm-4.7 (สำรองราคาถูก, $0.6/1M)
|
||||
3. if/kimi-k2-thinking (fallback ฟรี)
|
||||
|
||||
→ สลับอัตโนมัติเมื่อโควตาหมดหรือเกิด error
|
||||
```
|
||||
|
||||
### 📊 ติดตามโควตาแบบ Real-Time
|
||||
|
||||
- การใช้โทเค็นต่อผู้ให้บริการ
|
||||
- นับถอยหลังรีเซ็ต (5 ชั่วโมง, รายวัน, รายสัปดาห์)
|
||||
- ประมาณการค่าใช้จ่ายสำหรับชั้นแบบเสียค่าใช้จ่าย
|
||||
- รายงานค่าใช้จ่ายรายเดือน
|
||||
|
||||
### 🔄 แปลงรูปแบบ
|
||||
|
||||
แปลงรูปแบบได้อย่างราบรื่น:
|
||||
- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **Cursor** ↔ **Kiro** ↔ **Vertex** ↔ **Antigravity** ↔ **Ollama** ↔ **OpenAI Responses**
|
||||
- เครื่องมือ CLI ของคุณส่งรูปแบบ OpenAI → 9Router แปลง → ผู้ให้บริการได้รับรูปแบบต้นฉบับ
|
||||
- ใช้ได้กับเครื่องมือใดก็ได้ที่รองรับ custom OpenAI endpoints
|
||||
|
||||
### 👥 รองรับหลายบัญชี
|
||||
|
||||
- เพิ่มหลายบัญชีสำหรับผู้ให้บริการแต่ละราย
|
||||
- เลือกเส้นทาง round-robin หรือตามลำดับความสำคัญอัตโนมัติ
|
||||
- Fallback ไปยังบัญชีถัดไปเมื่อบัญชีหนึ่งชนโควตา
|
||||
|
||||
### 🔄 รีเฟรชโทเค็นอัตโนมัติ
|
||||
|
||||
- OAuth token รีเฟรชอัตโนมัติก่อนหมดอายุ
|
||||
- ไม่ต้องยืนยันตัวตนใหม่ด้วยตนเอง
|
||||
- ประสบการณ์ที่ราบรื่นบนผู้ให้บริการทุกราย
|
||||
|
||||
### 🎨 Combo กำหนดเอง
|
||||
|
||||
- สร้างการผสมผสานโมเดลไม่จำกัด
|
||||
- ผสมชั้นสมาชิก, ราคาถูกและฟรี
|
||||
- ตั้งชื่อ combo เพื่อเข้าถึงง่าย
|
||||
- แชร์ combo ระหว่างอุปกรณ์ด้วยการซิงค์คลาวด์
|
||||
|
||||
### 📝 บันทึก Request
|
||||
|
||||
- เปิดโหมด debug เพื่อดู log request/response ครบถ้วน
|
||||
- ติดตาม API calls, headers และ payloads
|
||||
- แก้ไขปัญหาการเชื่อมต่อ
|
||||
- Export log เพื่อวิเคราะห์
|
||||
|
||||
### 💾 ซิงค์คลาวด์
|
||||
|
||||
- ซิงค์ผู้ให้บริการ, combo และการตั้งค่าระหว่างอุปกรณ์
|
||||
- ซิงค์เบื้องหลังอัตโนมัติ
|
||||
- จัดเก็บข้อมูลแบบเข้ารหัสปลอดภัย
|
||||
- เข้าถึงการตั้งค่าของคุณจากทุกที่
|
||||
|
||||
### 📊 วิเคราะห์การใช้งาน
|
||||
|
||||
- ติดตามการใช้โทเค็นตามผู้ให้บริการและโมเดล
|
||||
- ประมาณการค่าใช้จ่ายและแนวโน้มค่าใช้จ่าย
|
||||
- รายงานและข้อมูลเชิงลึกรายเดือน
|
||||
- ปรับแต่งค่าใช้จ่าย AI ของคุณ
|
||||
|
||||
### 🌐 Deploy ได้ทุกที่
|
||||
|
||||
- 💻 **Localhost** - ค่าเริ่มต้น, ทำงานออฟไลน์
|
||||
- ☁️ **VPS/Cloud** - แชร์ระหว่างอุปกรณ์
|
||||
- 🐳 **Docker** - Deploy ด้วยคำสั่งเดียว
|
||||
- 🚀 **Cloudflare Workers** - เครือข่าย edge ทั่วโลก
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 💰 สรุปราคา
|
||||
|
||||
| ประเภท | ผู้ให้บริการ | ค่าใช้จ่าย | รีเซ็ตโควตา | ดีที่สุดสำหรับ |
|
||||
|------|----------|------|-------------|----------|
|
||||
| **💳 สมาชิก** | Claude Code (Pro) | $20/เดือน | 5 ชม. + รายสัปดาห์ | มีสมาชิกอยู่แล้ว |
|
||||
| | Codex (Plus/Pro) | $20-200/เดือน | 5 ชม. + รายสัปดาห์ | ผู้ใช้ OpenAI |
|
||||
| | GitHub Copilot | $10-19/เดือน | รายเดือน | ผู้ใช้ GitHub |
|
||||
| **💰 ราคาถูก** | GLM-4.7 | $0.6/1M | ทุกวัน 10:00 AM | สำรองงบ |
|
||||
| | MiniMax M2.1 | $0.2/1M | 5 ชั่วโมง | ถูกที่สุด |
|
||||
| | Kimi K2 | $9/เดือน คงที่ | 10M โทเค็น/เดือน | ค่าใช้จ่ายที่คาดเดาได้ |
|
||||
| **🆓 ฟรี** | Kiro | $0 | ไม่จำกัด | Claude ฟรี |
|
||||
| | OpenCode Free | $0 | ไม่จำกัด | ไม่ต้องยืนยันตัวตน |
|
||||
| | Vertex AI | $0 | $300 เครดิตฟรี | Gemini 3 Pro |
|
||||
|
||||
**💡 เคล็ดลับ:** เริ่มจาก combo Kiro (Claude ฟรีไม่จำกัด) + OpenCode Free (ไม่ต้องยืนยันตัวตน) = ค่าใช้จ่าย $0!
|
||||
|
||||
---
|
||||
|
||||
## 🎯 กรณีการใช้งาน
|
||||
|
||||
### กรณีที่ 1: "ฉันมีสมาชิก Claude Pro"
|
||||
|
||||
**ปัญหา:** โควตาหมดอายุโดยไม่ได้ใช้, Rate Limit ตอนเขียนโค้ดหนัก
|
||||
|
||||
**วิธีแก้:**
|
||||
```
|
||||
Combo: "maximize-claude"
|
||||
1. cc/claude-opus-4-6 (ใช้สมาชิกเต็มที่)
|
||||
2. glm/glm-4.7 (สำรองราคาถูกเมื่อโควตาหมด)
|
||||
3. kr/claude-sonnet-4.5 (fallback ฉุกเฉินฟรี)
|
||||
|
||||
ค่าใช้จ่ายรายเดือน: $20 (สมาชิก) + ~$5 (สำรอง) = $25 รวม
|
||||
เทียบกับ $20 + ชนโควตา = ผิดหวัง
|
||||
```
|
||||
|
||||
### กรณีที่ 2: "ฉันต้องการค่าใช้จ่ายเป็นศูนย์"
|
||||
|
||||
**ปัญหา:** ไม่มีงบจ่ายสมาชิก, ต้องการ AI เขียนโค้ดที่เชื่อถือได้
|
||||
|
||||
**วิธีแก้:**
|
||||
```
|
||||
Combo: "free-forever"
|
||||
1. kr/claude-sonnet-4.5 (Claude ฟรีไม่จำกัด)
|
||||
2. oc/* (OpenCode Free ไม่ต้องยืนยันตัวตน)
|
||||
3. vertex/gemini-3.1-pro-preview (Vertex $300 เครดิตฟรี)
|
||||
|
||||
ค่าใช้จ่ายรายเดือน: $0
|
||||
คุณภาพ: โมเดลพร้อมใช้งาน production
|
||||
```
|
||||
|
||||
### กรณีที่ 3: "ฉันต้องเขียนโค้ด 24/7 ไม่มีสะดุด"
|
||||
|
||||
**ปัญหา:** Deadline, ไม่สามารถหยุดทำงานได้
|
||||
|
||||
**วิธีแก้:**
|
||||
```
|
||||
Combo: "always-on"
|
||||
1. cc/claude-opus-4-6 (คุณภาพดีที่สุด)
|
||||
2. cx/gpt-5.5 (สมาชิกที่สอง)
|
||||
3. glm/glm-5.1 (ราคาถูก, รีเซ็ตทุกวัน)
|
||||
4. minimax/MiniMax-M2.7 (ถูกที่สุด, รีเซ็ต 5 ชม.)
|
||||
5. kr/claude-sonnet-4.5 (ฟรีไม่จำกัด)
|
||||
|
||||
ผลลัพธ์: 5 ชั้น fallback = ไม่มีเวลาหยุดทำงาน
|
||||
ค่าใช้จ่ายเดือน: $20-200 (สมาชิก) + $10-20 (สำรอง)
|
||||
```
|
||||
|
||||
### กรณีที่ 4: "ฉันต้องการ AI ฟรีใน OpenClaw"
|
||||
|
||||
**ปัญหา:** ต้องการ AI assistant ในแอปพลิเคชันแชท (WhatsApp, Telegram, Slack...), ฟรีทั้งหมด
|
||||
|
||||
**วิธีแก้:**
|
||||
```
|
||||
Combo: "openclaw-free"
|
||||
1. kr/claude-sonnet-4.5 (Claude ฟรีไม่จำกัด)
|
||||
2. kr/glm-5 (GLM ฟรีไม่จำกัด)
|
||||
3. kr/MiniMax-M2.5 (MiniMax ฟรีไม่จำกัด)
|
||||
|
||||
ค่าใช้จ่ายรายเดือน: $0
|
||||
เข้าถึงผ่าน: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❓ คำถามที่พบบ่อย
|
||||
|
||||
<details>
|
||||
<summary><b>💳 9Router เก็บเงินฉันหรือไม่?</b></summary>
|
||||
|
||||
**ไม่.** 9Router เป็นซอฟต์แวร์ฟรีแบบ open source ที่ทำงานบนเครื่องของคุณเอง มันไม่มีวันเรียกเก็บเงินจากคุณ
|
||||
|
||||
**คุณจ่ายเงินเฉพาะ:**
|
||||
- ✅ **ผู้ให้บริการสมาชิก** (Claude Code $20/เดือน, Codex $20-200/เดือน) → จ่ายตรงให้พวกเขาบนเว็บไซต์ของพวกเขา
|
||||
- ✅ **ผู้ให้บริการราคาถูก** (GLM, MiniMax) → จ่ายตรงให้พวกเขา, 9Router แค่เลือกเส้นทางคำขอของคุณ
|
||||
- ❌ **ตัว 9Router เอง** → **ไม่มีวันเรียกเก็บเงินใดๆ ทั้งสิ้น**
|
||||
|
||||
9Router เป็น proxy/router ท้องถิ่น มันไม่มีบัตรเครดิตของคุณ, ไม่สามารถส่งใบแจ้งหนี้ได้ และไม่มีระบบชำระเงิน เป็นซอฟต์แวร์ฟรีทั้งหมด
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🆓 ผู้ให้บริการฟรีไม่จำกัดจริงหรือ?</b></summary>
|
||||
|
||||
**จริง!** ผู้ให้บริการที่ระบุว่าฟรี (Kiro, OpenCode Free, Vertex) ไม่จำกัดจริงๆ **ไม่มีค่าใช้จ่ายแอบแฝง**
|
||||
|
||||
นี่คือบริการฟรีที่บริษัทต่างๆ ให้บริการ:
|
||||
- **Kiro**: Claude ฟรีไม่จำกัดผ่าน AWS Builder ID
|
||||
- **OpenCode Free**: ไม่ต้องยืนยันตัวตน, ดึงโมเดลอัตโนมัติ
|
||||
- **Vertex AI**: $300 เครดิตฟรีสำหรับ Gemini 3 Pro
|
||||
|
||||
9Router แค่เลือกเส้นทางคำขอของคุณไปหาพวกเขา — ไม่มี "กับดัก" หรือการเรียกเก็บเงินในอนาคต เป็นบริการที่ฟรีจริงๆ และ 9Router ทำให้ใช้งานง่ายด้วยการรองรับ fallback
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>💰 ทำอย่างไรเพื่อลดค่าใช้จ่าย AI จริงของฉัน?</b></summary>
|
||||
|
||||
**กลยุทธ์ Free First:**
|
||||
|
||||
1. **เริ่มจาก combo ฟรี 100%:**
|
||||
```
|
||||
1. kr/claude-sonnet-4.5 (Claude ฟรีไม่จำกัด)
|
||||
2. oc/* (OpenCode Free ไม่ต้องยืนยันตัวตน)
|
||||
3. vertex/gemini-3.1-pro-preview ($300 เครดิตฟรี)
|
||||
```
|
||||
**ค่าใช้จ่าย: $0/เดือน**
|
||||
|
||||
2. **เพิ่มสำรองราคาถูก** เมื่อจำเป็นเท่านั้น:
|
||||
```
|
||||
4. glm/glm-5.1 ($0.6/1M โทเค็น)
|
||||
```
|
||||
**ค่าใช้จ่ายเพิ่มเติม:** จ่ายเฉพาะที่ใช้
|
||||
|
||||
3. **ใช้ผู้ให้บริการสมาชิก** ก็ต่อเมื่อมีอยู่แล้ว:
|
||||
- 9Router ช่วยเพิ่มประสิทธิภาพมูลค่าของพวกเขาผ่านการติดตามโควตา
|
||||
|
||||
**ผลลัพธ์:** ผู้ใช้ส่วนใหญ่สามารถทำงานที่ $0/เดือน โดยใช้เฉพาะชั้นฟรี!
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🐛 การแก้ไขปัญหา
|
||||
|
||||
**"Language model did not provide messages"**
|
||||
- โควตาผู้ให้บริการหมด → ตรวจสอบตัวติดตามโควตาในแดชบอร์ด
|
||||
- วิธีแก้: ใช้ combo fallback หรือสลับไปชั้นที่ถูกกว่า
|
||||
|
||||
**Rate Limiting**
|
||||
- สมาชิกหมดโควตา → Fallback ไป GLM/MiniMax
|
||||
- เพิ่ม combo: `cc/claude-opus-4-6 → glm/glm-5.1 → kr/claude-sonnet-4.5`
|
||||
|
||||
**OAuth Token หมดอายุ**
|
||||
- รีเฟรชอัตโนมัติโดย 9Router
|
||||
- ถ้าปัญหายังคงอยู่: แดชบอร์ด → ผู้ให้บริการ → เชื่อมต่อใหม่
|
||||
|
||||
**ค่าใช้จ่ายสูง**
|
||||
- เปิดใช้ RTK ใน แดชบอร์ด → ตั้งค่า Endpoint (เปิดเป็นค่าเริ่มต้น, ประหยัด 20-40% โทเค็น)
|
||||
- ตรวจสอบสถิติการใช้งานในแดชบอร์ด
|
||||
- สลับโมเดลหลักไป GLM/MiniMax
|
||||
- ใช้ชั้นฟรี (Kiro, OpenCode Free, Vertex) สำหรับงานที่ไม่สำคัญ
|
||||
|
||||
**แดชบอร์ดเปิดผิดพอร์ต**
|
||||
- ตั้ง `PORT=20128` และ `NEXT_PUBLIC_BASE_URL=http://localhost:20128`
|
||||
|
||||
**ล็อกอินครั้งแรกไม่ทำงาน**
|
||||
- ตรวจสอบ `INITIAL_PASSWORD` ใน `.env`
|
||||
- ถ้ายังไม่ตั้งค่า รหัสผ่านสำรองคือ `123456`
|
||||
|
||||
**ไม่มี request log ใต้ `logs/`**
|
||||
- ตั้ง `ENABLE_REQUEST_LOGS=true`
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
- **Runtime**: Node.js 20+
|
||||
- **Framework**: Next.js 16
|
||||
- **UI**: React 19 + Tailwind CSS 4
|
||||
- **Database**: SQLite (better-sqlite3 / node:sqlite / sql.js fallback)
|
||||
- **Streaming**: Server-Sent Events (SSE)
|
||||
- **Auth**: OAuth 2.0 (PKCE) + JWT + API Keys
|
||||
|
||||
---
|
||||
|
||||
## 📝 API Reference
|
||||
|
||||
### Chat Completions
|
||||
|
||||
```bash
|
||||
POST http://localhost:20128/v1/chat/completions
|
||||
Authorization: Bearer your-api-key
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"model": "cc/claude-opus-4-6",
|
||||
"messages": [
|
||||
{"role": "user", "content": "เขียนฟังก์ชันเพื่อ..."}
|
||||
],
|
||||
"stream": true
|
||||
}
|
||||
```
|
||||
|
||||
### List Models
|
||||
|
||||
```bash
|
||||
GET http://localhost:20128/v1/models
|
||||
Authorization: Bearer your-api-key
|
||||
|
||||
→ คืนค่าโมเดลทั้งหมด + combo ในรูปแบบ OpenAI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📧 สนับสนุน
|
||||
|
||||
- **เว็บไซต์**: [9router.com](https://9router.com)
|
||||
- **GitHub**: [github.com/decolua/9router](https://github.com/decolua/9router)
|
||||
- **Issues**: [github.com/decolua/9router/issues](https://github.com/decolua/9router/issues)
|
||||
|
||||
---
|
||||
|
||||
## 👥 ผู้มีส่วนร่วม
|
||||
|
||||
ขอขอบคุณผู้มีส่วนร่วมทุกคนที่ช่วยทำให้ 9Router ดียิ่งขึ้น!
|
||||
|
||||
[](https://github.com/decolua/9router/graphs/contributors)
|
||||
|
||||
---
|
||||
|
||||
## 📄 ลิขสิทธิ์
|
||||
|
||||
MIT License - ดู [LICENSE](../LICENSE) สำหรับรายละเอียด
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub>สร้างด้วย ❤️ สำหรับนักพัฒนาที่เขียนโค้ด 24/7</sub>
|
||||
</div>
|
||||
@@ -1,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.**
|
||||
|
||||
[](https://www.npmjs.com/package/9router)
|
||||
[](https://www.npmjs.com/package/9router)
|
||||
[](https://github.com/decolua/9router/blob/main/LICENSE)
|
||||
[](https://github.com/decolua/9router/blob/main/LICENSE)
|
||||
|
||||
[🚀 Bắt đầu nhanh](#-quick-start) • [💡 Tính năng](#-key-features) • [📖 Cài đặt](#-setup-guide) • [🌐 Website](https://9router.com)
|
||||
</div>
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
@@ -66,6 +75,14 @@ const nextConfig = {
|
||||
source: "/responses",
|
||||
destination: "/api/v1/responses"
|
||||
},
|
||||
{
|
||||
source: "/v1beta/:path*",
|
||||
destination: "/api/v1beta/:path*"
|
||||
},
|
||||
{
|
||||
source: "/v1beta",
|
||||
destination: "/api/v1beta"
|
||||
},
|
||||
{
|
||||
source: "/v1/:path*",
|
||||
destination: "/api/v1/:path*"
|
||||
|
||||
@@ -16,7 +16,7 @@ Provider-agnostic SSE engine: one OpenAI-style request → any provider (LLM cha
|
||||
- `rtk/` — request token-killer. `index.js` compresses `tool_result` content in-place (OpenAI/Claude/Kiro shapes); `filters/` per-tool compressors + `autodetect.js`; `headroom.js` external compress proxy; `caveman.js` system-prompt injector.
|
||||
- `transformer/` — `responsesTransformer.js` (Chat Completions SSE → Codex Responses API SSE), `streamToJsonConverter.js`.
|
||||
- `shared/` — cross-provider auth/identity: `clineAuth.js`, `machineId.js`, `qoder/`.
|
||||
- `services/` — `model.js`, `provider.js`, `accountFallback.js`, `combo.js`, `compact.js`, `tokenRefresh/`+`tokenRefresh.js`, `oauthCredentialManager.js`, `usage/`, `projectId.js`, `kiroModels.js`/`qoderModels.js`.
|
||||
- `services/` — `model.js`, `provider.js`, `accountFallback.js`, `combo.js`, `tokenRefresh/`+`tokenRefresh.js`, `oauthCredentialManager.js`, `usage/`, `projectId.js`, `kiroModels.js`/`qoderModels.js`.
|
||||
- `utils/` — streamHandler, stream, sse, error, sessionManager, claudeCloaking, clientDetector, proxyFetch (patches global fetch), cursorProtobuf/cursorChecksum, ollamaTransform.
|
||||
|
||||
## Conventions
|
||||
@@ -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`.
|
||||
|
||||
@@ -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(),
|
||||
@@ -153,6 +171,13 @@ export const LOAD_CODE_ASSIST_METADATA = {
|
||||
|
||||
// System prompts
|
||||
export const CLAUDE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude.";
|
||||
// Rewrite rules applied to Antigravity system prompts: competing-client branding
|
||||
// makes the backend flag the request and answer 429 Quota Exhausted.
|
||||
export const ANTIGRAVITY_PROMPT_REWRITES = [
|
||||
{ from: "You are a Claude agent, built on Anthropic's Claude Agent SDK.", to: "" },
|
||||
{ from: /opencode/gi, to: (m) => (m === "OpenCode" ? "Antigravity" : m === "OPENCODE" ? "ANTIGRAVITY" : "antigravity") }
|
||||
];
|
||||
|
||||
export const ANTIGRAVITY_DEFAULT_SYSTEM = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**";
|
||||
|
||||
// Derive từ registry oauth.refreshLeadMs
|
||||
@@ -165,17 +190,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,6 +73,17 @@ export const ERROR_RULES = [
|
||||
{ status: 403, cooldownMs: COOLDOWN.long },
|
||||
{ status: 404, cooldownMs: COOLDOWN.long },
|
||||
{ status: 429, backoff: true },
|
||||
// --- Request-scoped errors: the request itself is broken — retrying the same
|
||||
// body on another account/model can never succeed, and locking the account
|
||||
// would punish a healthy credential for our own bad request. Callers use this
|
||||
// to fail fast (no account rotation, no model lock).
|
||||
{ text: "context_length_exceeded", requestScoped: true },
|
||||
{ text: "context window", requestScoped: true },
|
||||
{ text: "maximum context length", requestScoped: true },
|
||||
{ text: "prompt is too long", requestScoped: true },
|
||||
{ text: "input is too long", requestScoped: true },
|
||||
{ text: "max_tokens exceed", requestScoped: true },
|
||||
{ text: "reduce the length", requestScoped: true },
|
||||
];
|
||||
|
||||
// Backward compat: COOLDOWN_MS object (used by index.js re-export)
|
||||
|
||||
10
open-sse/config/grokCli.js
Normal file
10
open-sse/config/grokCli.js
Normal 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 || ""));
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -2,9 +2,9 @@ import { PROVIDERS } from "./providers.js";
|
||||
import REGISTRY from "../providers/registry/index.js";
|
||||
// PROVIDER_MODELS now built from providers/registry (transport + models co-located)
|
||||
import { PROVIDER_MODELS } from "../providers/index.js";
|
||||
import { modelQuotaFamily, modelStrip, modelTargetFormat } from "../providers/models/schema.js";
|
||||
import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js";
|
||||
|
||||
import { modelQuotaFamily, modelStrip, modelTargetFormat, modelSupportedFormats, normalizeModelId } from "../providers/models/schema.js";
|
||||
import { CODEX_REVIEW_SUFFIX, isMuseSparkModel } from "../providers/models/helpers.js";
|
||||
import { FORMATS } from "../translator/formats.js";
|
||||
export { PROVIDER_MODELS };
|
||||
|
||||
|
||||
@@ -18,46 +18,85 @@ 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) {
|
||||
if ((!aliasOrId || aliasOrId === "oc" || aliasOrId === "opencode" || aliasOrId === "ocg" || aliasOrId === "opencode-go") && isMuseSparkModel(modelId)) {
|
||||
return FORMATS.OPENAI_RESPONSES;
|
||||
}
|
||||
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 +118,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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -49,10 +58,15 @@ export const STREAM_FIRST_CHUNK_TIMEOUT_MS = envMs("STREAM_FIRST_CHUNK_TIMEOUT_M
|
||||
// Fetch connect timeout: abort if upstream doesn't return response headers within this duration
|
||||
export const FETCH_CONNECT_TIMEOUT_MS = envMs("FETCH_CONNECT_TIMEOUT_MS", 60 * 1000);
|
||||
|
||||
// Gemini native TTS fetch timeout: abort if Google does not return response headers in time.
|
||||
export const GEMINI_NATIVE_TTS_FETCH_TIMEOUT_MS = envMs("GEMINI_NATIVE_TTS_FETCH_TIMEOUT_MS", 45 * 1000);
|
||||
|
||||
// Default token limits
|
||||
export const DEFAULT_MAX_TOKENS = 64000;
|
||||
export const DEFAULT_MIN_TOKENS = 32000;
|
||||
|
||||
export const TOKEN_SAVER_HEADER = "x-9router-token-saver";
|
||||
|
||||
// Retry config for 429 responses (legacy - kept for backward compatibility)
|
||||
export const RETRY_CONFIG = {
|
||||
maxAttempts: 2,
|
||||
|
||||
@@ -33,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: {
|
||||
@@ -96,15 +111,25 @@ export const TTS_MODELS_CONFIG = {
|
||||
},
|
||||
gemini: {
|
||||
models: [
|
||||
{ id: "gemini-3.1-flash-tts-preview", name: "Gemini 3.1 Flash TTS", type: "tts" },
|
||||
{ id: "gemini-2.5-flash-preview-tts", name: "Gemini 2.5 Flash TTS", type: "tts" },
|
||||
{ id: "gemini-2.5-pro-preview-tts", name: "Gemini 2.5 Pro TTS", type: "tts" },
|
||||
],
|
||||
voices: {
|
||||
"gemini-3.1-flash-tts-preview": GEMINI_VOICES,
|
||||
"gemini-2.5-flash-preview-tts": GEMINI_VOICES,
|
||||
"gemini-2.5-pro-preview-tts": GEMINI_VOICES,
|
||||
},
|
||||
allVoices: GEMINI_VOICES,
|
||||
},
|
||||
"xiaomi-mimo": {
|
||||
models: [
|
||||
{ id: "mimo-v2.5-tts", name: "MiMo V2.5 TTS", type: "tts" },
|
||||
],
|
||||
voices: {
|
||||
"mimo-v2.5-tts": MIMO_VOICES,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ── Helper: get voices for a specific model ────────────────────────────────
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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, ANTIGRAVITY_PROMPT_REWRITES } from "../config/appConstants.js";
|
||||
import { HTTP_STATUS } from "../config/runtimeConfig.js";
|
||||
import { resolveSessionId } from "../utils/sessionManager.js";
|
||||
import { resolveSessionId, toNumericSessionId } 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";
|
||||
import { getGeminiThoughtSignatureSync } from "../services/thoughtSignatureStore.js";
|
||||
|
||||
// Sanitize function name: Gemini requires [a-zA-Z_][a-zA-Z0-9_.:\-]{0,63}
|
||||
function sanitizeFunctionName(name) {
|
||||
@@ -16,7 +18,26 @@ function sanitizeFunctionName(name) {
|
||||
}
|
||||
|
||||
const MAX_RETRY_AFTER_MS = 10000;
|
||||
const MAX_ANTIGRAVITY_OUTPUT_TOKENS = 16384;
|
||||
const ANTIGRAVITY_TRANSIENT_RETRY_MAX_MS = 15000;
|
||||
const MAX_ANTIGRAVITY_OUTPUT_TOKENS = 64000;
|
||||
const ANTIGRAVITY_IDE_REQUEST_ID_RE = /^agent\/[^/]+\/\d+\/[^/]+\/\d+$/;
|
||||
|
||||
const ANTIGRAVITY_TRANSIENT_ERROR_PATTERNS = [
|
||||
/high\s+traffic/i,
|
||||
/agent\s+(execution\s+)?terminated\s+due\s+to\s+error/i,
|
||||
/capacity/i,
|
||||
/temporarily\s+unavailable/i,
|
||||
/timeout/i,
|
||||
/stream\s+(ended|closed|terminated|interrupted)/i,
|
||||
/empty\s+response/i,
|
||||
];
|
||||
|
||||
const ANTIGRAVITY_TRANSIENT_STATUSES = new Set([
|
||||
HTTP_STATUS.SERVER_ERROR,
|
||||
HTTP_STATUS.BAD_GATEWAY,
|
||||
HTTP_STATUS.SERVICE_UNAVAILABLE,
|
||||
HTTP_STATUS.GATEWAY_TIMEOUT,
|
||||
]);
|
||||
|
||||
// Fields Google generateContent rejects (Claude/OpenAI/Qwen thinking fields set at body root by thinkingUnified.js)
|
||||
const ANTIGRAVITY_REQUEST_BLACKLIST = [
|
||||
@@ -68,6 +89,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);
|
||||
@@ -85,20 +127,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);
|
||||
@@ -123,28 +165,32 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
const rawSessionId = body.request?.sessionId || resolveSessionId({ headers: credentials?.rawHeaders, body, connectionId: credentials?.email || credentials?.connectionId, scope: "antigravity" });
|
||||
const sessionId = toNumericSessionId(rawSessionId) || rawSessionId;
|
||||
|
||||
// ─── Standard (non-image) request ───
|
||||
// Fix contents for Claude models via Antigravity
|
||||
const contents = body.request?.contents?.map(c => {
|
||||
@@ -159,8 +205,33 @@ 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 from cache or default signature.
|
||||
// In parallel function calls, only the first call needs a signature; siblings stay unsigned.
|
||||
let firstFunctionCallSeen = false;
|
||||
const modifiedParts = parts?.map(p => {
|
||||
if (!p.functionCall) return p;
|
||||
const callId = p.functionCall.id;
|
||||
const cachedSig = callId ? getGeminiThoughtSignatureSync(callId, sessionId) : null;
|
||||
const callSig = p.thoughtSignature || cachedSig || (!firstFunctionCallSeen ? DEFAULT_THINKING_AG_SIGNATURE : undefined);
|
||||
firstFunctionCallSeen = true;
|
||||
if (callSig) {
|
||||
return { ...p, thoughtSignature: callSig };
|
||||
}
|
||||
if (p.thoughtSignature && !cachedSig) {
|
||||
// Unsigned sibling call
|
||||
const { thoughtSignature: _, ...rest } = p;
|
||||
return rest;
|
||||
}
|
||||
return p;
|
||||
});
|
||||
|
||||
const partsChanged = parts?.length !== c.parts?.length || modifiedParts?.some((p, idx) => p !== c.parts[idx]);
|
||||
if (role !== c.role || partsChanged) {
|
||||
return {
|
||||
...c, role,
|
||||
parts: modifiedParts || parts,
|
||||
};
|
||||
}
|
||||
return c;
|
||||
});
|
||||
@@ -170,21 +241,40 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
|
||||
if (tools && tools.length > 0) {
|
||||
// Merge all groups into a single functionDeclarations group (Gemini expects 1 group)
|
||||
const allDeclarations = tools.flatMap(group =>
|
||||
(group.functionDeclarations || []).map(fn => ({
|
||||
...fn,
|
||||
name: sanitizeFunctionName(fn.name),
|
||||
parameters: fn.parameters
|
||||
? cleanJSONSchemaForAntigravity(structuredClone(fn.parameters))
|
||||
: { type: "object", properties: { reason: { type: "string", description: "Brief explanation" } }, required: ["reason"] }
|
||||
}))
|
||||
);
|
||||
const seenToolNames = new Set();
|
||||
const allDeclarations = [];
|
||||
for (const group of tools) {
|
||||
for (const fn of group.functionDeclarations || []) {
|
||||
const name = sanitizeFunctionName(fn.name);
|
||||
if (seenToolNames.has(name)) continue;
|
||||
seenToolNames.add(name);
|
||||
allDeclarations.push({
|
||||
...fn,
|
||||
name,
|
||||
parameters: fn.parameters
|
||||
? cleanJSONSchemaForAntigravity(structuredClone(fn.parameters))
|
||||
: { type: "object", properties: { reason: { type: "string", description: "Brief explanation" } }, required: ["reason"] }
|
||||
});
|
||||
}
|
||||
}
|
||||
tools = allDeclarations.length > 0 ? [{ functionDeclarations: allDeclarations }] : [];
|
||||
}
|
||||
|
||||
// Strip tools/toolConfig (handled separately) and blacklisted fields that Google rejects
|
||||
const { tools: _originalTools, toolConfig: _originalToolConfig, ...requestWithoutTools } = body.request || {};
|
||||
stripBlacklisted(requestWithoutTools);
|
||||
|
||||
// Rewrite competing-client branding in system prompts (e.g. Zed's Claude prompt,
|
||||
// OpenCode naming) so Antigravity doesn't flag the request with a 429 Quota Exhausted.
|
||||
if (requestWithoutTools.systemInstruction?.parts) {
|
||||
for (const part of requestWithoutTools.systemInstruction.parts) {
|
||||
if (typeof part.text !== "string") continue;
|
||||
for (const { from, to } of ANTIGRAVITY_PROMPT_REWRITES) {
|
||||
part.text = part.text.replaceAll(from, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const generationConfig = { ...(requestWithoutTools.generationConfig || {}) };
|
||||
if (generationConfig.maxOutputTokens > MAX_ANTIGRAVITY_OUTPUT_TOKENS) {
|
||||
generationConfig.maxOutputTokens = MAX_ANTIGRAVITY_OUTPUT_TOKENS;
|
||||
@@ -195,7 +285,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
generationConfig,
|
||||
...(contents && { contents }),
|
||||
...(tools && { tools }),
|
||||
sessionId: body.request?.sessionId || resolveSessionId({ headers: credentials?.rawHeaders, body, connectionId: credentials?.email || credentials?.connectionId, scope: "antigravity" }),
|
||||
sessionId,
|
||||
safetySettings: undefined,
|
||||
...(tools?.length > 0 && { toolConfig: { functionCallingConfig: { mode: "VALIDATED" } } })
|
||||
};
|
||||
@@ -208,10 +298,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
|
||||
};
|
||||
}
|
||||
@@ -305,23 +395,49 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
return totalMs > 0 ? totalMs : null;
|
||||
}
|
||||
|
||||
extractErrorMessage(errorJson, bodyText = "") {
|
||||
return [
|
||||
errorJson?.error?.message,
|
||||
errorJson?.message,
|
||||
errorJson?.error,
|
||||
bodyText,
|
||||
].filter(Boolean).map(v => typeof v === "string" ? v : JSON.stringify(v)).join("\n");
|
||||
}
|
||||
|
||||
isTransientAntigravityError(status, message) {
|
||||
if (status === HTTP_STATUS.RATE_LIMITED) return true;
|
||||
if (ANTIGRAVITY_TRANSIENT_STATUSES.has(status)) return true;
|
||||
return ANTIGRAVITY_TRANSIENT_ERROR_PATTERNS.some(pattern => pattern.test(message || ""));
|
||||
}
|
||||
|
||||
// Hook called by BaseExecutor.tryRetry: derive delay from Retry-After (header → body),
|
||||
// cap at MAX_RETRY_AFTER_MS, else exponential backoff for 429. Return false to veto (fallback URL).
|
||||
// cap at MAX_RETRY_AFTER_MS, else retry transient Antigravity failures with backoff.
|
||||
// Return false to veto (fallback URL / final error).
|
||||
async computeRetryDelay(response, attempt) {
|
||||
let bodyText = "";
|
||||
let errorJson = null;
|
||||
let retryMs = this.parseRetryHeaders(response.headers);
|
||||
|
||||
try {
|
||||
bodyText = await response.clone().text();
|
||||
errorJson = bodyText ? JSON.parse(bodyText) : null;
|
||||
} catch {
|
||||
// ignore parse errors → fall through to status/message based retry
|
||||
}
|
||||
|
||||
const errorMessage = this.extractErrorMessage(errorJson, bodyText);
|
||||
|
||||
if (!retryMs) {
|
||||
try {
|
||||
const errorJson = JSON.parse(await response.clone().text());
|
||||
retryMs = this.parseRetryFromErrorMessage(errorJson?.error?.message || errorJson?.message || "");
|
||||
} catch {
|
||||
// ignore parse errors → fall through to backoff
|
||||
}
|
||||
retryMs = this.parseRetryFromErrorMessage(errorMessage);
|
||||
}
|
||||
if (retryMs) return retryMs <= MAX_RETRY_AFTER_MS ? retryMs : false;
|
||||
if (response.status === HTTP_STATUS.RATE_LIMITED) {
|
||||
return Math.min(1000 * (2 ** attempt), MAX_RETRY_AFTER_MS); // exponential backoff
|
||||
}
|
||||
return false;
|
||||
|
||||
if (!this.isTransientAntigravityError(response.status, errorMessage)) return false;
|
||||
|
||||
const cap = response.status === HTTP_STATUS.RATE_LIMITED
|
||||
? MAX_RETRY_AFTER_MS
|
||||
: ANTIGRAVITY_TRANSIENT_RETRY_MAX_MS;
|
||||
return Math.min(1000 * (2 ** attempt), cap); // exponential backoff
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -25,10 +54,14 @@ export class CodeBuddyExecutor extends DefaultExecutor {
|
||||
const eff = transformed.reasoning_effort;
|
||||
if (eff === "none" || eff === "off") {
|
||||
delete transformed.reasoning_effort; // gateway has no "none" — just omit
|
||||
} else {
|
||||
if (!eff) transformed.reasoning_effort = "medium";
|
||||
} else if (eff) {
|
||||
// Client explicitly asked for reasoning — mirror the CLI's reasoning_summary
|
||||
// so CodeBuddy surfaces the model's reasoning.
|
||||
transformed.reasoning_summary = "auto";
|
||||
}
|
||||
// No reasoning requested: leave both unset. Forcing reasoning_effort:"medium"
|
||||
// + reasoning_summary on plain requests makes CodeBuddy trip its content
|
||||
// filter and return an error (#2071).
|
||||
return transformed;
|
||||
}
|
||||
}
|
||||
|
||||
44
open-sse/executors/codebuddy-intl.js
Normal file
44
open-sse/executors/codebuddy-intl.js
Normal 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;
|
||||
@@ -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)_/;
|
||||
@@ -32,7 +41,8 @@ const CODEX_PASSTHROUGH_TOOL_TYPES = new Set(["custom"]);
|
||||
// Allowlist of fields accepted by Codex Responses API — anything else is stripped
|
||||
const RESPONSES_API_ALLOWLIST = new Set([
|
||||
"model", "input", "instructions", "tools", "tool_choice", "stream", "store",
|
||||
"reasoning", "service_tier", "include", "prompt_cache_key", "client_metadata"
|
||||
"reasoning", "service_tier", "include", "prompt_cache_key", "client_metadata",
|
||||
"text"
|
||||
]);
|
||||
|
||||
// Convert role=system → role=developer in body.input (keeps content in cacheable prefix)
|
||||
@@ -115,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
|
||||
@@ -134,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;
|
||||
}
|
||||
@@ -197,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) {
|
||||
@@ -209,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
|
||||
@@ -272,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
|
||||
@@ -346,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}`)) {
|
||||
@@ -359,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;
|
||||
|
||||
@@ -390,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];
|
||||
|
||||
@@ -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,53 +15,282 @@ 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);
|
||||
result.response = await inspectAndWrapCommandCodeResponse(result.response, opts.model);
|
||||
return result;
|
||||
}
|
||||
|
||||
parseError(response, bodyText) {
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = JSON.parse(bodyText || "{}");
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
const errObj = parsed?.error || parsed;
|
||||
const msg = errObj?.message || parsed?.message || bodyText || response.statusText;
|
||||
const status = Number(errObj?.code || errObj?.statusCode || response.status) || response.status;
|
||||
return {
|
||||
status,
|
||||
message: msg || `CommandCode upstream error: ${response.status}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function wrapNdjsonAsOpenAISse(originalResponse, model) {
|
||||
export function parseCommandCodeError(event) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return {
|
||||
statusCode: 503,
|
||||
message: "CommandCode upstream error",
|
||||
type: "server_error",
|
||||
};
|
||||
}
|
||||
|
||||
const errVal = event.error ?? event.message ?? "unknown";
|
||||
let message = "";
|
||||
let statusCode = null;
|
||||
let type = "server_error";
|
||||
|
||||
if (typeof errVal === "object" && errVal !== null) {
|
||||
message = errVal.message || errVal.error || JSON.stringify(errVal);
|
||||
if (errVal.statusCode && Number.isInteger(Number(errVal.statusCode))) {
|
||||
statusCode = Number(errVal.statusCode);
|
||||
} else if (errVal.status && Number.isInteger(Number(errVal.status))) {
|
||||
statusCode = Number(errVal.status);
|
||||
}
|
||||
if (errVal.type) type = errVal.type;
|
||||
} else if (typeof errVal === "string") {
|
||||
message = errVal;
|
||||
} else {
|
||||
message = JSON.stringify(errVal);
|
||||
}
|
||||
|
||||
if (event.statusCode && Number.isInteger(Number(event.statusCode))) {
|
||||
statusCode = Number(event.statusCode);
|
||||
}
|
||||
|
||||
if (!statusCode || statusCode < 400 || statusCode > 599) {
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("rate limit") || lower.includes("too many requests")) {
|
||||
statusCode = 429;
|
||||
type = "rate_limit_error";
|
||||
} else if (lower.includes("unauthorized") || lower.includes("invalid api key") || lower.includes("authentication")) {
|
||||
statusCode = 401;
|
||||
type = "authentication_error";
|
||||
} else if (lower.includes("payment required") || lower.includes("billing")) {
|
||||
statusCode = 402;
|
||||
type = "billing_error";
|
||||
} else if (lower.includes("quota") || lower.includes("forbidden") || lower.includes("permission")) {
|
||||
statusCode = 403;
|
||||
type = "permission_error";
|
||||
} else if (lower.includes("not found")) {
|
||||
statusCode = 404;
|
||||
type = "invalid_request_error";
|
||||
} else if (lower.includes("unavailable") || lower.includes("overloaded") || lower.includes("server error")) {
|
||||
statusCode = 503;
|
||||
type = "server_error";
|
||||
} else {
|
||||
statusCode = 503;
|
||||
}
|
||||
}
|
||||
|
||||
return { statusCode, message, type };
|
||||
}
|
||||
|
||||
export async function inspectAndWrapCommandCodeResponse(originalResponse, model) {
|
||||
const reader = originalResponse.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const bufferedLines = [];
|
||||
let detectedError = null;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
const trimmed = buffer.trim();
|
||||
if (trimmed) {
|
||||
try {
|
||||
const jsonStr = trimmed.startsWith("data:") ? trimmed.slice(5).trim() : trimmed;
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
if (parsed?.type === "error") {
|
||||
detectedError = parsed;
|
||||
} else {
|
||||
bufferedLines.push(trimmed);
|
||||
}
|
||||
} catch {
|
||||
bufferedLines.push(trimmed);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
let stopLoop = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const jsonStr = trimmed.startsWith("data:") ? trimmed.slice(5).trim() : trimmed;
|
||||
if (!jsonStr || jsonStr === "[DONE]") {
|
||||
bufferedLines.push(trimmed);
|
||||
stopLoop = true;
|
||||
break;
|
||||
}
|
||||
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(jsonStr);
|
||||
} catch {
|
||||
bufferedLines.push(trimmed);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event?.type === "error") {
|
||||
detectedError = event;
|
||||
stopLoop = true;
|
||||
break;
|
||||
}
|
||||
|
||||
bufferedLines.push(trimmed);
|
||||
|
||||
if (
|
||||
event?.type === "text-delta" ||
|
||||
event?.type === "reasoning-delta" ||
|
||||
event?.type === "tool-input-start" ||
|
||||
event?.type === "tool-call" ||
|
||||
event?.type === "finish" ||
|
||||
event?.type === "finish-step"
|
||||
) {
|
||||
stopLoop = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (stopLoop) break;
|
||||
}
|
||||
} catch {
|
||||
try { reader.releaseLock(); } catch { /* ignore */ }
|
||||
return originalResponse;
|
||||
}
|
||||
|
||||
if (detectedError) {
|
||||
try { await reader.cancel(); } catch { /* ignore */ }
|
||||
const { statusCode, message, type } = parseCommandCodeError(detectedError);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: `[CommandCode error: ${message}]`,
|
||||
type,
|
||||
code: statusCode,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: statusCode,
|
||||
statusText: statusCode === 503 ? "Service Unavailable" : (statusCode === 429 ? "Too Many Requests" : "Bad Gateway"),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const combinedStream = createReplayedStream(bufferedLines, buffer, reader);
|
||||
return wrapNdjsonAsOpenAISse(combinedStream, model, originalResponse);
|
||||
}
|
||||
|
||||
function createReplayedStream(bufferedLines, remainingBuffer, reader) {
|
||||
const encoder = new TextEncoder();
|
||||
let replayed = false;
|
||||
|
||||
return new ReadableStream({
|
||||
async pull(controller) {
|
||||
if (!replayed) {
|
||||
replayed = true;
|
||||
let prefix = bufferedLines.join("\n");
|
||||
if (prefix && remainingBuffer) {
|
||||
prefix += "\n" + remainingBuffer;
|
||||
} else if (remainingBuffer) {
|
||||
prefix = remainingBuffer;
|
||||
} else if (prefix) {
|
||||
prefix += "\n";
|
||||
}
|
||||
if (prefix) {
|
||||
controller.enqueue(encoder.encode(prefix));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
controller.close();
|
||||
} else {
|
||||
controller.enqueue(value);
|
||||
}
|
||||
} catch (err) {
|
||||
controller.error(err);
|
||||
}
|
||||
},
|
||||
async cancel(reason) {
|
||||
try {
|
||||
await reader.cancel(reason);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function wrapNdjsonAsOpenAISse(streamBody, model, originalResponse = null) {
|
||||
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) {
|
||||
@@ -70,7 +300,6 @@ function wrapNdjsonAsOpenAISse(originalResponse, model) {
|
||||
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);
|
||||
}
|
||||
},
|
||||
@@ -83,11 +312,17 @@ function wrapNdjsonAsOpenAISse(originalResponse, model) {
|
||||
},
|
||||
});
|
||||
|
||||
const newBody = originalResponse.body.pipeThrough(transform);
|
||||
const newBody = streamBody.pipeThrough(transform);
|
||||
return new Response(newBody, {
|
||||
status: originalResponse.status,
|
||||
statusText: originalResponse.statusText,
|
||||
headers: originalResponse.headers,
|
||||
status: originalResponse?.status || 200,
|
||||
statusText: originalResponse?.statusText || "OK",
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
...(originalResponse?.headers ? Object.fromEntries(originalResponse.headers.entries()) : {}),
|
||||
"content-type": "text/event-stream",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,29 @@ 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);
|
||||
|
||||
// anthropic-compatible-* nodes serving a real Claude model sit in front of
|
||||
// Anthropic itself (a rotating multi-account proxy, a corporate gateway),
|
||||
// so the request needs the same beta flags the `claude` provider sends:
|
||||
// without `context-management-2025-06-27` upstream rejects the
|
||||
// `context_management` block Claude Code puts in every request with
|
||||
// "context_management: Extra inputs are not permitted" (HTTP 400), and the
|
||||
// combo silently falls through to the next model. The model id gates this:
|
||||
// a node fronting Kimi or GLM answers on its own ids and never matches, so
|
||||
// gateways that would choke on unknown beta flags are left untouched.
|
||||
const isClaudeModel = typeof model === "string" && /^claude-/.test(model);
|
||||
if (model && (this.provider === "claude"
|
||||
|| (this.provider?.startsWith?.("anthropic-compatible-") && isClaudeModel))) {
|
||||
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 +222,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 +301,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();
|
||||
|
||||
847
open-sse/executors/devin-cli.js
Normal file
847
open-sse/executors/devin-cli.js
Normal 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;
|
||||
@@ -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", {
|
||||
|
||||
552
open-sse/executors/grok-cli.js
Normal file
552
open-sse/executors/grok-cli.js
Normal 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;
|
||||
@@ -5,20 +5,26 @@ 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 +34,18 @@ 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 +53,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 +80,23 @@ 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";
|
||||
|
||||
123
open-sse/executors/kimchi.js
Normal file
123
open-sse/executors/kimchi.js
Normal 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
@@ -1,49 +1,182 @@
|
||||
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";
|
||||
import crypto from "node:crypto";
|
||||
import { DefaultExecutor } from "./default.js";
|
||||
import { resolveSessionId } from "../utils/sessionManager.js";
|
||||
import { isMuseSparkModel } from "../providers/models/helpers.js";
|
||||
import {
|
||||
normalizeResponsesInput,
|
||||
clampResponsesCallId,
|
||||
coerceResponsesArguments,
|
||||
coerceResponsesOutput,
|
||||
} from "../translator/formats/responsesApi.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 SESSION_HEADER = "x-opencode-session";
|
||||
const SESSION_FIELD = "_opencodeGoSession";
|
||||
const MAX_SESSION_LENGTH = 256;
|
||||
|
||||
const BASE = "https://opencode.ai/zen/go/v1";
|
||||
const RESPONSES_BASE_URL = "https://opencode.ai/zen/go/v1/responses";
|
||||
const MAX_TOOL_NAME_LEN = 128;
|
||||
|
||||
export class OpenCodeGoExecutor extends BaseExecutor {
|
||||
function normalizeSession(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized.length > MAX_SESSION_LENGTH) return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function nativeSession(headers) {
|
||||
if (!headers || typeof headers !== "object") return null;
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.toLowerCase() === SESSION_HEADER) return normalizeSession(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function translatedSession(sessionId, clientTool) {
|
||||
const digest = crypto
|
||||
.createHash("sha256")
|
||||
.update(`opencode-go\0${clientTool || "generic"}\0${sessionId}`)
|
||||
.digest("hex")
|
||||
.slice(0, 32);
|
||||
return `ses_${digest}`;
|
||||
}
|
||||
|
||||
// Strip the thinking suffix "model(level)" so checks hit the base id.
|
||||
function baseModelId(model) {
|
||||
return String(model || "").replace(/\([^()]+\)\s*$/, "").trim();
|
||||
}
|
||||
|
||||
function isResponsesModel(model) {
|
||||
return isMuseSparkModel(baseModelId(model));
|
||||
}
|
||||
|
||||
// Flatten Chat Completions tool declarations into the Responses flat shape and
|
||||
// drop hosted/nameless tools the /responses endpoint rejects.
|
||||
function normalizeResponsesTools(body) {
|
||||
if (!Array.isArray(body.tools)) return;
|
||||
const validNames = new Set();
|
||||
body.tools = body.tools.filter((tool) => {
|
||||
if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false;
|
||||
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 : "");
|
||||
let 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: {} });
|
||||
// Mirror the request translator: {type:"object"} without properties is rejected
|
||||
// by strict Responses backends, so fill in the empty properties map.
|
||||
if (parameters.type === "object" && !parameters.properties) parameters = { ...parameters, properties: {} };
|
||||
for (const k of Object.keys(tool)) delete tool[k];
|
||||
tool.type = "function";
|
||||
tool.name = name.slice(0, MAX_TOOL_NAME_LEN);
|
||||
if (description) tool.description = description;
|
||||
tool.parameters = parameters;
|
||||
validNames.add(tool.name);
|
||||
return true;
|
||||
});
|
||||
if (body.tool_choice && typeof body.tool_choice === "object" && !Array.isArray(body.tool_choice)) {
|
||||
if (body.tool_choice.type === "function") {
|
||||
const n = typeof body.tool_choice.name === "string" ? body.tool_choice.name.trim() : "";
|
||||
if (!n || !validNames.has(n)) delete body.tool_choice;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last line of defense for native Responses clients (sourceFormat === targetFormat
|
||||
// skips translation): coerce items in place so malformed tool payloads 400 here
|
||||
// with a clear shape instead of upstream as InputValidationError.
|
||||
function sanitizeResponsesItems(body) {
|
||||
if (!Array.isArray(body.input)) return;
|
||||
body.input = body.input.filter((item) => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) return true;
|
||||
if (item.type === "function_call") {
|
||||
if (!item.name || typeof item.name !== "string" || item.name.trim() === "") return false;
|
||||
item.name = item.name.trim().slice(0, MAX_TOOL_NAME_LEN);
|
||||
item.call_id = clampResponsesCallId(item.call_id);
|
||||
item.arguments = coerceResponsesArguments(item.arguments);
|
||||
return true;
|
||||
}
|
||||
if (item.type === "function_call_output") {
|
||||
item.call_id = clampResponsesCallId(item.call_id);
|
||||
item.output = coerceResponsesOutput(item.output);
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export class OpenCodeGoExecutor extends DefaultExecutor {
|
||||
constructor() {
|
||||
super("opencode-go", PROVIDERS["opencode-go"]);
|
||||
super("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`;
|
||||
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
||||
// Muse Spark lives on /responses even when a stale runtimeTransport leaks in.
|
||||
if (isResponsesModel(model)) return RESPONSES_BASE_URL;
|
||||
return super.buildUrl(model, stream, urlIndex, credentials);
|
||||
}
|
||||
|
||||
buildHeaders(credentials, stream = true) {
|
||||
const key = credentials?.apiKey || credentials?.accessToken;
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
prepareRequestCredentials({ body, credentials, providerSessionId, clientTool } = {}) {
|
||||
const sourceCredentials = credentials || {};
|
||||
const native = nativeSession(sourceCredentials.rawHeaders);
|
||||
const resolved = normalizeSession(providerSessionId) || resolveSessionId({
|
||||
headers: sourceCredentials.rawHeaders,
|
||||
body,
|
||||
connectionId: sourceCredentials.connectionId,
|
||||
scope: "opencode-go",
|
||||
});
|
||||
|
||||
if (MESSAGES_FORMAT_MODELS.has(this._lastModel)) {
|
||||
headers["x-api-key"] = key;
|
||||
headers["anthropic-version"] = ANTHROPIC_API_VERSION;
|
||||
} else {
|
||||
headers["Authorization"] = `Bearer ${key}`;
|
||||
return {
|
||||
...sourceCredentials,
|
||||
[SESSION_FIELD]: native || translatedSession(resolved, clientTool),
|
||||
};
|
||||
}
|
||||
|
||||
async execute(args) {
|
||||
const credentials = this.prepareRequestCredentials(args);
|
||||
return super.execute({ ...args, credentials });
|
||||
}
|
||||
|
||||
buildHeaders(credentials, stream = true, url, model) {
|
||||
const headers = super.buildHeaders(credentials || {}, stream, url, model);
|
||||
const prepared = credentials?.[SESSION_FIELD];
|
||||
if (prepared) {
|
||||
headers[SESSION_HEADER] = prepared;
|
||||
return headers;
|
||||
}
|
||||
|
||||
if (stream) headers["Accept"] = "text/event-stream";
|
||||
const fallback = this.prepareRequestCredentials({ credentials });
|
||||
headers[SESSION_HEADER] = fallback[SESSION_FIELD];
|
||||
return headers;
|
||||
}
|
||||
|
||||
transformRequest(model, body) {
|
||||
return injectReasoningContent({ provider: this.provider, model, body });
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
const out = super.transformRequest(model, body);
|
||||
if (!isResponsesModel(model || body?.model)) return out;
|
||||
const normalized = normalizeResponsesInput(out.input);
|
||||
if (normalized) out.input = normalized;
|
||||
if (!Array.isArray(out.input) || out.input.length === 0) {
|
||||
out.input = [{ type: "message", role: "user", content: [{ type: "input_text", text: "..." }] }];
|
||||
}
|
||||
// Responses names the output cap max_output_tokens, not max_tokens.
|
||||
if (out.max_output_tokens === undefined) {
|
||||
if (out.max_completion_tokens !== undefined) out.max_output_tokens = out.max_completion_tokens;
|
||||
else if (out.max_tokens !== undefined) out.max_output_tokens = out.max_tokens;
|
||||
}
|
||||
delete out.max_tokens;
|
||||
delete out.max_completion_tokens;
|
||||
if (out.reasoning_effort !== undefined && out.reasoning === undefined) {
|
||||
out.reasoning = { effort: out.reasoning_effort, summary: "auto" };
|
||||
}
|
||||
if (out.reasoning && typeof out.reasoning === "object" && !Array.isArray(out.reasoning)) {
|
||||
if (!out.reasoning.summary) out.reasoning.summary = "auto";
|
||||
}
|
||||
delete out.reasoning_effort;
|
||||
out.stream = true;
|
||||
out.store = false;
|
||||
normalizeResponsesTools(out);
|
||||
sanitizeResponsesItems(out);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,116 @@
|
||||
import crypto from "crypto";
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { getThinkingLevels } from "../providers/thinkingLevels.js";
|
||||
import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
|
||||
import { resolveSessionId } from "../utils/sessionManager.js";
|
||||
import { isMuseSparkModel } from "../providers/models/helpers.js";
|
||||
|
||||
// Models that use /zen/v1/messages (claude format)
|
||||
const MESSAGES_MODELS = new Set();
|
||||
const OPENCODE_UA = "opencode";
|
||||
// Models served by /zen/v1/responses; every other model stays on /chat/completions.
|
||||
const RESPONSES_MODELS = new Set([
|
||||
"muse-spark-1.2-contributor-free",
|
||||
"muse-spark-1.3-contributor-free",
|
||||
]);
|
||||
|
||||
function generateRequestId() {
|
||||
return `msg_${crypto.randomUUID().replace(/-/g, "")}`;
|
||||
}
|
||||
|
||||
function generateSessionId() {
|
||||
return `ses_${crypto.randomUUID().replace(/-/g, "")}`;
|
||||
}
|
||||
|
||||
// Strip the thinking suffix "model(level)" so registry lookups hit the base id.
|
||||
function baseModelId(model) {
|
||||
return String(model || "").replace(/\([^()]+\)\s*$/, "").trim();
|
||||
}
|
||||
|
||||
function isResponsesModel(model) {
|
||||
const base = baseModelId(model);
|
||||
return RESPONSES_MODELS.has(base) || isMuseSparkModel(base);
|
||||
}
|
||||
|
||||
function resolveOpencodeSession(body, credentials) {
|
||||
const headers = credentials?.rawHeaders || {};
|
||||
return resolveSessionId({
|
||||
headers,
|
||||
body,
|
||||
connectionId: credentials?.connectionId,
|
||||
scope: "opencode",
|
||||
generate: generateSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeOpencodeReasoning(model, body) {
|
||||
const current = body.reasoning;
|
||||
const currentReasoning = current && typeof current === "object" && !Array.isArray(current)
|
||||
? current
|
||||
: null;
|
||||
const requestedEffort = typeof body.reasoning_effort === "string"
|
||||
? body.reasoning_effort
|
||||
: currentReasoning?.effort;
|
||||
if (typeof requestedEffort !== "string") return;
|
||||
|
||||
const cleanModel = baseModelId(model || body.model);
|
||||
const supportedLevels = getThinkingLevels("opencode", cleanModel);
|
||||
let effort = requestedEffort.toLowerCase().trim();
|
||||
if ((effort === "max" || effort === "ultra") && supportedLevels?.length && !supportedLevels.includes(effort)) {
|
||||
if (effort === "ultra" && supportedLevels.includes("max")) effort = "max";
|
||||
else if (supportedLevels.includes("xhigh")) effort = "xhigh";
|
||||
}
|
||||
|
||||
body.reasoning = { ...currentReasoning, effort };
|
||||
if (!body.reasoning.summary) body.reasoning.summary = "auto";
|
||||
delete body.reasoning_effort;
|
||||
}
|
||||
|
||||
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);
|
||||
if (isResponsesModel(model)) {
|
||||
// Responses API names the output cap max_output_tokens and takes thinking
|
||||
// as reasoning:{effort,summary} — normalize the Chat fields at this boundary.
|
||||
if (body.max_output_tokens === undefined) {
|
||||
if (body.max_completion_tokens !== undefined) body.max_output_tokens = body.max_completion_tokens;
|
||||
else if (body.max_tokens !== undefined) body.max_output_tokens = body.max_tokens;
|
||||
}
|
||||
delete body.max_tokens;
|
||||
delete body.max_completion_tokens;
|
||||
normalizeOpencodeReasoning(model, body);
|
||||
}
|
||||
return injectReasoningContent({ provider: this.provider, model, body });
|
||||
}
|
||||
|
||||
buildUrl(model) {
|
||||
const base = this.config.baseUrl;
|
||||
return MESSAGES_MODELS.has(model)
|
||||
? `${base}/zen/v1/messages`
|
||||
return isResponsesModel(model)
|
||||
? `${base}/zen/v1/responses`
|
||||
: `${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" : "*/*",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,15 +30,21 @@ 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";
|
||||
import { OPENAI_BLOCK, CLAUDE_BLOCK } from "../translator/schema/blocks.js";
|
||||
import { encodeDataUri } from "../translator/concerns/image.js";
|
||||
|
||||
/**
|
||||
* Hoist role:"system" messages out of the messages array (Qoder rejects
|
||||
* system in messages) and flatten any multipart content arrays.
|
||||
* system in messages) and flatten multipart content arrays — EXCEPT image
|
||||
* blocks, which are preserved (see normalizeContent).
|
||||
*/
|
||||
function normalizeMessages(messages) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
@@ -48,18 +54,72 @@ function normalizeMessages(messages) {
|
||||
const out = [];
|
||||
for (const msg of messages) {
|
||||
if (!msg || typeof msg !== "object") continue;
|
||||
const text = extractText(msg.content);
|
||||
if (msg.role === "system") {
|
||||
const text = extractText(msg.content);
|
||||
if (text) systemParts.push(text);
|
||||
continue;
|
||||
}
|
||||
const cloned = { ...msg };
|
||||
cloned.content = text;
|
||||
cloned.content = normalizeContent(msg.content);
|
||||
out.push(cloned);
|
||||
}
|
||||
return { messages: out, systemText: systemParts.join("\n\n") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize one message's content for Qoder.
|
||||
*
|
||||
* Text-only content is flattened to a plain string (Qoder's historical
|
||||
* shape). When images are present the content stays an array and image
|
||||
* blocks are kept as OpenAI-style `image_url` parts — verified against the
|
||||
* upstream: it accepts both http(s) URLs and inline base64 data: URIs
|
||||
* directly, no pre-upload to the /image/upload OSS flow required (that is
|
||||
* a qodercli client-side choice, not a protocol requirement). The legacy
|
||||
* top-level `image_urls` / `chat_context.imageUrls` slots stay null —
|
||||
* qodercli leaves them null too.
|
||||
*
|
||||
* Claude-style `{type:"image", source:{...}}` blocks are converted to
|
||||
* `image_url` so claude-format clients also round-trip.
|
||||
*/
|
||||
function normalizeContent(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (content == null) return "";
|
||||
if (!Array.isArray(content)) return String(content);
|
||||
|
||||
const blocks = [];
|
||||
const textParts = [];
|
||||
let hasImage = false;
|
||||
for (const item of content) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
if (item.type === OPENAI_BLOCK.IMAGE_URL && typeof item.image_url?.url === "string" && item.image_url.url) {
|
||||
blocks.push({ type: OPENAI_BLOCK.IMAGE_URL, image_url: { url: item.image_url.url } });
|
||||
hasImage = true;
|
||||
} else if (item.type === CLAUDE_BLOCK.IMAGE && item.source) {
|
||||
// Claude base64/url image → OpenAI image_url equivalent.
|
||||
const src = item.source;
|
||||
const url = src.type === "base64" && src.data
|
||||
? encodeDataUri(src.media_type || "image/png", src.data)
|
||||
: typeof src.url === "string" && src.url ? src.url : null;
|
||||
if (url) {
|
||||
blocks.push({ type: OPENAI_BLOCK.IMAGE_URL, image_url: { url } });
|
||||
hasImage = true;
|
||||
}
|
||||
} else if (typeof item.text === "string" && item.text) {
|
||||
if (hasImage || blocks.length) {
|
||||
// Keep ordering faithful once images are in play.
|
||||
blocks.push({ type: OPENAI_BLOCK.TEXT, text: item.text });
|
||||
} else {
|
||||
textParts.push(item.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasImage) return textParts.join("\n");
|
||||
// Prepend any text collected before the first image block.
|
||||
if (textParts.length) blocks.unshift({ type: OPENAI_BLOCK.TEXT, text: textParts.join("\n") });
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function extractText(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (content == null) return "";
|
||||
@@ -82,9 +142,9 @@ function extractText(content) {
|
||||
function lastUserText(messages) {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i];
|
||||
if (m?.role === "user" && typeof m.content === "string") {
|
||||
return m.content;
|
||||
}
|
||||
if (m?.role !== "user") continue;
|
||||
if (typeof m.content === "string") return m.content;
|
||||
if (Array.isArray(m.content)) return extractText(m.content);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -108,6 +168,11 @@ function stableChatRecordId(model, messages, tools, maxTokens) {
|
||||
if (m.role) { h.update("\0"); h.update(m.role); }
|
||||
if (typeof m.content === "string" && m.content) {
|
||||
h.update("\0"); h.update(m.content);
|
||||
} else if (Array.isArray(m.content)) {
|
||||
// Include image refs so the same prompt with a different image gets
|
||||
// a distinct chat_record_id.
|
||||
h.update("\0");
|
||||
try { h.update(JSON.stringify(m.content)); } catch {}
|
||||
}
|
||||
}
|
||||
if (tools) {
|
||||
@@ -213,6 +278,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 +331,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 +404,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 +493,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 +509,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 +599,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 +620,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 +644,5 @@ export const __test__ = {
|
||||
normalizeMessages,
|
||||
wrapQoderSSE,
|
||||
buildQoderRequestBody,
|
||||
isBillingBlock,
|
||||
};
|
||||
|
||||
@@ -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
339
open-sse/executors/trae.js
Normal 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;
|
||||
}
|
||||
}
|
||||
588
open-sse/executors/windsurf.js
Normal file
588
open-sse/executors/windsurf.js
Normal 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
304
open-sse/executors/zed.js
Normal 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;
|
||||
@@ -1,19 +1,20 @@
|
||||
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 { trackPendingRequest, saveRequestDetail } from "@/lib/usageDb.js";
|
||||
import { getExecutor } from "../executors/index.js";
|
||||
import { buildRequestDetail, extractRequestConfig } from "./chatCore/requestDetail.js";
|
||||
import { supportsGrokCliReasoningEffort } from "../config/grokCli.js";
|
||||
import { buildRequestDetail, extractRequestConfig, shouldPersistRequestDetail } from "./chatCore/requestDetail.js";
|
||||
import { handleForcedSSEToJson } from "./chatCore/sseToJsonHandler.js";
|
||||
import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js";
|
||||
import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/streamingHandler.js";
|
||||
@@ -22,10 +23,14 @@ import { dedupeTools } from "../utils/toolDeduper.js";
|
||||
import { injectCaveman } from "../rtk/caveman.js";
|
||||
import { injectPonytail } from "../rtk/ponytail.js";
|
||||
import { compressMessages, formatRtkLog } from "../rtk/index.js";
|
||||
import { compressWithHeadroom, formatHeadroomLog } from "../rtk/headroom.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 { defaultClaudeToolType } from "../translator/concerns/toolCall.js";
|
||||
import { resolveSessionId } from "../utils/sessionManager.js";
|
||||
import { maybeRejectEarlyStreamError } from "../utils/streamErrorPeek.js";
|
||||
|
||||
/**
|
||||
* Core chat handler - shared between SSE and Worker
|
||||
@@ -34,9 +39,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, headroomTimeoutMs, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking, capsOverride = null, streamErrorPatterns = null, persistUsage = "all" }) {
|
||||
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 +80,25 @@ 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;
|
||||
// A source-format-matched endpoint keeps the request lossless. Prefer it
|
||||
// over a model-level targetFormat, which is only the fallback for clients
|
||||
// whose wire format has no supported transport (for example MiniMax-M3:
|
||||
// OpenAI clients should stay on /chat/completions; other clients can fall
|
||||
// back to its declared Claude target).
|
||||
const targetFormat = useTransport?.format || modelTargetFormat || getTargetFormat(provider, credentials);
|
||||
if (useTransport && credentials) credentials.runtimeTransport = useTransport;
|
||||
const stripList = getModelStrip(alias, model);
|
||||
const upstreamModel = getModelUpstreamId(alias, model);
|
||||
|
||||
@@ -58,7 +107,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
if (providerThinking?.mode && providerThinking.mode !== "auto") {
|
||||
const mode = providerThinking.mode;
|
||||
if (mode === "on" && !body.thinking) {
|
||||
console.log("Injecting provider-level thinking config override: on");
|
||||
log?.debug?.("THINKING", `provider-level override: on`);
|
||||
body = { ...body, thinking: { type: "enabled", budget_tokens: 10000 } };
|
||||
} else if (mode === "off" && !body.thinking) {
|
||||
body = { ...body, thinking: { type: "disabled" } };
|
||||
@@ -89,7 +138,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
const acceptHeader = clientRawRequest?.headers?.accept || "";
|
||||
const clientPrefersJson = acceptHeader.includes("application/json");
|
||||
const clientPrefersSSE = acceptHeader.includes("text/event-stream");
|
||||
if (clientPrefersJson && !clientPrefersSSE && body.stream !== true) {
|
||||
if (clientPrefersJson && !clientPrefersSSE && body.stream !== true && !providerRequiresStreaming) {
|
||||
stream = false;
|
||||
}
|
||||
|
||||
@@ -107,8 +156,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
if (credentials) credentials.rawHeaders = clientRawRequest?.headers || {};
|
||||
|
||||
// Auto-strip media blocks the model can't read (vision/audio/pdf) before translation.
|
||||
// capsOverride lets the app layer assert per-model capabilities (e.g. user-registered
|
||||
// models) on top of the static tables.
|
||||
if (!passthrough) {
|
||||
const caps = getCapabilitiesForModel(provider, model);
|
||||
const caps = { ...getCapabilitiesForModel(provider, model), ...(capsOverride || {}) };
|
||||
if (stripUnsupportedModalities(body, sourceFormat, caps)) {
|
||||
log?.debug?.("MODALITY", `stripped unsupported media for ${provider}/${model}`);
|
||||
}
|
||||
@@ -121,11 +172,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 +198,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,37 +217,95 @@ 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;
|
||||
}
|
||||
|
||||
// Claude tool schema requires `type` to be explicitly set; strict gateways (e.g., MiniMax)
|
||||
// reject legacy payloads that omit it with HTTP 400. Default to "custom" when missing.
|
||||
if (finalFormat === FORMATS.CLAUDE && Array.isArray(translatedBody.tools)) {
|
||||
translatedBody.tools = defaultClaudeToolType(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);
|
||||
if (rtkLine) log?.info?.("RTK", rtkLine.replace(/^\[RTK\] /, ""));
|
||||
|
||||
// Headroom: optional external proxy compression; fail open if proxy is absent.
|
||||
const headroomStats = await compressWithHeadroom(translatedBody, { enabled: headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages });
|
||||
const headroomDiagnostics = {};
|
||||
const headroomStats = await compressWithHeadroom(translatedBody, { enabled: tokenSaverEnabled && headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, timeoutMs: headroomTimeoutMs, diagnostics: headroomDiagnostics });
|
||||
const headroomLine = formatHeadroomLog(headroomStats);
|
||||
if (headroomLine) log?.info?.("HEADROOM", headroomLine);
|
||||
const headroomSizeLine = formatHeadroomSizeLog(headroomDiagnostics);
|
||||
if (headroomLine) {
|
||||
log?.info?.("HEADROOM", `${headroomLine}${headroomSizeLine ? ` | ${headroomSizeLine}` : ""}`);
|
||||
if (isHeadroomPhantomSavings(headroomStats, headroomDiagnostics)) {
|
||||
log?.warn?.("HEADROOM", `reported token delta, but outbound JSON shrank <5%; provider may bill near-original payload | ${formatHeadroomSizeLog(headroomDiagnostics)}`);
|
||||
}
|
||||
} else if (tokenSaverEnabled && headroomEnabled) log?.warn?.("HEADROOM", `skipped: ${headroomDiagnostics.reason || "compression unavailable"}${headroomDiagnostics.endpoint ? ` (${headroomDiagnostics.endpoint})` : ""}`);
|
||||
|
||||
// Token-saver flags accumulator for the single "⚙" log line below.
|
||||
const xf = [];
|
||||
|
||||
// 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(() => { });
|
||||
|
||||
const msgCount = translatedBody.messages?.length || translatedBody.input?.length || translatedBody.contents?.length || translatedBody.request?.contents?.length || 0;
|
||||
log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`);
|
||||
@@ -191,7 +316,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 = {
|
||||
@@ -229,48 +354,91 @@ 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 });
|
||||
const result = await executor.execute({
|
||||
model,
|
||||
body: translatedBody,
|
||||
stream,
|
||||
credentials,
|
||||
providerSessionId: sessionSeed,
|
||||
clientTool,
|
||||
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);
|
||||
appendRequestLog({ model, provider, connectionId, status: `FAILED ${error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}` }).catch(() => { });
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
provider, model, connectionId,
|
||||
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
providerRequest: translatedBody || null,
|
||||
response: { error: error.message || String(error), status: error.name === "AbortError" ? 499 : 502, thinking: null },
|
||||
status: "error"
|
||||
})).catch(() => { });
|
||||
if (shouldPersistRequestDetail(persistUsage, "error")) {
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
provider, model, connectionId,
|
||||
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
providerRequest: translatedBody || null,
|
||||
response: { error: error.message || String(error), status: error.name === "AbortError" ? 499 : 502, thinking: null },
|
||||
pxpipe: pxpipeSummary,
|
||||
status: "error"
|
||||
})).catch(() => { });
|
||||
}
|
||||
|
||||
if (error.name === "AbortError") {
|
||||
streamController.handleError(error);
|
||||
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; }
|
||||
const retryResult = await executor.execute({
|
||||
model,
|
||||
body: translatedBody,
|
||||
stream,
|
||||
credentials,
|
||||
providerSessionId: sessionSeed,
|
||||
clientTool,
|
||||
signal: streamController.signal,
|
||||
log,
|
||||
proxyOptions,
|
||||
});
|
||||
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`);
|
||||
@@ -280,47 +448,76 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Provider returned error
|
||||
if (!providerResponse.ok) {
|
||||
trackPendingRequest(model, provider, connectionId, false, true);
|
||||
const { statusCode, message, resetsAtMs } = await parseUpstreamError(providerResponse, executor);
|
||||
appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => { });
|
||||
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,
|
||||
response: { error: message, status: statusCode, thinking: null },
|
||||
status: "error"
|
||||
})).catch(() => { });
|
||||
if (shouldPersistRequestDetail(persistUsage, "error")) {
|
||||
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,
|
||||
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 appendLog = (extra) => appendRequestLog({ model, provider, connectionId, ...extra }).catch(() => { });
|
||||
const appendLog = () => {}; // request log derived from usageHistory; kept as no-op seam for handlers
|
||||
const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, pxpipe: pxpipeSummary, reqTag, log, streamErrorPatterns, persistUsage };
|
||||
|
||||
// Early-peek streaming responses for configured in-stream error patterns.
|
||||
// Some upstreams fail INSIDE a 200 SSE stream; without this the failure is
|
||||
// piped to the client verbatim and account/combo fallback never triggers
|
||||
// (see AGENTS.md "HTTP 200 in-stream errors"). Fail-open: no patterns → pass-through.
|
||||
if (providerResponse.ok && stream) {
|
||||
const peeked = await maybeRejectEarlyStreamError(
|
||||
providerResponse,
|
||||
streamErrorPatterns?.[provider],
|
||||
{ signal: streamController.signal },
|
||||
);
|
||||
if (!peeked.ok) {
|
||||
const { message } = await parseUpstreamError(peeked).catch(() => ({ message: "Stream error pattern matched" }));
|
||||
trackPendingRequest(model, provider, connectionId, false, true);
|
||||
appendLog({ status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}` });
|
||||
if (log?.errorLine) {
|
||||
log.errorLine(reqTag, "✗", `ERROR 502 · ${provider}/${model} · ${Date.now() - requestStartTime}ms (in-stream)\n ${message}`);
|
||||
}
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, message);
|
||||
}
|
||||
providerResponse = peeked;
|
||||
}
|
||||
|
||||
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, credentials });
|
||||
}
|
||||
|
||||
export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) {
|
||||
|
||||
@@ -1,19 +1,158 @@
|
||||
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 { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
||||
import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats, formatDoneLine, tokensForDetail, shouldPersistRequestDetail } from "./requestDetail.js";
|
||||
import { saveRequestDetail } from "@/lib/usageDb.js";
|
||||
import { matchStreamErrorPatterns } from "../../utils/streamErrorPatterns.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 +282,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, streamErrorPatterns, persistUsage = "all" }) {
|
||||
trackDone();
|
||||
const contentType = providerResponse.headers.get("content-type") || "";
|
||||
let responseBody;
|
||||
@@ -167,18 +306,44 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
|
||||
}
|
||||
|
||||
reqLogger.logProviderResponse(providerResponse.status, providerResponse.statusText, providerResponse.headers, responseBody);
|
||||
if (onRequestSuccess) await onRequestSuccess();
|
||||
if (onRequestSuccess) {
|
||||
Promise.resolve()
|
||||
.then(onRequestSuccess)
|
||||
.catch(err => {
|
||||
console.error("[ChatCore] onRequestSuccess failed:", err?.message || err);
|
||||
});
|
||||
}
|
||||
|
||||
// Decloak tool_use names once on raw Claude body, before any translation (INPUT side)
|
||||
responseBody = decloakToolNames(responseBody, toolNameMap);
|
||||
|
||||
// Config-driven in-stream error detection: the HTTP call succeeded but the
|
||||
// assembled content signals an upstream failure — treat it as an error so
|
||||
// account/combo fallback and FAILED logging kick in (AGENTS.md hook #3).
|
||||
const matchedPattern = matchStreamErrorPatterns(
|
||||
streamErrorPatterns?.[provider],
|
||||
responseBody?.choices?.[0]?.message?.content || responseBody?.content || "",
|
||||
);
|
||||
if (matchedPattern) {
|
||||
appendLog({ status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}` });
|
||||
if (log?.errorLine) {
|
||||
log.errorLine(reqTag, "✗", `ERROR 502 · ${provider}/${model} · ${Date.now() - requestStartTime}ms (in-stream)\n Stream error pattern matched: ${matchedPattern}`);
|
||||
}
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, `Stream error pattern matched: ${matchedPattern}`);
|
||||
}
|
||||
|
||||
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]) {
|
||||
@@ -191,13 +356,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) {
|
||||
@@ -207,7 +376,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;
|
||||
@@ -218,22 +387,25 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
|
||||
reqLogger.logConvertedResponse(translatedResponse);
|
||||
|
||||
const totalLatency = Date.now() - requestStartTime;
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
provider, model, connectionId,
|
||||
latency: { ttft: totalLatency, total: totalLatency },
|
||||
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
providerRequest: finalBody || translatedBody || null,
|
||||
providerResponse: responseBody || null,
|
||||
response: {
|
||||
content: translatedResponse?.choices?.[0]?.message?.content || translatedResponse?.content || null,
|
||||
thinking: translatedResponse?.choices?.[0]?.message?.reasoning_content || translatedResponse?.reasoning_content || null,
|
||||
finish_reason: translatedResponse?.choices?.[0]?.finish_reason || "unknown"
|
||||
},
|
||||
status: "success"
|
||||
}, { endpoint: clientRawRequest?.endpoint || null })).catch(err => {
|
||||
console.error("[RequestDetail] Failed to save:", err.message);
|
||||
});
|
||||
if (shouldPersistRequestDetail(persistUsage, "success")) {
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
provider, model, connectionId, apiKey,
|
||||
latency: { ttft: totalLatency, total: totalLatency },
|
||||
tokens: tokensForDetail(usage),
|
||||
request: extractRequestConfig(body, stream),
|
||||
providerRequest: finalBody || translatedBody || null,
|
||||
providerResponse: responseBody || null,
|
||||
response: {
|
||||
content: translatedResponse?.choices?.[0]?.message?.content || translatedResponse?.content || null,
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { saveRequestUsage, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
||||
import { saveRequestUsage, 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",
|
||||
@@ -24,10 +25,16 @@ export function extractUsageFromResponse(responseBody) {
|
||||
if (!responseBody || typeof responseBody !== "object") return null;
|
||||
|
||||
// Claude format
|
||||
// Note: OpenAI Responses usage ({input_tokens, input_tokens_details:{cached_tokens}})
|
||||
// also matches this branch. Its prompt is cache-INCLUSIVE and its cache rides in
|
||||
// input_tokens_details, so emit it as cached_tokens — the convention
|
||||
// canonicalizeUsage() passes through without folding. Reading it here keeps
|
||||
// cache accounting correct for /v1/responses and codex traffic.
|
||||
if (responseBody.usage?.input_tokens !== undefined) {
|
||||
return {
|
||||
prompt_tokens: responseBody.usage.input_tokens || 0,
|
||||
completion_tokens: responseBody.usage.output_tokens || 0,
|
||||
cached_tokens: responseBody.usage.cached_tokens ?? responseBody.usage.input_tokens_details?.cached_tokens,
|
||||
cache_read_input_tokens: responseBody.usage.cache_read_input_tokens,
|
||||
cache_creation_input_tokens: responseBody.usage.cache_creation_input_tokens
|
||||
};
|
||||
@@ -38,28 +45,40 @@ export function extractUsageFromResponse(responseBody) {
|
||||
return {
|
||||
prompt_tokens: responseBody.usage.prompt_tokens || 0,
|
||||
completion_tokens: responseBody.usage.completion_tokens || 0,
|
||||
cached_tokens: responseBody.usage.prompt_tokens_details?.cached_tokens,
|
||||
cached_tokens: responseBody.usage.cached_tokens ?? responseBody.usage.prompt_tokens_details?.cached_tokens,
|
||||
reasoning_tokens: responseBody.usage.completion_tokens_details?.reasoning_tokens
|
||||
};
|
||||
}
|
||||
|
||||
// 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 +86,54 @@ 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}`;
|
||||
}
|
||||
|
||||
// Request-details storage convention: always prompt_tokens / completion_tokens.
|
||||
// Translators often hand Claude `{input_tokens, output_tokens}` (or Gemini
|
||||
// counts) to onStreamComplete; the Details tab only reads the OpenAI names,
|
||||
// so an uncanonicalized object shows up as input=0 / output=0.
|
||||
export function tokensForDetail(usage) {
|
||||
if (!usage || typeof usage !== "object") {
|
||||
return { prompt_tokens: 0, completion_tokens: 0 };
|
||||
}
|
||||
return canonicalizeUsage(usage) || {
|
||||
prompt_tokens: usage.prompt_tokens ?? usage.input_tokens ?? 0,
|
||||
completion_tokens: usage.completion_tokens ?? usage.output_tokens ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Combo fallback/account hops must not inflate Details with 0-token rows.
|
||||
// `streaming-start` is never persisted: the placeholder was status=success at
|
||||
// tokens=0, and nested/fusion paths often abandon the stream before complete.
|
||||
export function shouldPersistRequestDetail(persistUsage, kind) {
|
||||
if (kind === "streaming-start") return false;
|
||||
if (persistUsage === "success-only") return kind === "success";
|
||||
return true;
|
||||
}
|
||||
|
||||
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 +141,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
|
||||
};
|
||||
|
||||
@@ -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 { saveRequestDetail } from "@/lib/usageDb.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;
|
||||
import { saveRequestDetail, appendRequestLog } from "@/lib/usageDb.js";
|
||||
const isResponsesProvider = (p) =>
|
||||
PROVIDERS[p]?.format === FORMATS.OPENAI_RESPONSES;
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,39 @@
|
||||
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,
|
||||
tokensForDetail,
|
||||
shouldPersistRequestDetail,
|
||||
} 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 }) {
|
||||
function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey, credentials }) {
|
||||
const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli");
|
||||
// Responses-API providers (e.g. codex) emit Responses SSE → translate into client format
|
||||
const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES;
|
||||
@@ -30,79 +41,196 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent,
|
||||
|
||||
if (needsCodexTranslation) {
|
||||
const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI;
|
||||
return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey);
|
||||
return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames, credentials);
|
||||
}
|
||||
|
||||
if (needsTranslation(targetFormat, sourceFormat)) {
|
||||
return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey);
|
||||
return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames, credentials);
|
||||
}
|
||||
|
||||
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) onRequestSuccess();
|
||||
export async function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, customToolNames, streamController, onStreamComplete, pxpipe, reqTag, log, credentials }) {
|
||||
if (onRequestSuccess) {
|
||||
Promise.resolve()
|
||||
.then(onRequestSuccess)
|
||||
.catch(err => {
|
||||
console.error("[ChatCore] onRequestSuccess failed:", err?.message || err);
|
||||
});
|
||||
}
|
||||
|
||||
const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey });
|
||||
// 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": "*",
|
||||
},
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// 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 transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey, credentials });
|
||||
|
||||
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);
|
||||
});
|
||||
// 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,
|
||||
);
|
||||
|
||||
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,
|
||||
persistUsage = "all",
|
||||
}) {
|
||||
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;
|
||||
const rawProviderText = typeof contentObj?.rawProviderText === "string" ? contentObj.rawProviderText : "";
|
||||
|
||||
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);
|
||||
});
|
||||
if (shouldPersistRequestDetail(persistUsage, "success")) {
|
||||
saveRequestDetail(
|
||||
buildRequestDetail(
|
||||
{
|
||||
provider,
|
||||
model,
|
||||
connectionId,
|
||||
apiKey,
|
||||
latency,
|
||||
tokens: tokensForDetail(usage),
|
||||
request: extractRequestConfig(body, stream),
|
||||
providerRequest: finalBody || translatedBody || null,
|
||||
providerResponse: rawProviderText || 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 };
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
46
open-sse/handlers/embeddingProviders/selfhostedEmbedding.js
Normal file
46
open-sse/handlers/embeddingProviders/selfhostedEmbedding.js
Normal 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`;
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Web Fetch handler — dispatches to firecrawl, jina-reader, tavily, exa
|
||||
// Web Fetch handler — dispatches to firecrawl, jina-reader, tavily, exa, ollama
|
||||
// Returns normalized shape across all providers
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15000;
|
||||
@@ -49,12 +49,15 @@ 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;
|
||||
}
|
||||
|
||||
function buildData({ provider, url, title, format, text, costUsd, responseMs, upstreamMs }) {
|
||||
return {
|
||||
function buildData({ provider, url, title, format, text, links, costUsd, responseMs, upstreamMs }) {
|
||||
const data = {
|
||||
provider,
|
||||
url,
|
||||
title: title || null,
|
||||
@@ -63,6 +66,8 @@ function buildData({ provider, url, title, format, text, costUsd, responseMs, up
|
||||
usage: { fetch_cost_usd: costUsd ?? null },
|
||||
metrics: { response_time_ms: responseMs, upstream_latency_ms: upstreamMs }
|
||||
};
|
||||
if (Array.isArray(links)) data.links = links;
|
||||
return data;
|
||||
}
|
||||
|
||||
async function readJsonOrText(res) {
|
||||
@@ -112,6 +117,18 @@ export async function handleFetchCore({ url, format, maxCharacters, provider, pr
|
||||
if (provider === "exa") {
|
||||
return await runExa({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt });
|
||||
}
|
||||
if (provider === "ollama") {
|
||||
return await runOllama({
|
||||
url,
|
||||
fmt,
|
||||
timeoutMs,
|
||||
apiKey,
|
||||
maxCharacters,
|
||||
costPerQuery,
|
||||
startedAt,
|
||||
baseUrl: providerConfig?.baseUrl,
|
||||
});
|
||||
}
|
||||
return { success: false, status: 400, error: `Unsupported provider: ${provider}` };
|
||||
} catch (err) {
|
||||
log?.("fetch handler error:", err?.message || err);
|
||||
@@ -151,11 +168,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) {
|
||||
@@ -235,3 +255,56 @@ async function runExa({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
async function runOllama({
|
||||
url,
|
||||
fmt,
|
||||
timeoutMs,
|
||||
apiKey,
|
||||
maxCharacters,
|
||||
costPerQuery,
|
||||
startedAt,
|
||||
baseUrl,
|
||||
}) {
|
||||
const upstreamStart = Date.now();
|
||||
const r = await tryFetch(baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(apiKey ? { authorization: `Bearer ${apiKey}` } : {})
|
||||
},
|
||||
body: JSON.stringify({ url })
|
||||
}, timeoutMs);
|
||||
|
||||
if (!r.ok) {
|
||||
return { success: false, status: r.timeout ? 504 : 502, error: r.error };
|
||||
}
|
||||
const upstreamMs = Date.now() - upstreamStart;
|
||||
const { json, text: responseText } = await readJsonOrText(r.res);
|
||||
if (!r.res.ok) {
|
||||
const error = json?.error
|
||||
|| json?.message
|
||||
|| responseText?.slice(0, 500)
|
||||
|| `Ollama error: ${r.res.status}`;
|
||||
return { success: false, status: r.res.status, error };
|
||||
}
|
||||
if (!json || typeof json.content !== "string") {
|
||||
return { success: false, status: 502, error: "Ollama returned an empty or invalid web fetch response" };
|
||||
}
|
||||
|
||||
const text = truncate(json.content, maxCharacters);
|
||||
return {
|
||||
success: true,
|
||||
data: buildData({
|
||||
provider: "ollama",
|
||||
url,
|
||||
title: json.title || null,
|
||||
format: fmt,
|
||||
text,
|
||||
links: json.links,
|
||||
costUsd: costPerQuery,
|
||||
responseMs: Date.now() - startedAt,
|
||||
upstreamMs
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Antigravity image adapter - delegates to the executor for correct request
|
||||
// envelope (project, model, requestType, sessionId) and auth headers.
|
||||
import { nowSec } from "./_base.js";
|
||||
import { nowSec, sizeToAspectRatio } from "./_base.js";
|
||||
import { getExecutor } from "../../executors/index.js";
|
||||
|
||||
// Convert image input (data URI or raw base64) to Gemini inlineData part
|
||||
@@ -31,6 +31,19 @@ export default {
|
||||
const executor = getExecutor("antigravity");
|
||||
if (!executor) throw new Error("Antigravity executor not found");
|
||||
|
||||
// Ensure we use an image model for image generation
|
||||
const isImageModel = (m) => /image|imagen|image-generation/i.test(m || "");
|
||||
let targetModel = isImageModel(model) ? model : "gemini-3.1-flash-image";
|
||||
|
||||
// If body.size is provided, resolve aspect ratio and append to model
|
||||
if (body.size && typeof body.size === "string") {
|
||||
const ratio = sizeToAspectRatio(body.size);
|
||||
const suffix = ratio.replace(":", "x");
|
||||
if (!targetModel.includes(suffix)) {
|
||||
targetModel = `${targetModel}-${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Build parts: text prompt + optional input image for editing
|
||||
const parts = [{ text: body.prompt }];
|
||||
const imageInput = body.image || (Array.isArray(body.images) && body.images[0]);
|
||||
@@ -44,7 +57,7 @@ export default {
|
||||
};
|
||||
|
||||
const result = await executor.execute({
|
||||
model,
|
||||
model: targetModel,
|
||||
body: chatBody,
|
||||
stream: false,
|
||||
credentials,
|
||||
|
||||
@@ -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,
|
||||
|
||||
137
open-sse/handlers/imageProviders/xai.js
Normal file
137
open-sse/handlers/imageProviders/xai.js
Normal 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,
|
||||
};
|
||||
@@ -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(/\/+$/, "");
|
||||
}
|
||||
|
||||
@@ -327,6 +347,81 @@ function buildSearxngRequest(config, params) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildXquikRequest(config, params) {
|
||||
const apiKey = params.token;
|
||||
if (!apiKey) throw new Error("Xquik requires an API key");
|
||||
|
||||
const queryType = getProviderSetting(params, "queryType");
|
||||
if (queryType && !["Latest", "Top"].includes(queryType)) {
|
||||
throw new Error("Xquik queryType must be Latest or Top");
|
||||
}
|
||||
|
||||
const qp = new URLSearchParams({
|
||||
q: params.query,
|
||||
limit: String(params.maxResults),
|
||||
});
|
||||
const cursor = getProviderSetting(params, "cursor");
|
||||
if (cursor) qp.set("cursor", cursor);
|
||||
if (queryType) qp.set("queryType", queryType);
|
||||
if (params.language) qp.set("language", params.language);
|
||||
|
||||
return {
|
||||
url: `${resolveBaseUrl(config, params)}?${qp}`,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json", "x-api-key": apiKey },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Ollama Cloud web_search ──────────────────────────────────────────────
|
||||
// POST https://ollama.com/api/web_search { query, max_results }
|
||||
// Response: { results: [{ title, url, content, published_at? }] }
|
||||
function buildOllamaSearchRequest(config, params) {
|
||||
const body = { query: params.query, max_results: params.maxResults };
|
||||
if (params.country) body.country = params.country;
|
||||
if (params.language) body.language = params.language;
|
||||
return {
|
||||
url: resolveBaseUrl(config, params),
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(params.token ? { Authorization: `Bearer ${params.token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── GLM Coding plan MCP web_search_prime ──────────────────────────────────
|
||||
// POST https://api.z.ai/api/mcp/web_search_prime/mcp
|
||||
// JSON-RPC envelope: { jsonrpc, id, method: "tools/call",
|
||||
// params: { name: "web_search_prime", arguments: { search_query, count } } }
|
||||
// Response: { result: { content: [{ type: "text", text: "<json>" }] } }
|
||||
function buildGlmSearchRequest(config, params) {
|
||||
const body = {
|
||||
jsonrpc: "2.0",
|
||||
id: `9r-${Date.now()}`,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search_prime",
|
||||
arguments: { search_query: params.query, count: params.maxResults },
|
||||
},
|
||||
};
|
||||
return {
|
||||
url: resolveBaseUrl(config, params),
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(params.token ? { Authorization: `Bearer ${params.token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Dispatcher ──────────────────────────────────────────────────────────
|
||||
|
||||
const BUILDERS = {
|
||||
@@ -340,6 +435,9 @@ const BUILDERS = {
|
||||
"searchapi": buildSearchApiRequest,
|
||||
"youcom": buildYouComRequest,
|
||||
"searxng": buildSearxngRequest,
|
||||
"xquik": buildXquikRequest,
|
||||
"ollama-search": buildOllamaSearchRequest,
|
||||
"glm": buildGlmSearchRequest,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Wrap chat-completions endpoints (with built-in web search) into the unified
|
||||
* /v1/search response format. Supports gemini, openai, xai, kimi, minimax, perplexity.
|
||||
* /v1/search response format. Supports gemini, antigravity, openai, xai, kimi,
|
||||
* minimax, perplexity.
|
||||
*/
|
||||
import { PROVIDER_MEDIA } from "../../providers/index.js";
|
||||
import { ANTIGRAVITY_IDE_USER_AGENT } from "../../providers/shared.js";
|
||||
|
||||
// Default search model + endpoint derive from registry searchViaChat (single source)
|
||||
const searchModel = (id) => PROVIDER_MEDIA[id]?.searchViaChat?.defaultModel;
|
||||
@@ -28,13 +30,37 @@ function toResult(c, index, provider, retrievedAt) {
|
||||
score: null,
|
||||
published_at: null,
|
||||
favicon_url: null,
|
||||
content: null,
|
||||
content: c.content || null,
|
||||
metadata: {},
|
||||
citation: { provider, retrieved_at: retrievedAt, rank: index + 1 },
|
||||
provider_raw: null
|
||||
};
|
||||
}
|
||||
|
||||
// Antigravity search request envelope (mirrors the IDE client)
|
||||
const AG_CLIENT_NAME = "antigravity";
|
||||
const AG_SEARCH_GENERATION_CONFIG = { temperature: 1.0, maxOutputTokens: 8192 };
|
||||
const AG_CONTEXT_BEFORE = 150;
|
||||
const AG_CONTEXT_AFTER = 250;
|
||||
|
||||
/** Widen a grounded segment to its surrounding sentence(s) in the answer text. */
|
||||
function expandSegment(text, segment) {
|
||||
const { startIndex, endIndex } = segment || {};
|
||||
if (!text || !Number.isInteger(startIndex) || !Number.isInteger(endIndex)) return "";
|
||||
const start = Math.max(0, startIndex - AG_CONTEXT_BEFORE);
|
||||
const end = Math.min(text.length, endIndex + AG_CONTEXT_AFTER);
|
||||
let out = text.slice(start, end).trim();
|
||||
// Drop the partial words the window cut off at either edge
|
||||
if (start > 0) out = `...${out.replace(/^\S+/, "")}`;
|
||||
if (end < text.length) out = `${out.replace(/\S+$/, "")}...`;
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
/** Join deduped grounding pieces, skipping empties. */
|
||||
function joinPieces(set, sep) {
|
||||
return [...(set || [])].filter(Boolean).join(sep).trim();
|
||||
}
|
||||
|
||||
/** Coerce a citation that might be a raw URL string or an object. */
|
||||
function normalizeCitation(c) {
|
||||
if (!c) return null;
|
||||
@@ -46,6 +72,8 @@ function normalizeCitation(c) {
|
||||
/**
|
||||
* Provider-specific configuration map. All providers must implement:
|
||||
* { endpoint, defaultModel, buildBody, buildHeaders, extractAnswer }
|
||||
* Optional: requireCredentials(credentials) → error string when a provider needs
|
||||
* more than a token (returns null when satisfied).
|
||||
*/
|
||||
const CHAT_SEARCH_CONFIG = {
|
||||
gemini: {
|
||||
@@ -73,6 +101,71 @@ const CHAT_SEARCH_CONFIG = {
|
||||
}
|
||||
},
|
||||
|
||||
antigravity: {
|
||||
endpoint: () => searchEndpoint("antigravity"),
|
||||
// Upstream 403s on a missing or fabricated project — surface the real cause
|
||||
requireCredentials: (credentials) =>
|
||||
credentials?.projectId ? null : "Antigravity account has no projectId — reconnect the account",
|
||||
buildBody: (query, model, credentials) => ({
|
||||
project: credentials.projectId,
|
||||
model,
|
||||
userAgent: AG_CLIENT_NAME,
|
||||
requestType: "search",
|
||||
request: {
|
||||
contents: [{ role: "user", parts: [{ text: query }] }],
|
||||
tools: [{ googleSearch: {} }],
|
||||
generationConfig: AG_SEARCH_GENERATION_CONFIG
|
||||
}
|
||||
}),
|
||||
buildHeaders: (token) => ({
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"User-Agent": ANTIGRAVITY_IDE_USER_AGENT
|
||||
}),
|
||||
extractAnswer: (data) => {
|
||||
// Antigravity wraps the Gemini payload in { response: {...} }
|
||||
const response = data?.response || data;
|
||||
const candidate = response?.candidates?.[0];
|
||||
const parts = candidate?.content?.parts || [];
|
||||
const text = parts.map((p) => p?.text || "").filter(Boolean).join("");
|
||||
const grounding = candidate?.groundingMetadata || {};
|
||||
const chunks = grounding.groundingChunks || [];
|
||||
const supports = grounding.groundingSupports || [];
|
||||
|
||||
// Upstream repeats the same source across chunks — key by URL so it stays one citation.
|
||||
// Map, not a plain object: both the index and the URL come from upstream.
|
||||
const sources = new Map();
|
||||
const byIndex = chunks.map((ch) => {
|
||||
const web = ch?.web;
|
||||
const url = web?.uri || web?.url || "";
|
||||
if (!url) return null;
|
||||
if (!sources.has(url)) sources.set(url, { title: web.title || "", snippets: new Set(), contexts: new Set() });
|
||||
return sources.get(url);
|
||||
});
|
||||
|
||||
// Each support ties a sentence of the answer back to the chunks that grounded it
|
||||
for (const s of supports) {
|
||||
const segment = s?.segment;
|
||||
const grounded = segment?.text || "";
|
||||
const expanded = expandSegment(text, segment) || grounded;
|
||||
for (const idx of s?.groundingChunkIndices || []) {
|
||||
const source = Number.isInteger(idx) ? byIndex[idx] : null;
|
||||
if (!source) continue;
|
||||
if (grounded) source.snippets.add(grounded);
|
||||
if (expanded) source.contexts.add(expanded);
|
||||
}
|
||||
}
|
||||
|
||||
const citations = [...sources].map(([url, src]) => {
|
||||
const snippet = joinPieces(src.snippets, " | ") || src.title;
|
||||
return { url, title: src.title, snippet, content: joinPieces(src.contexts, "\n\n") || snippet };
|
||||
});
|
||||
|
||||
const tokens = response?.usageMetadata?.totalTokenCount || 0;
|
||||
return { text, citations, tokens };
|
||||
}
|
||||
},
|
||||
|
||||
openai: {
|
||||
endpoint: () => searchEndpoint("openai"),
|
||||
buildBody: (query, model) => {
|
||||
@@ -273,6 +366,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 };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -319,13 +459,18 @@ export async function handleChatSearch({
|
||||
};
|
||||
}
|
||||
|
||||
const credentialError = cfg.requireCredentials?.(credentials);
|
||||
if (credentialError) {
|
||||
return { success: false, status: 401, error: credentialError };
|
||||
}
|
||||
|
||||
const limit =
|
||||
Number.isFinite(maxResults) && maxResults > 0
|
||||
? Math.floor(maxResults)
|
||||
: DEFAULT_MAX_RESULTS;
|
||||
const useModel = model || searchModel(provider);
|
||||
const url = cfg.endpoint(useModel);
|
||||
const body = cfg.buildBody(query, useModel);
|
||||
const body = cfg.buildBody(query, useModel, credentials);
|
||||
const headers = cfg.buildHeaders(token);
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { buildSearchRequest } from "./callers.js";
|
||||
import { normalizeSearchResponse } from "./normalizers.js";
|
||||
import { handleChatSearch } from "./chatSearch.js";
|
||||
import { fetchPublic } from "../../../src/shared/utils/ssrfGuard.js";
|
||||
|
||||
const GLOBAL_TIMEOUT_MS = 15000;
|
||||
const NON_RETRIABLE = new Set([400, 401, 403, 404]);
|
||||
@@ -100,7 +101,7 @@ async function tryDedicatedProvider({ provider, providerConfig, body, credential
|
||||
log?.info?.("SEARCH", `${provider.id} | "${params.query.slice(0, 80)}" | type=${params.searchType}`);
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { ...init, headers: sanitizeHeaders(init.headers), signal: controller.signal });
|
||||
const resp = await fetchPublic(url, { ...init, headers: sanitizeHeaders(init.headers), signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text().catch(() => "");
|
||||
@@ -111,6 +112,13 @@ async function tryDedicatedProvider({ provider, providerConfig, body, credential
|
||||
const normalized = normalizeSearchResponse(provider.id, data, params.query, params.searchType);
|
||||
const results = normalized.results.slice(0, params.maxResults);
|
||||
const duration = Date.now() - startTime;
|
||||
const usage = {
|
||||
queries_used: 1,
|
||||
search_cost_usd: providerConfig.costPerQuery ?? null,
|
||||
};
|
||||
if (Number.isFinite(providerConfig.creditsPerResult)) {
|
||||
usage.provider_credits_used = results.length * providerConfig.creditsPerResult;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -119,7 +127,8 @@ async function tryDedicatedProvider({ provider, providerConfig, body, credential
|
||||
query: params.query,
|
||||
results,
|
||||
answer: null,
|
||||
usage: { queries_used: 1, search_cost_usd: providerConfig.costPerQuery || 0 },
|
||||
usage,
|
||||
...(normalized.pagination ? { pagination: normalized.pagination } : {}),
|
||||
metrics: { response_time_ms: duration, upstream_latency_ms: duration, total_results_available: normalized.totalResults },
|
||||
errors: []
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -200,6 +199,89 @@ function normalizeSearxng(data, _query, _searchType) {
|
||||
return { results, totalResults: results.length };
|
||||
}
|
||||
|
||||
function normalizeXquik(data, _query, _searchType) {
|
||||
const now = new Date().toISOString();
|
||||
const items = Array.isArray(data.tweets) ? data.tweets : [];
|
||||
const results = items.map((item, idx) => {
|
||||
const username = typeof item?.author?.username === "string" ? item.author.username : "";
|
||||
const authorName = typeof item?.author?.name === "string" ? item.author.name : "";
|
||||
const tweetId = typeof item?.id === "string" ? item.id : String(item?.id || "");
|
||||
const url = username && tweetId
|
||||
? `https://x.com/${encodeURIComponent(username)}/status/${encodeURIComponent(tweetId)}`
|
||||
: tweetId
|
||||
? `https://x.com/i/web/status/${encodeURIComponent(tweetId)}`
|
||||
: "";
|
||||
const author = username ? `@${username}` : authorName || null;
|
||||
const title = author ? `${author} on X` : "X post";
|
||||
const imageUrl = Array.isArray(item?.media)
|
||||
? item.media.find((media) => typeof media?.mediaUrl === "string")?.mediaUrl
|
||||
: null;
|
||||
|
||||
return makeResult("xquik", {
|
||||
title,
|
||||
url,
|
||||
snippet: typeof item?.text === "string" ? item.text : "",
|
||||
published_at: typeof item?.createdAt === "string" ? item.createdAt : null,
|
||||
author,
|
||||
image_url: imageUrl || null,
|
||||
source_type: "x_post",
|
||||
full_text: typeof item?.text === "string" ? item.text : undefined,
|
||||
text_format: "text",
|
||||
}, idx, now);
|
||||
});
|
||||
const nextCursor = typeof data.next_cursor === "string" && data.next_cursor ? data.next_cursor : null;
|
||||
return {
|
||||
results,
|
||||
totalResults: null,
|
||||
pagination: {
|
||||
has_more: data.has_next_page === true,
|
||||
next_cursor: nextCursor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOllamaSearch(data, _query, _searchType) {
|
||||
const now = new Date().toISOString();
|
||||
const items = Array.isArray(data?.results) ? data.results : (Array.isArray(data) ? data : []);
|
||||
const results = items.map((item, idx) =>
|
||||
makeResult("ollama-search", {
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
snippet: item.content || item.snippet || "",
|
||||
full_text: item.content,
|
||||
text_format: "text",
|
||||
published_at: item.published_at || null,
|
||||
source_type: item.source || null,
|
||||
}, idx, now)
|
||||
);
|
||||
return { results, totalResults: results.length };
|
||||
}
|
||||
|
||||
function normalizeGlmSearch(data, _query, _searchType) {
|
||||
const now = new Date().toISOString();
|
||||
// MCP envelope: { result: { content: [{ type: "text", text: "<json>" }] } }
|
||||
let payload = data;
|
||||
const textContent = data?.result?.content?.[0]?.text;
|
||||
if (typeof textContent === "string") {
|
||||
try { payload = JSON.parse(textContent); } catch { payload = {}; }
|
||||
}
|
||||
const items = Array.isArray(payload?.results) ? payload.results
|
||||
: Array.isArray(payload?.news) ? payload.news
|
||||
: Array.isArray(payload) ? payload
|
||||
: [];
|
||||
const results = items.map((item, idx) =>
|
||||
makeResult("glm", {
|
||||
title: item.title,
|
||||
url: item.link || item.url,
|
||||
snippet: item.content || "",
|
||||
published_at: item.publish_date || item.published_at || null,
|
||||
favicon_url: item.icon || null,
|
||||
source_type: item.media || null,
|
||||
}, idx, now)
|
||||
);
|
||||
return { results, totalResults: results.length };
|
||||
}
|
||||
|
||||
const NORMALIZERS = {
|
||||
"serper": normalizeSerper,
|
||||
"brave-search": normalizeBrave,
|
||||
@@ -211,11 +293,14 @@ const NORMALIZERS = {
|
||||
"searchapi": normalizeSearchApi,
|
||||
"youcom": normalizeYouCom,
|
||||
"searxng": normalizeSearxng,
|
||||
"xquik": normalizeXquik,
|
||||
"ollama-search": normalizeOllamaSearch,
|
||||
"glm": normalizeGlmSearch,
|
||||
};
|
||||
|
||||
/**
|
||||
* Dispatch to the appropriate normalizer based on providerId.
|
||||
* @returns {{results: Array, totalResults: number|null}}
|
||||
* @returns {{results: Array, totalResults: number|null, pagination?: object}}
|
||||
*/
|
||||
export function normalizeSearchResponse(providerId, data, query, searchType) {
|
||||
const fn = NORMALIZERS[providerId];
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
// Gemini TTS — generateContent with AUDIO modality returns PCM L16, wrap as WAV
|
||||
import { Buffer } from "node:buffer";
|
||||
import { PROVIDER_MEDIA } from "../../providers/index.js";
|
||||
import { PROVIDER_MEDIA, PROVIDER_MODELS } from "../../providers/index.js";
|
||||
|
||||
const TTS_CFG = PROVIDER_MEDIA["gemini"]?.ttsConfig || {};
|
||||
const TTS_BASE = TTS_CFG.baseUrl;
|
||||
const KNOWN_MODELS = (TTS_CFG.models || []).map((m) => m.id);
|
||||
const DEFAULT_MODEL = KNOWN_MODELS[0];
|
||||
const FALLBACK_MODEL = "gemini-3.1-flash-tts-preview";
|
||||
const KNOWN_MODELS = [
|
||||
...(TTS_CFG.models || []),
|
||||
...(PROVIDER_MODELS["gemini-tts-models"] || []),
|
||||
...(PROVIDER_MODELS.gemini || []).filter((m) => (m.kind || m.type) === "tts"),
|
||||
]
|
||||
.map((m) => m?.id)
|
||||
.filter(Boolean)
|
||||
.filter((id, index, list) => list.indexOf(id) === index);
|
||||
const DEFAULT_MODEL = KNOWN_MODELS[0] || FALLBACK_MODEL;
|
||||
const DEFAULT_VOICE = "Kore";
|
||||
|
||||
// Parse "model/voice" — if input doesn't match a known TTS model, treat it as voice with default model
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
69
open-sse/handlers/ttsProviders/selfhostedTts.js
Normal file
69
open-sse/handlers/ttsProviders/selfhostedTts.js
Normal 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 };
|
||||
},
|
||||
};
|
||||
65
open-sse/handlers/ttsProviders/xiaomi-mimo.js
Normal file
65
open-sse/handlers/ttsProviders/xiaomi-mimo.js
Normal 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",
|
||||
};
|
||||
}
|
||||
166
open-sse/handlers/videoCore.js
Normal file
166
open-sse/handlers/videoCore.js
Normal 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": "*",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -47,7 +47,6 @@ export {
|
||||
refreshAccessToken,
|
||||
refreshClaudeOAuthToken,
|
||||
refreshGoogleToken,
|
||||
refreshQwenToken,
|
||||
refreshCodexToken,
|
||||
refreshIflowToken,
|
||||
refreshGitHubToken,
|
||||
|
||||
@@ -6,6 +6,16 @@
|
||||
// 3. PATTERN_CAPABILITIES — glob match, ordered specific -> generic
|
||||
// 4. DEFAULT_CAPABILITIES — safe floor (always returned)
|
||||
//
|
||||
// Two extra layers then refine the result, and neither can override the hand
|
||||
// written tables above (steps 1-2 short-circuit before they are consulted):
|
||||
// • the synced catalog — modalities keyed by model, limits keyed by provider
|
||||
// + model, refreshed from models.dev in the background. It reads a file, so
|
||||
// the server installs it via setCatalogSource(); this module stays free of
|
||||
// node:fs because the dashboard bundles it into the browser too.
|
||||
// • visionPatterns.js — name-based vision detection, last resort so a model
|
||||
// nobody has catalogued yet still accepts images.
|
||||
// Both only ever turn a capability ON.
|
||||
//
|
||||
// ── HOW TO ADD / UPDATE A MODEL ──────────────────────────────────────
|
||||
// Authoritative data source: https://models.dev/api.json (145 providers, 4000+
|
||||
// models, MIT). Each model exposes the exact fields we map below:
|
||||
@@ -23,6 +33,7 @@
|
||||
// 2.0+, Grok, Perplexity). Verify with: curl -s https://models.dev/api.json
|
||||
|
||||
import { matchPattern } from "./pricing.js";
|
||||
import { looksLikeVisionModel } from "./visionPatterns.js";
|
||||
|
||||
/**
|
||||
* Safe floor — every resolved result is merged over this so consumers
|
||||
@@ -46,6 +57,7 @@ export const DEFAULT_CAPABILITIES = {
|
||||
thinkingFormat: null,
|
||||
thinkingCanDisable: true, // false → model cannot turn thinking off (clamp to min instead of disable)
|
||||
thinkingRange: null, // { min, max } for budget formats; null = no clamp
|
||||
thinkingEffortSupported: false, // zai format only: model accepts a reasoning_effort level (GLM-5.2+; older GLM ignores it)
|
||||
// limits (tokens)
|
||||
contextWindow: 200000,
|
||||
maxOutput: 64000,
|
||||
@@ -71,49 +83,170 @@ export function capabilitiesFromServiceKind(kind) {
|
||||
* otherwise mis-match. Only declare deltas vs DEFAULT.
|
||||
*/
|
||||
export const MODEL_CAPABILITIES = {
|
||||
// Claude 4.6/4.7 have 1M context + adaptive thinking (override generic claude pattern)
|
||||
// Claude Fable 5.1, Opus 5, 4.6/4.7/4.8, and Kiro Sonnet 5 have 1M context + adaptive thinking (override generic claude pattern)
|
||||
"claude-fable-5-1": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 128000 },
|
||||
"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 },
|
||||
"claude-opus-4.8": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
|
||||
"claude-opus-4-6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
|
||||
"claude-sonnet-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 64000 },
|
||||
"claude-sonnet-4-6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 64000 },
|
||||
"claude-opus-4-8": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
|
||||
"claude-opus-4.8-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
|
||||
"claude-opus-4-8-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
|
||||
"claude-sonnet-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
|
||||
"claude-sonnet-4-6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
|
||||
"claude-sonnet-5": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
|
||||
"claude-sonnet-5-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
|
||||
"claude-sonnet-5-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
|
||||
"claude-sonnet-5-thinking-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
|
||||
|
||||
// Gemini image-gen / OpenAI image / xai image variants
|
||||
"gpt-image-1": { imageOutput: true, tools: false },
|
||||
|
||||
// GLM vision variant (text GLM has no vision)
|
||||
"glm-4.6v": { vision: true, reasoning: true, thinkingFormat: "zai", contextWindow: 128000 },
|
||||
// GLM vision variants (text GLM has no vision) — 5.3-Flash and 5V-Turbo are
|
||||
// natively multimodal per z.ai, and 5.3-Flash carries the full 1M window.
|
||||
"glm-5.3-flash": { vision: true, videoInput: true, pdf: true, reasoning: true, thinkingFormat: "zai", contextWindow: 1000000, maxOutput: 131072 },
|
||||
"glm-4.6v": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "zai", contextWindow: 128000, maxOutput: 32768 },
|
||||
"glm-4.5v": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "zai", contextWindow: 64000, maxOutput: 16384 },
|
||||
|
||||
// DeepSeek's first V4 model with image input; text limits match V4-Flash.
|
||||
"deepseek-v4-flash-vision-exp": { vision: true, reasoning: true, thinkingFormat: "deepseek", contextWindow: 1000000, maxOutput: 384000 },
|
||||
|
||||
// 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 },
|
||||
// OpenCode Free Muse Spark — multimodal (text+image per models.dev meta/muse-spark)
|
||||
// via OpenAI Responses input_image; reasoning supports up to xhigh.
|
||||
"muse-spark-1.2-contributor-free": { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 1048576, maxOutput: 131072 },
|
||||
"muse-spark-1.3-contributor-free": { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 1048576, maxOutput: 131072 },
|
||||
};
|
||||
|
||||
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-6-astra": { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 },
|
||||
"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
|
||||
// (see registry thinkingFormat). `onlyReasoning` models can't turn thinking
|
||||
// off → thinkingCanDisable:false (clamped to minimal instead of disabled).
|
||||
// (see registry thinkingFormat). For thinkingCanDisable use the server's
|
||||
// reasoning.canDisableThinking flag — see the note in the codebuddy-cn block
|
||||
// below; it is NOT the inverse of onlyReasoning.
|
||||
"codebuddy-cn": {
|
||||
"glm-5.2": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 48000 },
|
||||
"glm-5.1": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 48000 },
|
||||
"glm-5.2": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: true, contextWindow: 1000000, maxOutput: 48000 },
|
||||
"glm-5.1": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 48000 },
|
||||
"glm-5.0": { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 48000 },
|
||||
"glm-5.0-turbo": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 48000 },
|
||||
"glm-5v-turbo": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 38000 },
|
||||
// maxOutput 64000 per both the plugin-baked fallback and the live server
|
||||
// table (the old 38000 had no source and truncated output).
|
||||
"glm-5v-turbo": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 64000 },
|
||||
"glm-4.7": { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 48000 },
|
||||
"minimax-m3": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 512000, maxOutput: 48000 },
|
||||
"minimax-m2.7": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 48000 },
|
||||
"minimax-m3": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 512000, maxOutput: 128000 },
|
||||
"kimi-k2.7": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 256000, maxOutput: 32000 },
|
||||
"kimi-k2.6": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 256000, maxOutput: 32000 },
|
||||
"kimi-k2.5": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 164000, maxOutput: 32000 },
|
||||
"hy3-preview": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 192000, maxOutput: 64000 },
|
||||
"deepseek-v4-pro": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 50000 },
|
||||
"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 },
|
||||
// Per-model values mirror the server's product-config payload (the plugin
|
||||
// fetches it from copilot.tencent.com; the `models[]` entries carry
|
||||
// maxInputTokens/maxOutputTokens/supportsImages). contextWindow =
|
||||
// maxInputTokens, maxOutput = maxOutputTokens. Where the server and the
|
||||
// plugin-baked fallback disagree, the server table wins.
|
||||
// ⚠️ thinkingCanDisable maps to the server's reasoning.canDisableThinking —
|
||||
// it is NOT the inverse of onlyReasoning. onlyReasoning means "thinking is
|
||||
// on by default"; canDisableThinking means "it CAN be turned off". glm-5.3
|
||||
// and glm-5.3-flash are onlyReasoning:true BUT canDisableThinking:true, so
|
||||
// their thinking is switchable; the hy* models are forced always-on.
|
||||
"hy3": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 192000, maxOutput: 64000 },
|
||||
"hy4-preview": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 64000 },
|
||||
"glm-5.3": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: true, contextWindow: 1000000, maxOutput: 48000 },
|
||||
"glm-5.3-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: true, contextWindow: 1000000, maxOutput: 32000 },
|
||||
"kimi-k3-1": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 32000 },
|
||||
"deepseek-v4-pro": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: true, contextWindow: 1000000, maxOutput: 50000 },
|
||||
"deepseek-v4-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: true, contextWindow: 1000000, maxOutput: 50000 },
|
||||
},
|
||||
// Qoder — upstream exposes opaque internal ids (dfmodel, kmodel, …); the
|
||||
// registry `name` is display-only and capability lookup matches on the raw
|
||||
// id, so every qoder model would fall through to DEFAULT_CAPABILITIES
|
||||
// (200K) without this map. contextWindow follows the real model family's
|
||||
// spec: the /algo/api/v2/model/list max_input_tokens under-reports some
|
||||
// windows (GLM-5.3 / Kimi-K3 / Qwen3.8-Max claim 180K but accept more).
|
||||
// max_output_tokens arrives as 0 for every model, so outputs are
|
||||
// best-guess from the real model family. Vision tags below follow the
|
||||
// upstream is_vl flag per explicit request, even though the executor
|
||||
// currently sends image_urls:null (image pass-through over the agent_chat
|
||||
// SSE protocol is unverified). reasoning:true on all of them — every model can
|
||||
// reason; the upstream is_reasoning flag only drives model_config selection.
|
||||
// thinkingFormat keeps the true-model family for documentation/UI, but
|
||||
// thinkingCanDisable:false everywhere: the executor only forwards
|
||||
// messages/tools/max_tokens, and thinking is fixed upstream via
|
||||
// modelConfig.is_reasoning — client thinking intent is dropped, so "none"
|
||||
// must never be offered as an option.
|
||||
"qoder": {
|
||||
"ultimate": { vision: true, reasoning: true, thinkingFormat: "claude-adaptive", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 128000 }, // Claude Opus 5
|
||||
"performance": { vision: true, reasoning: true, thinkingFormat: "claude-adaptive", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 128000 }, // Claude Sonnet 5
|
||||
"dmodel": { reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // DeepSeek-V4-Pro
|
||||
"dfmodel": { reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // DeepSeek-V4-Flash
|
||||
"gmodel": { reasoning: true, thinkingFormat: "zai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 128000 }, // GLM-5.3
|
||||
"gfmodel": { vision: true, reasoning: true, thinkingFormat: "zai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 128000 }, // GLM-5.3-Flash
|
||||
"kmodel_latest": { vision: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // Kimi-K3
|
||||
"kmodel": { vision: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 256000, maxOutput: 65536 }, // Kimi-K2.7-Code
|
||||
"mmodel": { reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 512000 }, // MiniMax-M3
|
||||
"qmodel_latest": { vision: true, reasoning: true, thinkingFormat: "qwen", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // Qwen3.7-Max
|
||||
"qmodel": { vision: true, reasoning: true, thinkingFormat: "qwen", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // Qwen3.7-Plus
|
||||
"qfmodel": { vision: true, reasoning: true, thinkingFormat: "qwen", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // Qwen3.8-Flash
|
||||
"qmodel_38max": { vision: true, reasoning: true, thinkingFormat: "qwen", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // Qwen3.8-Max
|
||||
},
|
||||
// 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 },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -125,6 +258,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" } },
|
||||
@@ -140,6 +274,8 @@ 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.8*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65536 } },
|
||||
{ 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 } },
|
||||
@@ -148,6 +284,9 @@ export const PATTERN_CAPABILITIES = [
|
||||
{ pattern: "*gemma*", caps: { vision: true, contextWindow: 128000 } },
|
||||
{ pattern: "*nanobanana*", caps: { vision: true, imageOutput: true } },
|
||||
|
||||
// ── OpenAI GPT-6.x (vision + thinking + web search) ──────────────
|
||||
{ pattern: "*gpt-6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 } },
|
||||
|
||||
// ── OpenAI GPT-5.x (vision + thinking + web search) ──────────────
|
||||
{ pattern: "*gpt-5*image*", caps: { imageOutput: true } },
|
||||
{ pattern: "*gpt-5*codex*", caps: { reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 400000, maxOutput: 128000 } },
|
||||
@@ -168,25 +307,39 @@ 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.6: 500k context, no text output limit (docs.x.ai/developers/grok-4-6)
|
||||
{ pattern: "*grok-4.6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 500000, maxOutput: 500000 } },
|
||||
// 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 } },
|
||||
|
||||
// ── GLM / Z.ai (thinking.enabled; disable via enable_thinking:false) ─
|
||||
// reasoning_effort is only read by z.ai from GLM-5.2 onward (docs.z.ai/guides/capabilities/thinking) —
|
||||
// older GLM (4.x, 5.0, 5.1, 5-turbo, 5v-turbo) ignore it, so gate it per exact version, not the "*glm-5*" catch-all.
|
||||
{ pattern: "*glm-5.3*", caps: { reasoning: true, thinkingFormat: "zai", thinkingEffortSupported: true, contextWindow: 200000, maxOutput: 128000 } },
|
||||
{ pattern: "*glm-5.2*", caps: { reasoning: true, thinkingFormat: "zai", thinkingEffortSupported: true, contextWindow: 200000, maxOutput: 128000 } },
|
||||
{ pattern: "*glm-5*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000, maxOutput: 128000 } },
|
||||
{ pattern: "*glm-4.7*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000, maxOutput: 128000 } },
|
||||
{ pattern: "*glm-4*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000 } },
|
||||
@@ -206,7 +359,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 } },
|
||||
|
||||
@@ -228,6 +381,16 @@ 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 } },
|
||||
|
||||
|
||||
// ── OpenCode Free Muse Spark (multimodal text+image; OpenAI Responses reasoning supports up to xhigh) ─
|
||||
{ pattern: "*muse*spark*", caps: { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 1048576, maxOutput: 131072 } },
|
||||
// ── Others ───────────────────────────────────────────────────────
|
||||
{ pattern: "*hunyuan*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } },
|
||||
{ pattern: "hy3*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } },
|
||||
@@ -236,6 +399,11 @@ export const PATTERN_CAPABILITIES = [
|
||||
{ pattern: "*ling-*", caps: { reasoning: true, contextWindow: 128000 } },
|
||||
];
|
||||
|
||||
// OpenRouter-style gateways validate modalities upstream — a text-only model
|
||||
// sent an image gets a clear upstream error instead of silent corruption. So for
|
||||
// unknown models on these providers, trust vision instead of stripping images.
|
||||
const TRUST_UPSTREAM_VISION = new Set(["openrouter"]);
|
||||
|
||||
/**
|
||||
* Resolve capabilities for a model using the 4-step fallback chain,
|
||||
* merged over DEFAULT_CAPABILITIES so the result is always complete.
|
||||
@@ -244,26 +412,120 @@ export const PATTERN_CAPABILITIES = [
|
||||
* @param {string} model
|
||||
* @returns {object} full capabilities object
|
||||
*/
|
||||
export function getCapabilitiesForModel(provider, model) {
|
||||
if (!model) return { ...DEFAULT_CAPABILITIES };
|
||||
const MODALITY_KEYS = ["vision", "pdf", "audioInput", "videoInput"];
|
||||
|
||||
// 1. Provider-specific override
|
||||
if (provider && PROVIDER_CAPABILITIES[provider]?.[model]) {
|
||||
return { ...DEFAULT_CAPABILITIES, ...PROVIDER_CAPABILITIES[provider][model] };
|
||||
// ── Server-injected readers ──────────────────────────────────────────
|
||||
// Next.js compiles instrumentation and each API route into SEPARATE server
|
||||
// bundles, so a plain module-local `let` would give every bundle its own copy of
|
||||
// this file and a source installed at boot would be invisible to the request
|
||||
// handlers (silently: the setters still "succeed"). The slots therefore live on
|
||||
// globalThis, which IS shared across server bundles in the same process.
|
||||
// Same reason the browser bundle is safe: it never calls a setter, so the slots
|
||||
// stay empty and every consumer below short-circuits.
|
||||
const SOURCE_SLOTS = (globalThis.__9R_CAPABILITY_SOURCES ||= {
|
||||
catalog: null, // { getModalities, getLimits } — synced models.dev catalog
|
||||
userCaps: null, // (provider, model) => asserted caps — dashboard toggles
|
||||
});
|
||||
|
||||
/**
|
||||
* Install the synced catalog reader (server only).
|
||||
* @param {{ getModalities: Function, getLimits: Function } | null} source
|
||||
*/
|
||||
export function setCatalogSource(source) {
|
||||
SOURCE_SLOTS.catalog = source || null;
|
||||
}
|
||||
|
||||
// Capabilities the user asserted per provider+model (dashboard "Add/Edit Model"
|
||||
// toggles), installed by the server from the custom-model store. Unlike the
|
||||
// catalog and name heuristics this is authoritative and two-directional: it can
|
||||
// turn a capability OFF as well as on.
|
||||
const USER_CAPS_KEYS = ["vision", "pdf", "audioInput", "videoInput", "imageOutput", "audioOutput", "search", "tools", "reasoning", "thinkingFormat", "contextWindow", "maxOutput"];
|
||||
// (slot lives in SOURCE_SLOTS above — see the cross-bundle note)
|
||||
|
||||
/**
|
||||
* Install the user-asserted caps reader (server only).
|
||||
* @param {(provider: string|null, model: string) => object|null} source sync lookup
|
||||
*/
|
||||
export function setUserCapsSource(source) {
|
||||
SOURCE_SLOTS.userCaps = typeof source === "function" ? source : null;
|
||||
}
|
||||
|
||||
// Last step of every resolution path: the user's own assertion wins over any
|
||||
// heuristic, including the tables above (a hand-typed model id can collide with
|
||||
// a pattern entry that describes a different product).
|
||||
function applyUserCaps(result, provider, model) {
|
||||
const userCapsSource = SOURCE_SLOTS.userCaps;
|
||||
if (!userCapsSource) return result;
|
||||
let asserted = null;
|
||||
try {
|
||||
asserted = userCapsSource(provider, model);
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
if (!asserted || typeof asserted !== "object") return result;
|
||||
for (const key of USER_CAPS_KEYS) {
|
||||
if (asserted[key] === undefined) continue;
|
||||
result[key] = asserted[key];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. Canonical exact (strip vendor prefix: "anthropic/claude-opus-4.7" -> "claude-opus-4.7")
|
||||
const baseModel = model.includes("/") ? model.split("/").pop() : model;
|
||||
if (MODEL_CAPABILITIES[baseModel]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[baseModel] };
|
||||
if (MODEL_CAPABILITIES[model]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[model] };
|
||||
// Apply the synced catalog + name heuristic on top of a table-resolved result.
|
||||
// Strictly additive: a capability already true stays true, and a false one only
|
||||
// flips when an outside source positively declares support.
|
||||
function refine(base, provider, model) {
|
||||
const result = { ...DEFAULT_CAPABILITIES, ...base };
|
||||
const catalogSource = SOURCE_SLOTS.catalog;
|
||||
if (catalogSource) {
|
||||
const modalities = catalogSource.getModalities(model);
|
||||
if (modalities) {
|
||||
for (const key of MODALITY_KEYS) {
|
||||
if (modalities[key] === true) result[key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Pattern match (first match wins)
|
||||
for (const { pattern, caps } of PATTERN_CAPABILITIES) {
|
||||
if (matchPattern(pattern, baseModel) || matchPattern(pattern, model)) {
|
||||
return { ...DEFAULT_CAPABILITIES, ...caps };
|
||||
const limits = catalogSource.getLimits(provider, model);
|
||||
if (limits) {
|
||||
if (limits.contextWindow > 0) result.contextWindow = limits.contextWindow;
|
||||
if (limits.maxOutput > 0) result.maxOutput = limits.maxOutput;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Floor
|
||||
return { ...DEFAULT_CAPABILITIES };
|
||||
if (!result.vision && looksLikeVisionModel(model)) result.vision = true;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
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;
|
||||
const resolve = () => {
|
||||
// 1. Provider-specific override
|
||||
if (provider) {
|
||||
const providerCaps = PROVIDER_CAPABILITIES[provider];
|
||||
if (providerCaps?.[model]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[model] };
|
||||
if (providerCaps?.[baseModel]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[baseModel] };
|
||||
}
|
||||
|
||||
// 2. Canonical exact
|
||||
if (MODEL_CAPABILITIES[baseModel]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[baseModel] };
|
||||
if (MODEL_CAPABILITIES[model]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[model] };
|
||||
|
||||
// 3. Pattern match (first match wins), refined by catalog + name heuristic
|
||||
for (const { pattern, caps } of PATTERN_CAPABILITIES) {
|
||||
if (matchPattern(pattern, baseModel) || matchPattern(pattern, model)) {
|
||||
return refine(caps, provider, model);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Floor (upstream-validated gateways keep vision on for unknown models)
|
||||
if (provider && TRUST_UPSTREAM_VISION.has(provider)) {
|
||||
return { ...refine(null, provider, model), vision: true };
|
||||
}
|
||||
return refine(null, provider, model);
|
||||
};
|
||||
|
||||
return applyUserCaps(resolve(), provider, model);
|
||||
}
|
||||
|
||||
72
open-sse/providers/catalogOverride.js
Normal file
72
open-sse/providers/catalogOverride.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// Read side of the model catalog synced from models.dev.
|
||||
//
|
||||
// The file is the source of truth; the only thing held in memory is a parsed
|
||||
// copy dropped as soon as the file's mtime changes. getCapabilitiesForModel is
|
||||
// synchronous and runs per request, so the hot path is one stat (~1us) and the
|
||||
// parse (~0.1ms on a ~18KB file) only reruns after a sync.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DATA_DIR } from "@/lib/dataDir.js";
|
||||
|
||||
export const CATALOG_FILE = path.join(DATA_DIR, "model-catalog.json");
|
||||
// Trimmed upstream catalog, read by the add-models skill (not by the router).
|
||||
export const CATALOG_RAW_FILE = path.join(DATA_DIR, "model-catalog-raw.json");
|
||||
|
||||
const EMPTY = { models: {}, providers: {} };
|
||||
let cache = EMPTY;
|
||||
let cachedMtime = -1;
|
||||
|
||||
// "zai-org/GLM-4.6V:free" -> "glm-4.6v"
|
||||
function baseId(model) {
|
||||
if (!model) return "";
|
||||
const withoutVendor = model.includes("/") ? model.split("/").pop() : model;
|
||||
return withoutVendor.toLowerCase().split(":")[0];
|
||||
}
|
||||
|
||||
function load() {
|
||||
let mtime;
|
||||
try {
|
||||
mtime = fs.statSync(CATALOG_FILE).mtimeMs;
|
||||
} catch {
|
||||
cache = EMPTY;
|
||||
cachedMtime = -1;
|
||||
return cache;
|
||||
}
|
||||
if (mtime === cachedMtime) return cache;
|
||||
|
||||
cachedMtime = mtime;
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(CATALOG_FILE, "utf8"));
|
||||
cache = { models: parsed?.models || {}, providers: parsed?.providers || {} };
|
||||
} catch {
|
||||
cache = EMPTY;
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
// Modality is a property of the model itself — any gateway serving it inherits
|
||||
// the same image/video/pdf support, so this is keyed by model id alone.
|
||||
export function getCatalogModalities(model) {
|
||||
return load().models[baseId(model)] || null;
|
||||
}
|
||||
|
||||
// Context and output limits are a property of the gateway, not the model: each
|
||||
// one truncates differently, so these stay keyed by provider + model.
|
||||
export function getCatalogLimits(provider, model) {
|
||||
const byProvider = provider && load().providers[provider];
|
||||
if (!byProvider) return null;
|
||||
return byProvider[model] || byProvider[baseId(model)] || null;
|
||||
}
|
||||
|
||||
// Force a re-read on the next lookup (called right after a sync writes the file).
|
||||
export function invalidateCatalog() {
|
||||
cachedMtime = -1;
|
||||
}
|
||||
|
||||
// Hand the reader to capabilities.js. That module is bundled into the browser
|
||||
// too, so it cannot import this file directly — the server pushes it in.
|
||||
export async function installCatalogSource() {
|
||||
const { setCatalogSource } = await import("./capabilities.js");
|
||||
setCatalogSource({ getModalities: getCatalogModalities, getLimits: getCatalogLimits });
|
||||
}
|
||||
@@ -18,3 +18,10 @@ export function withCodexReviewModels(models) {
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function isMuseSparkModel(modelId) {
|
||||
if (!modelId || typeof modelId !== "string") return false;
|
||||
const clean = modelId.replace(/\([^()]+\)\s*$/, "").trim();
|
||||
const base = clean.includes("/") ? clean.split("/").pop() : clean;
|
||||
return /^muse[-_]?spark(?:$|[-_:.\s])/i.test(base);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,42 @@ 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 },
|
||||
"gpt-6-astra": { 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.8-flash": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||
"gemini-3.8-flash-high": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||
"gemini-3.8-flash-medium": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||
"gemini-3.8-flash-low": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||
"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 +90,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 +146,127 @@ 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 },
|
||||
"z-ai/glm-5.3-free": { input: 0, output: 0, cached: 0, reasoning: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -140,11 +281,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 +302,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 +325,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 +422,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 +441,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);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
32
open-sse/providers/registry/alims-intl.js
Normal file
32
open-sse/providers/registry/alims-intl.js
Normal 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" },
|
||||
],
|
||||
};
|
||||
35
open-sse/providers/registry/alitp-intl.js
Normal file
35
open-sse/providers/registry/alitp-intl.js
Normal 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" },
|
||||
],
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
@@ -18,26 +17,26 @@ export default {
|
||||
deprecationNotice: "RISK_NOTICE",
|
||||
},
|
||||
category: "oauth",
|
||||
serviceKinds: ["llm", "image"],
|
||||
serviceKinds: ["llm", "image", "webSearch"],
|
||||
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": {
|
||||
attempts: 3,
|
||||
},
|
||||
"500": {
|
||||
attempts: 3,
|
||||
},
|
||||
"503": {
|
||||
attempts: 3,
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
quotaApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels",
|
||||
quotaApiUrl: `${ANTIGRAVITY_IDE_BASE_URL}/v1internal:fetchAvailableModels`,
|
||||
loadProjectApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
tokenUrl: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
@@ -45,6 +44,17 @@ export default {
|
||||
clientSecret: "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",
|
||||
},
|
||||
models: [
|
||||
{ id: "gemini-3.8-flash-high", name: "Gemini 3.8 Flash (High)", upstreamModelId: "gemini-3.8-flash-high(high)" },
|
||||
{ id: "gemini-3.8-flash-medium", name: "Gemini 3.8 Flash (Medium)", upstreamModelId: "gemini-3.8-flash-medium(medium)" },
|
||||
{ id: "gemini-3.8-flash-low", name: "Gemini 3.8 Flash (Low)", upstreamModelId: "gemini-3.8-flash-low(low)" },
|
||||
{ id: "gemini-3.8-flash", name: "Gemini 3.8 Flash", upstreamModelId: "gemini-3.8-flash-medium(medium)" },
|
||||
{ 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)" },
|
||||
@@ -68,14 +78,18 @@ 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,
|
||||
},
|
||||
searchViaChat: {
|
||||
defaultModel: "gemini-2.5-flash",
|
||||
endpoint: `${ANTIGRAVITY_IDE_BASE_URL}/v1internal:generateContent`,
|
||||
freeTier: "Free — Google Search grounding through an Antigravity OAuth account.",
|
||||
},
|
||||
features: {
|
||||
usage: true,
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user