Compare commits

...

32 commits

Author SHA1 Message Date
39ddda477e fix(antigravity): route chat traffic to prod endpoint and dereference in JSON schemas 2026-08-18 18:07:43 -04:00
decolua
699edac327 # v0.5.55 (2026-08-14)
## Features
- **Auth**: native SAML 2.0 SSO alongside OIDC — AuthnRequest generation, ACS
  assertion handling, SP metadata export, admin config test, replay-protected
  via a `saml_state` cookie matched against `InResponseTo`
- **Providers**: add Alibaba Token Plan (`token-plan.ap-southeast-1`) — the
  fourth Alibaba key type, Singapore-only and OpenAI-compatible transport only
- **Providers**: add `glm-5.3` to GLM Coding and GLM (China)
- **Providers**: Kimchi accepts API keys as well as OAuth (dual auth), with a
  working Test Connection for both modes
- **Antigravity**: add Gemini 3.7 Flash and its tiered high/medium/low variants
  (also in the Gemini registry) with pricing and quota tracking
- **TTS**: add Fish Audio — model id travels in an HTTP `model` header, voice
  is a `reference_id` (preset or cloned voice model)
- **OpenCode-Go**: route by request format via declared transports instead of
  forcing every client into `/messages` — Codex/OpenAI clients no longer pay a
  lossy Responses→OpenAI→Claude double translation. Per-model `supportedFormats`
  guard; the bespoke executor is gone (its shared `_lastModel` cache could cross
  auth headers between concurrent requests)
- **Usage**: dedup + cache Claude quota calls (120s TTL keyed by access token,
  in-flight promise dedup, last-good read on soft failure) to stop multiple
  tabs tripping 429; manual refresh (↻) sends `force=1` to bypass the cache

## Fixes
- **Docker**: ship `sql.js` in the image so the pure-JS DB fallback can start —
  file tracing carried the package's JS without `dist/sql-wasm.wasm`, so a
  container with no native driver aborted with ENOENT and never got a database
  (#3248)
- **Usage**: read Gemini `usageMetadata` out of the antigravity `{ response }`
  envelope — every non-streaming antigravity request logged `IN 0 | OUT 0`
  (#3260)
- **Claude**: re-anchor passthrough cache breakpoints — the client's own
  `cache_control` markers point at pre-normalization offsets, so the tail was
  re-cached every request. Last system block and last tool pinned at 1h TTL,
  last assistant turn at 5m, mid-conversation system messages folded into the
  neighbouring user turn instead of hoisted into `body.system`
- **Combos**: detect images from Hermes and attachment payloads (`images[]`,
  `experimental_attachments`, message-level `image_url`/`audio_url`, inline
  `data:` URIs) so the Vision Adapter auto-switch fires for Hermes/Ollama/
  Vercel AI SDK shapes
- **Kiro**: intercept chat via `x-amz-target` — Kiro IDE 1.0.228+ moved
  `GenerateAssistantResponse` to `POST /` + header, bypassing MITM. Also emit
  the now-mandatory initial-response frame and map the `auto` model slot
- **Kiro**: report real output tokens and stop discarding usable turns
- **Qoder**: detect billing blocks at stream start and return a synthetic 403
  so combo/account fallback triggers instead of leaking the error into chat
- **Antigravity**: strip competitive system prompts (Zed IDE's Claude-agent
  prompt) that Antigravity flags with a 429 Quota Exhausted
- **OpenCode**: send the official client fingerprint on free-tier requests so
  the Console stops classifying traffic as unidentified and rate-limiting it;
  session id resolves conversation-stable to preserve prompt caching
- **Responses**: don't close the message on an empty `tool_calls` array — some
  providers attach one to every chunk, and the truthy check ended the message
  on the first content token (#3234)
- **Translator**: preserve `prompt_cache_key` when converting chat to responses
- **Models**: expose snake_case token limits on `/v1/models`
- **Combos**: strip `stream_options` from the Fusion panel fan-out to avoid a
  DeepSeek 400 (#3024); raise the dashboard model-test probe budget to 1024 and
  soft-pass reasoning-only responses (#3010)
- **Headroom**: the toggle reflects the `headroomEnabled` setting even when the
  proxy is down — it previously showed OFF while the engine kept calling
  `/v1/compress`; proxy status stays visible via the status chip
- **Hermes**: add the `api_key` parameter to the model block in YAML config
- **Providers**: add llm7 to provider test support

## Docs
- **i18n**: add Spanish, French, and Brazilian Portuguese README translations

## Security
- **Real IP**: `x-9r-real-ip` and the Host fallback were trusted from
  client-controlled headers whenever `custom-server.js` was not in the request
  path (`npm run start`, `start:bun`), letting a remote caller pose as local to
  skip API key auth and reach `LOCAL_ONLY_PATHS` (`/api/mcp/*`,
  `/api/tunnel/enable`, `/api/auth/reset-password`). The server now stamps a
  per-process `x-9r-peer-token` on every request it sanitizes and only trusts
  `x-9r-real-ip` behind it — falling back to Host in development and failing
  closed in production (GHSA-pjm4-8fpg-f9p6). Also fixes IPv6 loopback
  detection (`::1`, `::ffff:127.0.0.1`) and routes `npm run start` /
  `start:bun` through `custom-server.js`
- **Search**: `resolveBaseUrl()` rejects client-supplied non-public baseUrls
  (SSRF guard on `/v1/search`)
- **Login**: fresh-install remote login with the default password returns 403
  without issuing a JWT
- **Usage**: `/api/usage/request-details` redacts request/response payloads
2026-08-14 17:08:02 +07:00
decolua
540ebbe682 test(baseline): regenerate provider snapshot for opencode-go transports 2026-08-14 16:53:04 +07:00
KiMelody
e1115e2839 feat(opencode-go): route by request format via transports + per-model guard
opencode-go hard-coded targetFormat: claude per model, so every client
format was force-routed to /messages (Codex/OpenAI clients paid a lossy
Responses->OpenAI->Claude double translation). Declare the existing
upstream multi-endpoint transports [openai, claude, openai-responses]
and guard per model via registry supportedFormats: kimi/glm/mimo only
support /chat/completions, minimax/qwen add /messages, deepseek adds
/responses. Undeclared models keep the upstream default.

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

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

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

Fixes #3260
2026-08-14 16:34:54 +07:00
Nguyen Thanh Dat
92259214db fix(security): require proof that x-9r-real-ip came from the socket (GHSA-pjm4-8fpg-f9p6)
x-9r-real-ip and the Host fallback were trusted from client-controlled
headers whenever custom-server.js was not in the request path (npm run
start, start:bun), letting a remote caller pose as local to skip API key
auth and reach LOCAL_ONLY_PATHS (/api/mcp/*, /api/tunnel/enable,
/api/auth/reset-password).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-13 11:27:49 +07:00
105 changed files with 9201 additions and 437 deletions

View file

@ -1,3 +1,89 @@
# 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

View file

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

View file

@ -17,7 +17,7 @@
[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com)
[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) • [🇮🇩 Indonesia](./i18n/README.id-ID.md)
[🇧🇷 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)
</div>

View file

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

View file

@ -216,7 +216,9 @@ function buildCliPackage() {
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");
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.

View file

@ -53,6 +53,9 @@ const PROVIDER_MODELS = {
{ id: "glm-4.7" },
],
ag: [
{ id: "gemini-3.7-flash-high" },
{ id: "gemini-3.7-flash-medium" },
{ id: "gemini-3.7-flash-low" },
{ id: "gemini-3.6-flash-high" },
{ id: "gemini-3.6-flash-medium" },
{ id: "gemini-3.6-flash-low" },

View file

@ -1,9 +1,18 @@
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() {
@ -57,7 +66,9 @@ 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);
};
@ -114,4 +125,15 @@ http.createServer = (...args) => {
return server;
};
if (require.main === module) require("./server.js");
if (require.main === module) {
const standalone = path.join(__dirname, "server.js");
if (fs.existsSync(standalone)) {
require(standalone);
} else {
// Repo checkout has no standalone build next to us. `next start` builds its HTTP
// server in-process, so the wrapper above still sanitizes every request.
const nextBin = require.resolve("next/dist/bin/next");
process.argv = [process.argv[0], nextBin, "start", ...process.argv.slice(2)];
require(nextBin);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

1445
i18n/README.es.md Normal file

File diff suppressed because it is too large Load diff

1445
i18n/README.fr.md Normal file

File diff suppressed because it is too large Load diff

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

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@ 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, normalizeModelId } from "../providers/models/schema.js";
import { modelQuotaFamily, modelStrip, modelTargetFormat, modelSupportedFormats, normalizeModelId } from "../providers/models/schema.js";
import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js";
export { PROVIDER_MODELS };
@ -54,6 +54,14 @@ export function getModelTargetFormat(aliasOrId, 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;

View file

@ -245,6 +245,18 @@ export class AntigravityExecutor extends BaseExecutor {
// Strip tools/toolConfig (handled separately) and blacklisted fields that Google rejects
const { tools: _originalTools, toolConfig: _originalToolConfig, ...requestWithoutTools } = body.request || {};
stripBlacklisted(requestWithoutTools);
// Rewrite competitive system prompts (e.g. Zed IDE's Claude prompt) to prevent Antigravity from
// flagging the request and immediately blocking it with a 429 Quota Exhausted response.
if (requestWithoutTools.systemInstruction?.parts) {
const oldText = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
for (const part of requestWithoutTools.systemInstruction.parts) {
if (typeof part.text === "string" && part.text.includes(oldText)) {
part.text = part.text.split(oldText).join("");
}
}
}
const generationConfig = { ...(requestWithoutTools.generationConfig || {}) };
if (generationConfig.maxOutputTokens > MAX_ANTIGRAVITY_OUTPUT_TOKENS) {
generationConfig.maxOutputTokens = MAX_ANTIGRAVITY_OUTPUT_TOKENS;

View file

@ -10,7 +10,6 @@ import { CodexExecutor } from "./codex.js";
import { CursorExecutor } from "./cursor.js";
import { VertexExecutor } from "./vertex.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";
@ -41,7 +40,6 @@ const executors = {
vertex: new VertexExecutor("vertex"),
"vertex-partner": new VertexExecutor("vertex-partner"),
opencode: new OpenCodeExecutor(),
"opencode-go": new OpenCodeGoExecutor(),
"grok-web": new GrokWebExecutor(),
"grok-cli": new GrokCliExecutor(),
gcli: new GrokCliExecutor(), // Alias
@ -86,7 +84,6 @@ export { CursorExecutor } from "./cursor.js";
export { VertexExecutor } from "./vertex.js";
export { DefaultExecutor } from "./default.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";

View file

@ -144,6 +144,12 @@ function normalizeStopReason(value) {
return reason || null;
}
// Of the reasons stopDisposition() folds into "terminal_incomplete", only these
// mean "usable as far as it got, then the budget ran out" -- the case
// finish_reason "length" exists for. cancelled / pause_turn are abandoned turns
// whose partial content must stay private, so they are deliberately absent.
const KIRO_TRUNCATION_STOP_REASONS = new Set(["model_context_window_exceeded", "max_tokens"]);
function stopDisposition(stopReason, hasToolCalls) {
if (["malformed_model_output", "invalid_model_output"].includes(stopReason)) return "retryable_protocol_failure";
if (["cancelled", "pause_turn", "model_context_window_exceeded"].includes(stopReason)) return "terminal_incomplete";
@ -711,14 +717,25 @@ export class KiroExecutor extends BaseExecutor {
};
const emitTools = (controller) => {
for (const tool of state.tools.values()) {
const input = parsedToolInput(tool);
if (tool.name === "tool_call") {
if (typeof input.name !== "string" || !input.name.trim()) {
throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool name");
}
if (!Object.prototype.hasOwnProperty.call(input, "arguments")) {
throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool arguments");
// Validate per tool, not per turn: one unusable fragment used to throw out
// of emitTools and take every other complete tool call in the same turn
// with it, which the client saw as a turn that answered nothing.
let input;
try {
input = parsedToolInput(tool);
if (tool.name === "tool_call") {
if (typeof input.name !== "string" || !input.name.trim()) {
throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool name");
}
if (!Object.prototype.hasOwnProperty.call(input, "arguments")) {
throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool arguments");
}
}
} catch (error) {
state.droppedTools = (state.droppedTools || 0) + 1;
state.toolValidationError ||= error.message;
console.error(`[Kiro] dropping unusable tool call ${tool.id} (${tool.name}): ${error.message}`);
continue;
}
const index = state.toolCounter++;
emitDelta(controller, {
@ -729,14 +746,26 @@ export class KiroExecutor extends BaseExecutor {
function: { name: tool.name, arguments: "" }
}]
});
const serializedInput = JSON.stringify(input);
emitDelta(controller, {
tool_calls: [{ index, function: { arguments: JSON.stringify(input) } }]
tool_calls: [{ index, function: { arguments: serializedInput } }]
});
// Tool arguments are billed output like any other completion bytes. They
// were never added to totalContentLength, so the /4 estimator in finish()
// reported OUT 0 -- or the Math.max floor of 1 -- for every turn whose
// entire answer was a tool call.
state.totalContentLength += tool.name.length + serializedInput.length;
state.hasToolCalls = true;
}
state.tools.clear();
state.bufferedToolBytes = 0;
if (state.stopReason === "tool_use" && !state.hasToolCalls) {
// A declared tool turn that emitted no usable call is only fatal when the
// turn produced nothing else. Throwing unconditionally here escaped
// emitTools() with provenance "invalid_tool_call", which the integrity gate
// re-derived into a repair retry -- discarding text the client had already
// been promised.
if (state.stopReason === "tool_use" && !state.hasToolCalls &&
!state.hasText && !state.hasReasoning && !state.hasCode) {
throw new Error("Kiro tool_use stop reason did not include a complete tool call");
}
};
@ -796,7 +825,6 @@ export class KiroExecutor extends BaseExecutor {
emitDelta(controller, { content: event.payload.content });
} else if (eventType === "toolUseEvent") {
state.sawToolUse = true;
if (state.toolValidationError) return true;
const values = Array.isArray(event.payload) ? event.payload : [event.payload];
if (!values[0]) throw new Error("Kiro toolUseEvent is empty");
for (const value of values) {
@ -924,9 +952,10 @@ export class KiroExecutor extends BaseExecutor {
} catch (error) {
const bufferExceeded = error.code === "KIRO_BUFFER_EXCEEDED";
if (!bufferExceeded) {
// Keep whatever is already buffered: the rejected fragment belongs to
// one tool, and clearing the map dropped the complete calls too.
state.toolValidationError ||= error.message;
state.tools.clear();
state.bufferedToolBytes = 0;
console.error(`[Kiro] tool fragment rejected, keeping ${state.tools.size} buffered tool(s): ${error.message}`);
continue;
}
fail(
@ -958,7 +987,16 @@ export class KiroExecutor extends BaseExecutor {
}
state.transportState = "clean_eof";
const declaredDisposition = stopDisposition(state.stopReason, state.sawToolUse);
if (["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(declaredDisposition)) {
// model_context_window_exceeded / max_tokens map to terminal_incomplete. When
// they arrive after the model already streamed content, fail() threw away a
// complete-enough answer; a truncated turn is what finish_reason "length" is
// for. chunkIndex > 0 means at least one delta already reached the client.
const declaredTruncatedAfterOutput = declaredDisposition === "terminal_incomplete" &&
KIRO_TRUNCATION_STOP_REASONS.has(state.stopReason) && state.chunkIndex > 0;
if (declaredTruncatedAfterOutput) {
console.error(`[Kiro] truncated after ${state.chunkIndex} chunk(s) (stop_reason=${state.stopReason}); keeping output`);
}
if (!declaredTruncatedAfterOutput && ["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(declaredDisposition)) {
const code = declaredDisposition === "retryable_protocol_failure"
? "kiro_retryable_protocol_failure"
: declaredDisposition === "terminal_refusal"
@ -975,16 +1013,6 @@ export class KiroExecutor extends BaseExecutor {
);
return;
}
if (state.toolValidationError) {
fail(
controller,
"invalid_tool_call",
"invalid_kiro_tool_call",
state.toolValidationError,
{ transport_state: state.transportState, stop_disposition: "retryable_protocol_failure" }
);
return;
}
try {
emitTools(controller);
} catch (error) {
@ -997,6 +1025,22 @@ export class KiroExecutor extends BaseExecutor {
);
return;
}
// Fail only when the turn has nothing usable left. emitTools() validates
// per tool and drops just the unusable ones, so this has to run AFTER it:
// before, the rejected tool was still buffered and tools.size was never 0.
// A turn that also produced text keeps that text -- the dropped call is
// logged, not fatal.
if (state.toolValidationError && !state.hasToolCalls &&
!state.hasText && !state.hasReasoning && !state.hasCode) {
fail(
controller,
"invalid_tool_call",
"invalid_kiro_tool_call",
state.toolValidationError,
{ transport_state: state.transportState, stop_disposition: "retryable_protocol_failure" }
);
return;
}
const hasOutput = state.hasText || state.hasReasoning || state.hasCode || state.hasToolCalls;
if (!hasOutput && !state.explicitStop) {
@ -1011,7 +1055,13 @@ export class KiroExecutor extends BaseExecutor {
}
const disposition = stopDisposition(state.stopReason, state.hasToolCalls);
if (["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(disposition)) {
// Same reasoning as declaredTruncatedAfterOutput above.
const truncatedAfterOutput = disposition === "terminal_incomplete" &&
KIRO_TRUNCATION_STOP_REASONS.has(state.stopReason) && state.chunkIndex > 0;
if (truncatedAfterOutput) {
console.error(`[Kiro] truncated after ${state.chunkIndex} chunk(s) (stop_reason=${state.stopReason}); closing as length`);
}
if (!truncatedAfterOutput && ["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(disposition)) {
const code = disposition === "retryable_protocol_failure"
? "kiro_retryable_protocol_failure"
: disposition === "terminal_refusal"
@ -1041,18 +1091,24 @@ export class KiroExecutor extends BaseExecutor {
total_tokens: prompt + completion
};
}
const finishReason = state.hasToolCalls
? "tool_calls"
: disposition === "length"
? "length"
: "stop";
const finishReason = truncatedAfterOutput
? "length"
: state.hasToolCalls
? "tool_calls"
: disposition === "length"
? "length"
: "stop";
controller.enqueue(sseChunk({}, finishReason, state.usage));
controller.enqueue(encoder.encode(SSE_DONE));
state.finished = true;
options.onTerminalState?.(diagnostics({
terminal_provenance: state.terminalProvenance || "clean_eventstream_eof",
transport_state: state.transportState,
stop_disposition: disposition
// Report what this exit actually did, not the raw disposition. The
// integrity gate re-derives its verdict from stop_disposition, so
// reporting "terminal_incomplete" for a turn we deliberately kept made
// it discard the very bytes we just released to the client.
stop_disposition: truncatedAfterOutput ? "length" : disposition
}));
};

View file

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

View file

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

View file

@ -215,6 +215,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.
@ -229,16 +275,34 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio
* [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 encoder = new TextEncoder();
let buffer = "";
let doneEmitted = false;
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 doneEmitted = false;
// Process one already-extracted SSE line (no trailing newline).
const processLine = (line, controller) => {
const trimmed = line.replace(/\r$/, "").trim();
@ -287,7 +351,28 @@ function wrapQoderSSE(response, model) {
// enqueueing would never be re-invoked, hanging consumers like .text().
async start(controller) {
try {
while (!doneEmitted) {
// 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();
@ -472,7 +557,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 };
}
@ -496,4 +581,5 @@ export const __test__ = {
normalizeMessages,
wrapQoderSSE,
buildQoderRequestBody,
isBillingBlock,
};

View file

@ -2,11 +2,11 @@ import { detectFormat, getTargetFormat, resolveTransport } from "../services/pro
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 { 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, TOKEN_SAVER_HEADER } from "../config/runtimeConfig.js";
@ -78,10 +78,20 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
const modelTargetFormat = getModelTargetFormat(alias, model);
// Multi-endpoint providers: pick transport matching sourceFormat → zero translation
// Multi-endpoint providers: pick transport matching sourceFormat → zero translation.
// Per-model guard: only use the transport when the model declares support for that
// sourceFormat — opencode-go models differ in endpoint support (kimi/glm only do
// /chat/completions), so without this guard a claude-format request would wrongly
// route kimi to /messages.
const modelSupportedFormats = getModelSupportedFormats(alias, model);
const runtimeTransport = resolveTransport(provider, sourceFormat);
const targetFormat = modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider, credentials);
if (runtimeTransport && credentials) credentials.runtimeTransport = runtimeTransport;
// Per-model guard: when a model declares supportedFormats, only use the
// sourceFormat-matched transport if that format is declared (opencode-go models
// differ — kimi/glm only do /chat/completions). Undeclared models keep the
// upstream default (use the transport), preserving behavior for glm/deepseek/...
const useTransport = (!modelSupportedFormats || modelSupportedFormats.includes(sourceFormat)) ? runtimeTransport : null;
const targetFormat = modelTargetFormat || useTransport?.format || getTargetFormat(provider, credentials);
if (useTransport && credentials) credentials.runtimeTransport = useTransport;
const stripList = getModelStrip(alias, model);
const upstreamModel = getModelUpstreamId(alias, model);
@ -275,6 +285,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
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(() => { });

View file

@ -44,13 +44,14 @@ export function extractUsageFromResponse(responseBody) {
};
}
// Gemini format
if (responseBody.usageMetadata) {
// Gemini format. Antigravity / gemini-cli wrap the payload in { response: {...} }.
const usageMetadata = responseBody.usageMetadata || responseBody.response?.usageMetadata;
if (usageMetadata) {
return {
prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0,
completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0,
cached_tokens: responseBody.usageMetadata.cachedContentTokenCount || 0,
reasoning_tokens: responseBody.usageMetadata.thoughtsTokenCount || 0
prompt_tokens: usageMetadata.promptTokenCount || 0,
completion_tokens: usageMetadata.candidatesTokenCount || 0,
cached_tokens: usageMetadata.cachedContentTokenCount || 0,
reasoning_tokens: usageMetadata.thoughtsTokenCount || 0
};
}

View file

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

View file

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

View file

@ -205,6 +205,7 @@ export const PATTERN_CAPABILITIES = [
// ── Gemini (all 2.0+ multimodal + google_search grounding, 1M ctx) ─
{ pattern: "*gemini*image*", caps: { vision: true, imageOutput: true, contextWindow: 1048576 } },
{ pattern: "*gemini-3.7*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65536 } },
{ pattern: "*gemini-3*pro*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65535 } },
{ pattern: "*gemini-3*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65536 } },
{ pattern: "*gemini-2.5*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-budget", thinkingRange: { min: 0, max: 24576 }, contextWindow: 1048576, maxOutput: 65536 } },

View file

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

View file

@ -57,6 +57,10 @@ export const MODEL_PRICING = {
"o1-mini": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 },
// === Gemini ===
"gemini-3.7-flash": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.7-flash-high": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.7-flash-medium": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.7-flash-low": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.6-flash": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.6-flash-high": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.6-flash-medium": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },

View file

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

View file

@ -45,6 +45,9 @@ export default {
clientSecret: "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",
},
models: [
{ id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)", upstreamModelId: "gemini-3.7-flash-tiered(high)" },
{ id: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)", upstreamModelId: "gemini-3.7-flash-tiered(medium)" },
{ id: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash (Low)", upstreamModelId: "gemini-3.7-flash-tiered(low)" },
{ id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", upstreamModelId: "gemini-3.6-flash-tiered(high)" },
{ id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", upstreamModelId: "gemini-3.6-flash-tiered(medium)" },
{ id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)", upstreamModelId: "gemini-3.6-flash-tiered(low)" },
@ -72,7 +75,7 @@ export default {
"https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs",
],
apiEndpoint: "https://daily-cloudcode-pa.googleapis.com",
apiEndpoint: "https://cloudcode-pa.googleapis.com",
apiVersion: "v1internal",
loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
onboardUserEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",

View file

@ -0,0 +1,31 @@
// Fish Audio TTS — the model id travels in an HTTP `model` header rather than the
// JSON body, and the voice is a reference_id (a cloned or preset voice model).
export default {
id: "fish-audio",
alias: "fish",
display: {
name: "Fish Audio",
icon: "record_voice_over",
color: "#1E9BF0",
textIcon: "FA",
website: "https://fish.audio",
notice: {
apiKeyUrl: "https://fish.audio/app/api-keys/",
},
},
category: "apikey",
authType: "apikey",
serviceKinds: ["tts"],
ttsConfig: {
baseUrl: "https://api.fish.audio/v1/tts",
authType: "apikey",
authHeader: "bearer",
format: "fish-audio",
models: [
{ id: "s2.1-pro-free", name: "S2.1 Pro Free" },
{ id: "s2.1-pro", name: "S2.1 Pro" },
{ id: "s2-pro", name: "S2 Pro" },
{ id: "s1", name: "S1" },
],
},
};

View file

@ -36,6 +36,7 @@ export default {
},
},
models: [
{ id: "gemini-3.7-flash", name: "Gemini 3.7 Flash" },
{ id: "gemini-3.6-flash", name: "Gemini 3.6 Flash" },
{ id: "gemini-3.5-flash-lite", name: "Gemini 3.5 Flash Lite" },
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },

View file

@ -21,6 +21,7 @@ export default {
},
},
models: [
{ id: "glm-5.3", name: "GLM 5.3" },
{ id: "glm-5.2", name: "GLM 5.2" },
{ id: "glm-5.1", name: "GLM 5.1" },
{ id: "glm-5", name: "GLM 5" },

View file

@ -45,6 +45,7 @@ export default {
},
],
models: [
{ id: "glm-5.3", name: "GLM 5.3" },
{ id: "glm-5.2", name: "GLM 5.2" },
{ id: "glm-5.1", name: "GLM 5.1" },
{ id: "glm-5", name: "GLM 5" },

View file

@ -119,6 +119,8 @@ import p116 from "./tokenrouter.js";
import p117 from "./selfhosted-stt.js";
import p118 from "./selfhosted-tts.js";
import p119 from "./selfhosted-embedding.js";
import p120 from "./fish-audio.js";
import p121 from "./alitp-intl.js";
export default [
p0,
@ -239,4 +241,6 @@ export default [
p117,
p118,
p119,
p120,
p121,
];

View file

@ -14,7 +14,7 @@ export default {
},
},
category: "freeTier",
authModes: ["oauth"],
authModes: ["oauth", "apikey"],
hasOAuth: true,
transport: {
baseUrl: "https://llm.kimchi.dev/openai/v1/chat/completions",

View file

@ -22,20 +22,28 @@ export default {
baseUrl: "https://opencode.ai/zen/go/v1/chat/completions",
headers: {},
},
// Multi-endpoint: pick the transport matching the client sourceFormat to skip
// translation. Guarded per-model by `supportedFormats` (see chatCore) because
// opencode-go models differ in endpoint support.
transports: [
{ format: "openai", baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", auth: { combined: true, header: "Authorization", scheme: "bearer" } },
{ format: "claude", baseUrl: "https://opencode.ai/zen/go/v1/messages", auth: { combined: true, header: "x-api-key", scheme: "raw", anthropicVersion: true } },
{ format: "openai-responses", baseUrl: "https://opencode.ai/zen/go/v1/responses", auth: { combined: true, header: "Authorization", scheme: "bearer" } },
],
models: [
{ id: "glm-5.2", name: "GLM 5.2" },
{ id: "glm-5.1", name: "GLM 5.1" },
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code" },
{ id: "kimi-k2.6", name: "Kimi K2.6" },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" },
{ id: "mimo-v2.5", name: "MiMo V2.5" },
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" },
{ id: "minimax-m3", name: "MiniMax M3", targetFormat: "claude" },
{ id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" },
{ id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" },
{ id: "qwen3.7-max", name: "Qwen 3.7 Max", targetFormat: "claude" },
{ id: "qwen3.7-plus", name: "Qwen 3.7 Plus", targetFormat: "claude" },
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus", targetFormat: "claude" },
{ id: "glm-5.2", name: "GLM 5.2", supportedFormats: ["openai"] },
{ id: "glm-5.1", name: "GLM 5.1", supportedFormats: ["openai"] },
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code", supportedFormats: ["openai"] },
{ id: "kimi-k2.6", name: "Kimi K2.6", supportedFormats: ["openai"] },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportedFormats: ["openai", "claude", "openai-responses"] },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportedFormats: ["openai", "claude", "openai-responses"] },
{ id: "mimo-v2.5", name: "MiMo V2.5", supportedFormats: ["openai"] },
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", supportedFormats: ["openai"] },
{ id: "minimax-m3", name: "MiniMax M3", supportedFormats: ["openai", "claude"] },
{ id: "minimax-m2.7", name: "MiniMax M2.7", supportedFormats: ["openai", "claude"] },
{ id: "minimax-m2.5", name: "MiniMax M2.5", supportedFormats: ["openai", "claude"] },
{ id: "qwen3.7-max", name: "Qwen 3.7 Max", supportedFormats: ["openai", "claude"] },
{ id: "qwen3.7-plus", name: "Qwen 3.7 Plus", supportedFormats: ["openai", "claude"] },
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus", supportedFormats: ["openai", "claude"] },
],
};

View file

@ -78,7 +78,7 @@ export const ANTHROPIC_COMPAT_BASE = "https://api.anthropic.com/v1";
// Keep this static even when 9router runs on Linux: the provider profile is
// intentionally matching the IDE client, not the server host.
export const ANTIGRAVITY_IDE_VERSION = "2.1.1";
export const ANTIGRAVITY_IDE_BASE_URL = "https://daily-cloudcode-pa.googleapis.com";
export const ANTIGRAVITY_IDE_BASE_URL = "https://cloudcode-pa.googleapis.com";
export const ANTIGRAVITY_IDE_USER_AGENT = `antigravity/ide/${ANTIGRAVITY_IDE_VERSION} darwin/arm64`;
// Antigravity OAuth client credentials (public CLI client — duplicated in usage.js + src/lib/oauth)

View file

@ -138,8 +138,42 @@ export function detectRequiredCapabilities(body) {
if (Array.isArray(content)) for (const b of content) scanBlock(b);
};
const scanMessage = (m) => {
if (!m || typeof m !== "object") return;
// Ollama / Hermes images array (strings or objects)
if (Array.isArray(m.images) && m.images.length > 0) {
required.add("vision");
}
// Vercel AI SDK / Hermes attachments / experimental_attachments
const attachments = m.experimental_attachments || m.attachments;
if (Array.isArray(attachments)) {
for (const att of attachments) {
if (!att) continue;
const mime = att.contentType || att.mediaType || (typeof att.url === "string" && att.url.match(/^data:([^;,]+)/)?.[1]);
if (mime) addByMime(mime);
else if (att.url || att.data) required.add("vision");
}
}
// Direct message-level modality properties
if (m.image_url || m.image) required.add("vision");
if (m.audio_url || m.audio) required.add("audioInput");
// Scan array content blocks
scanContent(m.content);
// Scan string content for embedded data URIs
if (typeof m.content === "string") {
if (m.content.includes("data:image/")) required.add("vision");
else if (m.content.includes("data:audio/")) required.add("audioInput");
else if (m.content.includes("data:application/pdf")) required.add("pdf");
}
};
// Modalities: current user turn only (trailing user run across each known shape).
for (const m of trailingUserItems(body.messages)) scanContent(m.content); // openai / claude
for (const m of trailingUserItems(body.messages)) scanMessage(m); // openai / claude / hermes / ollama
for (const it of trailingUserItems(body.input)) scanContent(it.content); // responses
const contents = body.contents || body.request?.contents; // gemini / antigravity
for (const c of trailingUserItems(contents)) scanContent(c.parts);
@ -530,7 +564,10 @@ export async function handleFusionChat({ body, models, handleSingleModel, log, c
log.info("FUSION", `Combo "${comboName}" | panel=${panel.length} [${panel.join(", ")}] | judge=${judge} | quorum=${minPanel}`);
// 1. Fan out to the panel in parallel: non-streaming, tools stripped (we want prose).
const { tools, tool_choice, ...rest } = body;
const { tools, tool_choice, stream_options, ...rest } = body;
// Fusion runs panel models non-streaming; drop stream_options too, or providers
// like DeepSeek reject it with "stream_options should be set along with stream = true".
// See issue #3024.
const panelBody = { ...rest, stream: false };
// Flatten tool turns to prose so panel models keep context without emitting tool_calls.

View file

@ -33,7 +33,7 @@ const USAGE_HANDLERS = {
github: (c) => getGitHubUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
"gemini-cli": (c) => getGeminiUsage(c.accessToken, c.providerDataWithProjectId, c.proxyOptions),
antigravity: (c) => getAntigravityUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions),
claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions, { force: c.force }),
codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions),
kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
qoder: async (c) => {
@ -56,7 +56,7 @@ const USAGE_HANDLERS = {
deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions),
};
export async function getUsageForProvider(connection, proxyOptions = null) {
export async function getUsageForProvider(connection, proxyOptions = null, options = {}) {
const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection;
const providerDataWithProjectId = {
...(providerSpecificData || {}),
@ -65,5 +65,13 @@ export async function getUsageForProvider(connection, proxyOptions = null) {
const handler = USAGE_HANDLERS[provider];
if (!handler) return { message: `Usage API not implemented for ${provider}` };
return await handler({ provider, accessToken, apiKey, providerSpecificData, providerDataWithProjectId, proxyOptions });
return await handler({
provider,
accessToken,
apiKey,
providerSpecificData,
providerDataWithProjectId,
proxyOptions,
force: options.force === true,
});
}

View file

@ -19,7 +19,43 @@ const CLAUDE_CONFIG = {
const OAUTH_429_COOLDOWN_MS = 180000;
const oauthCooldown = new Map();
export async function getClaudeUsage(accessToken, proxyOptions = null) {
// Dedup + short TTL cache per access token. Many tabs / many accounts / auto-refresh
// all funnel through here; without this each call hits Anthropic and triggers 429.
const USAGE_CACHE_TTL_MS = 300000;
const usageCache = new Map(); // token -> { promise } | { result, expiresAt }
export async function getClaudeUsage(accessToken, proxyOptions = null, options = {}) {
const force = options?.force === true;
// Serve in-flight or fresh cached result (skip on manual force)
if (!force && accessToken) {
const hit = usageCache.get(accessToken);
if (hit?.promise) return hit.promise;
if (hit && hit.expiresAt > Date.now()) return hit.result;
}
const stale = (!force && accessToken && usageCache.get(accessToken)?.result) || null;
const promise = (async () => {
const result = await fetchClaudeUsageRaw(accessToken, proxyOptions);
// Only cache real quota data, not soft-failure {message: ...} payloads
if (accessToken && result?.quotas) {
usageCache.set(accessToken, {
result,
expiresAt: Date.now() + USAGE_CACHE_TTL_MS,
});
return result;
}
// Soft failure (429/error): prefer the last good read over a transient error
if (stale) return stale;
return result;
})();
if (accessToken) usageCache.set(accessToken, { promise });
return promise;
}
async function fetchClaudeUsageRaw(accessToken, proxyOptions = null) {
try {
// Skip OAuth usage call while this token is cooling down from a recent 429
const cooldownUntil = oauthCooldown.get(accessToken);

View file

@ -161,6 +161,9 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro
if (data.models) {
// Filter only recommended/important models (must match PROVIDER_MODELS ag ids)
const importantModels = [
'gemini-3.7-flash-high',
'gemini-3.7-flash-medium',
'gemini-3.7-flash-low',
'gemini-3.6-flash-high',
'gemini-3.6-flash-medium',
'gemini-3.6-flash-low',

View file

@ -62,6 +62,19 @@ function stripOpenAI(body, caps) {
if (!Array.isArray(body.messages)) return;
const last = body.messages.length - 1;
body.messages.forEach((msg, i) => {
if (caps.vision === false) {
if (Array.isArray(msg.images)) delete msg.images;
if (Array.isArray(msg.experimental_attachments)) {
msg.experimental_attachments = msg.experimental_attachments.filter(
(a) => !(a?.contentType?.startsWith("image/") || (typeof a?.url === "string" && a.url.startsWith("data:image/")))
);
}
if (Array.isArray(msg.attachments)) {
msg.attachments = msg.attachments.filter(
(a) => !(a?.contentType?.startsWith("image/") || (typeof a?.url === "string" && a.url.startsWith("data:image/")))
);
}
}
if (!Array.isArray(msg.content)) return;
const removed = new Set();
msg.content = filterBlocks(msg.content, capForOpenAIBlock, caps, removed, i === last);

View file

@ -9,6 +9,9 @@ import { PROVIDERS } from "../../providers/index.js";
import { getCapabilitiesForModel } from "../../providers/capabilities.js";
import { DEFAULT_MAX_TOKENS } from "../../config/runtimeConfig.js";
const CACHE_CONTROL_5M = { type: "ephemeral" };
const CACHE_CONTROL_1H = { type: "ephemeral", ttl: "1h" };
// Check if message has valid non-empty content
export function hasValidContent(msg) {
if (typeof msg.content === "string" && msg.content.trim()) return true;
@ -124,32 +127,38 @@ export function normalizeClaudePassthrough(body, model = "") {
if (Object.keys(body.output_config).length === 0) delete body.output_config;
}
// 2. Hoist mid-conversation system messages into the top-level system field
// 2. Fold mid-conversation system messages into the neighbouring turn.
// Hoisting them into body.system would insert volatile content (token counters,
// reminders) ahead of the whole conversation and invalidate the prefix cache on
// every request. Folding in place keeps the cached prefix stable.
if (Array.isArray(body.messages)) {
const systemBlocks = [];
const messages = [];
for (const msg of body.messages) {
if (msg.role === ROLE.SYSTEM) {
const text = typeof msg.content === "string"
? msg.content
: Array.isArray(msg.content)
? msg.content.map(b => (typeof b === "string" ? b : b?.text || "")).join("\n")
: "";
if (text.trim()) systemBlocks.push({ type: CLAUDE_BLOCK.TEXT, text });
if (msg.role !== ROLE.SYSTEM) {
messages.push(msg);
continue;
}
messages.push(msg);
}
const text = typeof msg.content === "string"
? msg.content
: Array.isArray(msg.content)
? msg.content.map(b => (typeof b === "string" ? b : b?.text || "")).join("\n")
: "";
if (!text.trim()) continue;
if (systemBlocks.length > 0) {
const existing = Array.isArray(body.system)
? body.system
: typeof body.system === "string" && body.system.trim()
? [{ type: "text", text: body.system }]
: [];
body.system = [...existing, ...systemBlocks];
body.messages = messages;
// Copy-on-write: the caller's body is reused across account-fallback
// attempts, so folding must never mutate the original message.
const block = { type: CLAUDE_BLOCK.TEXT, text };
const prev = messages[messages.length - 1];
if (prev?.role === ROLE.USER) {
const content = typeof prev.content === "string"
? [{ type: CLAUDE_BLOCK.TEXT, text: prev.content }]
: Array.isArray(prev.content) ? [...prev.content] : [];
messages[messages.length - 1] = { ...prev, content: [...content, block] };
continue;
}
messages.push({ role: ROLE.USER, content: [block] });
}
body.messages = messages;
}
// 3. Drop thinking blocks whose signature is not Claude's (combo mixes models,
@ -182,6 +191,70 @@ export function normalizeClaudePassthrough(body, model = "") {
return body;
}
// Put a 5m breakpoint on the last cache-eligible block of a message.
// thinking/redacted_thinking blocks do not accept cache_control.
function markLastCacheableBlock(msg) {
if (!Array.isArray(msg?.content)) return false;
for (let i = msg.content.length - 1; i >= 0; i--) {
const block = msg.content[i];
if (typeof block !== "object" || block === null) continue;
if (block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING) continue;
block.cache_control = { ...CACHE_CONTROL_5M };
return true;
}
return false;
}
// Re-anchor cache breakpoints on a Claude passthrough body (same policy as
// prepareClaudeRequest): last tool + last system block at 1h, last assistant at 5m.
// The client's own markers point at pre-normalization offsets, so they are dropped.
// Must run LAST, after every step that can reshape system/tools/messages
// (normalize, tool dedupe, token savers) — otherwise the anchor drifts off the tail.
export function anchorClaudeCache(body) {
if (!body || typeof body !== "object") return body;
if (Array.isArray(body.system)) {
const last = body.system.length - 1;
body.system.forEach((block, i) => {
if (typeof block !== "object" || block === null) return;
if (i === last) block.cache_control = { ...CACHE_CONTROL_1H };
else delete block.cache_control;
});
}
if (Array.isArray(body.tools)) {
const last = body.tools.length - 1;
body.tools.forEach((tool, i) => {
if (i === last) tool.cache_control = { ...CACHE_CONTROL_1H };
else delete tool.cache_control;
});
}
if (Array.isArray(body.messages)) {
let anchored = null;
for (let i = body.messages.length - 1; i >= 0; i--) {
const msg = body.messages[i];
if (!Array.isArray(msg.content)) continue;
for (const block of msg.content) delete block.cache_control;
// Prefer the last assistant turn: it ends a completed exchange, so the
// prefix up to it stays byte-stable across the following requests.
if (anchored || msg.role !== ROLE.ASSISTANT) continue;
anchored = markLastCacheableBlock(msg);
}
// First turn of a conversation has no assistant yet — anchor the final
// message instead, so the opening prompt is cached rather than paid twice.
if (!anchored) {
for (let i = body.messages.length - 1; i >= 0 && !anchored; i--) {
anchored = markLastCacheableBlock(body.messages[i]);
}
}
}
return body;
}
// Prepare request for Claude format endpoints
// - Cleanup cache_control
// - Filter empty messages

View file

@ -311,6 +311,26 @@ function ensureObjectType(obj) {
// Clean JSON Schema for Antigravity API compatibility - removes unsupported keywords recursively
export function cleanJSONSchemaForAntigravity(schema) {
if (!schema || typeof schema !== "object") return schema;
const defs = schema.$defs || schema.definitions || {};
function deref(obj) {
if (!obj || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(deref);
if (obj.$ref && typeof obj.$ref === "string") {
const name = obj.$ref.split("/").pop();
if (defs[name]) return deref(Object.assign({}, defs[name]));
}
const res = {};
for (const [k, v] of Object.entries(obj)) {
if (k === "$defs" || k === "definitions") continue;
res[k] = deref(v);
}
return res;
}
return _orig_cleanJSONSchemaForAntigravity(deref(schema));
}
function _orig_cleanJSONSchemaForAntigravity(schema) {
if (!schema || typeof schema !== "object") return schema;
// Mutate directly (schema is only used once per request)
let cleaned = schema;

View file

@ -287,6 +287,18 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
toolSpecs,
nameMap,
});
// canonicalizeKiroConversation() already ran its second-chance repair (flatten
// every structured tool turn to text, then re-validate). A body that is STILL
// invalid here cannot be made shippable, and Kiro answers it with
// 400 {"message":"Improperly formed request.","reason":"REQUEST_BODY_INVALID"}.
// Fail locally instead: chatCore turns a falsy return into a 400 without
// spending an upstream call or a per-account cooldown. The taxonomy
// (role:N | pair:N | id:N | spec:N | orphan:0 | current) names the offending
// turn so the shape can be diagnosed from the log alone.
if (!canonical.valid) {
console.error(`[Kiro] refusing invalid conversation (claude → kiro): ${(canonical.errors || []).join(", ") || "unknown"} | turns=${(canonical.history || []).length + 1}`);
return null;
}
const replayCurrent = canonical.currentMessage.userInputMessage;
const userInputMessage = {
content: replayCurrent.content || "",

View file

@ -421,6 +421,7 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials)
if (body.reasoning !== undefined) result.reasoning = body.reasoning;
if (body.reasoning_effort !== undefined) result.reasoning = { effort: body.reasoning_effort, summary: "auto" };
if (body.service_tier !== undefined) result.service_tier = body.service_tier;
if (body.prompt_cache_key !== undefined) result.prompt_cache_key = body.prompt_cache_key;
return result;
}

View file

@ -379,6 +379,18 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
toolSpecs,
nameMap,
});
// canonicalizeKiroConversation() already ran its second-chance repair (flatten
// every structured tool turn to text, then re-validate). A body that is STILL
// invalid here cannot be made shippable, and Kiro answers it with
// 400 {"message":"Improperly formed request.","reason":"REQUEST_BODY_INVALID"}.
// Fail locally instead: chatCore turns a falsy return into a 400 without
// spending an upstream call or a per-account cooldown. The taxonomy
// (role:N | pair:N | id:N | spec:N | orphan:0 | current) names the offending
// turn so the shape can be diagnosed from the log alone.
if (!canonical.valid) {
console.error(`[Kiro] refusing invalid conversation (openai → kiro): ${(canonical.errors || []).join(", ") || "unknown"} | turns=${(canonical.history || []).length + 1}`);
return null;
}
const replayCurrent = canonical.currentMessage.userInputMessage;
const payload = {

View file

@ -75,6 +75,15 @@ export function kiroToClaudeResponse(chunk, state) {
? data.usage.completion_tokens
: 0;
state.usage = { input_tokens: promptTokens, output_tokens: outputTokens };
// Claude clients read cache_read/cache_creation to price a turn and to size
// their prompt cache. Both spellings are accepted because the Kiro executor
// emits the Chat shape and passthrough responses use the nested details form.
const cacheRead = data.usage.cache_read_input_tokens
?? data.usage.prompt_tokens_details?.cached_tokens;
const cacheCreation = data.usage.cache_creation_input_tokens
?? data.usage.prompt_tokens_details?.cache_creation_tokens;
if (typeof cacheRead === "number") state.usage.cache_read_input_tokens = cacheRead;
if (typeof cacheCreation === "number") state.usage.cache_creation_input_tokens = cacheCreation;
}
// First chunk → emit message_start.
@ -254,6 +263,13 @@ export function kiroToClaudeNonStreaming(data) {
usage: {
input_tokens: usage.prompt_tokens || 0,
output_tokens: usage.completion_tokens || 0,
// Same cache preservation as the streaming path above.
...(typeof (usage.cache_read_input_tokens ?? usage.prompt_tokens_details?.cached_tokens) === "number"
? { cache_read_input_tokens: usage.cache_read_input_tokens ?? usage.prompt_tokens_details.cached_tokens }
: {}),
...(typeof (usage.cache_creation_input_tokens ?? usage.prompt_tokens_details?.cache_creation_tokens) === "number"
? { cache_creation_input_tokens: usage.cache_creation_input_tokens ?? usage.prompt_tokens_details.cache_creation_tokens }
: {}),
},
};
}

View file

@ -99,8 +99,8 @@ export function openaiToOpenAIResponsesResponse(chunk, state) {
}
}
// Handle tool_calls
if (delta.tool_calls) {
// Handle tool_calls (empty array is truthy; require a real call)
if (delta.tool_calls && delta.tool_calls.length) {
closeMessage(state, emit, idx);
for (const tc of delta.tool_calls) {
emitToolCall(state, emit, tc);

View file

@ -1,6 +1,6 @@
{
"name": "9router-app",
"version": "0.5.50",
"version": "0.5.55",
"description": "9Router web dashboard",
"private": true,
"scripts": {
@ -9,10 +9,10 @@
"build": "next build --webpack",
"postbuild": "node scripts/copy-standalone-assets.mjs",
"postbuild:bun": "node scripts/copy-standalone-assets.mjs",
"start": "next start --port 20127",
"start": "node custom-server.js --port 20127",
"dev:bun": "bun --bun next dev --webpack --port 20127",
"build:bun": "bun --bun next build --webpack",
"start:bun": "bun ./.next/standalone/server.js",
"start:bun": "bun ./.next/standalone/custom-server.js",
"cli:pack": "npm --prefix cli run pack:cli",
"cli:publish": "npm --prefix cli run publish:cli"
},
@ -23,8 +23,10 @@
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.7.0",
"@next/third-parties": "^16.2.9",
"@node-saml/node-saml": "^5.1.0",
"@xyflow/react": "^12.10.1",
"bcryptjs": "^3.0.3",
"chalk": "^5.6.2",
"confbox": "^0.2.4",
"express": "^5.2.1",
"http-proxy-middleware": "^3.0.5",
@ -37,6 +39,7 @@
"node-machine-id": "^1.1.12",
"open": "^11.0.0",
"ora": "^9.1.0",
"prop-types": "^15.8.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-is": "^16.13.1",

View file

@ -29,6 +29,14 @@ export function copyStandaloneAssets({ projectRoot = process.cwd(), distDir = pr
cpSync(publicSource, publicDestination, { recursive: true, force: true });
console.log(`[standalone-assets] Copied public assets to ${publicDestination}`);
}
// Without it beside server.js the standalone build serves requests unsanitized.
const serverWrapperSource = resolve(projectRoot, "custom-server.js");
const serverWrapperDestination = resolve(standaloneDir, "custom-server.js");
if (existsSync(serverWrapperSource)) {
cpSync(serverWrapperSource, serverWrapperDestination, { force: true });
console.log(`[standalone-assets] Copied custom-server.js to ${serverWrapperDestination}`);
}
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(dirname(fileURLToPath(import.meta.url)), "copy-standalone-assets.mjs")) {

View file

@ -170,7 +170,7 @@ export default function HermesToolCard({
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n`;
const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n api_key: \${OPENAI_API_KEY}\n`;
const envContent = `OPENAI_API_KEY=${keyToUse}\n`;
return [

View file

@ -21,7 +21,7 @@ function getLocaleFromCookie() {
export default function ProfilePage() {
const { theme, setTheme, isDark } = useTheme();
const [locale, setLocale] = useState("en");
const [locale, setLocale] = useState(() => getLocaleFromCookie());
const [langOpen, setLangOpen] = useState(false);
const [shutdownOpen, setShutdownOpen] = useState(false);
const [isShuttingDown, setIsShuttingDown] = useState(false);
@ -46,8 +46,31 @@ export default function ProfilePage() {
const [oidcLoading, setOidcLoading] = useState(false);
const [oidcTestLoading, setOidcTestLoading] = useState(false);
const [oidcTestStatus, setOidcTestStatus] = useState({ type: "", message: "" });
const [oidcRedirectUri, setOidcRedirectUri] = useState("/api/auth/oidc/callback");
const [oidcExpanded, setOidcExpanded] = useState(false);
const origin = typeof window !== "undefined" ? window.location.origin : "";
const oidcRedirectUri = origin ? `${origin}/api/auth/oidc/callback` : "/api/auth/oidc/callback";
const samlAcsUrl = origin ? `${origin}/api/auth/saml/acs` : "/api/auth/saml/acs";
const samlMetadataUrl = origin ? `${origin}/api/auth/saml/metadata` : "/api/auth/saml/metadata";
// SAML State
const [ssoTypeTab, setSsoTypeTab] = useState("saml");
const [samlForm, setSamlForm] = useState({
samlEntryPoint: "",
samlIssuer: "urn:9router:sp",
samlCert: "",
samlLoginLabel: "Sign in with SAML SSO",
samlAttributeEmail: "email",
samlAttributeName: "name",
});
const [samlStatus, setSamlStatus] = useState({ type: "", message: "" });
const [samlLoading, setSamlLoading] = useState(false);
const [samlTestLoading, setSamlTestLoading] = useState(false);
const [samlTestStatus, setSamlTestStatus] = useState({ type: "", message: "" });
const [showSamlGuide, setShowSamlGuide] = useState(false);
const idpMetadataFileRef = useRef(null);
const certFileRef = useRef(null);
const importFileRef = useRef(null);
const [proxyForm, setProxyForm] = useState({
outboundProxyEnabled: false,
@ -58,10 +81,6 @@ export default function ProfilePage() {
const [proxyLoading, setProxyLoading] = useState(false);
const [proxyTestLoading, setProxyTestLoading] = useState(false);
useEffect(() => {
setLocale(getLocaleFromCookie());
}, [langOpen]);
useEffect(() => {
fetch("/api/settings")
.then((res) => res.json())
@ -75,7 +94,23 @@ export default function ProfilePage() {
oidcLoginLabel: data?.oidcLoginLabel || "Sign in with OIDC",
});
setOidcClientSecret("");
if (data?.authMode === "oidc" || data?.authMode === "both") setOidcExpanded(true);
setSsoTypeTab(data?.ssoType || "saml");
setSamlForm({
samlEntryPoint: data?.samlEntryPoint || "",
samlIssuer: data?.samlIssuer || "urn:9router:sp",
samlCert: data?.samlCert || "",
samlLoginLabel: data?.samlLoginLabel || "Sign in with SAML SSO",
samlAttributeEmail: data?.samlAttributeEmail || "email",
samlAttributeName: data?.samlAttributeName || "name",
});
if (
data?.authMode === "sso" ||
data?.authMode === "saml" ||
data?.authMode === "oidc" ||
data?.authMode === "both"
) {
setOidcExpanded(true);
}
setProxyForm({
outboundProxyEnabled: data?.outboundProxyEnabled === true,
outboundProxyUrl: data?.outboundProxyUrl || "",
@ -89,12 +124,6 @@ export default function ProfilePage() {
});
}, []);
useEffect(() => {
if (typeof window !== "undefined") {
setOidcRedirectUri(`${window.location.origin}/api/auth/oidc/callback`);
}
}, []);
const updateOutboundProxy = async (e) => {
e.preventDefault();
if (settings.outboundProxyEnabled !== true) return;
@ -331,6 +360,7 @@ export default function ProfilePage() {
try {
const payload = {
authMode,
ssoType: "oidc",
oidcIssuerUrl: issuerUrl,
oidcClientId: clientId,
oidcScopes: scopes || "openid profile email",
@ -445,6 +475,159 @@ export default function ProfilePage() {
}
};
const updateSamlForm = (field, value) => {
setSamlForm((prev) => ({ ...prev, [field]: value }));
};
const handleIdpMetadataUpload = (event) => {
const file = event.target.files?.[0];
if (idpMetadataFileRef.current) idpMetadataFileRef.current.value = "";
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const xmlText = e.target?.result || "";
const parser = new DOMParser();
const doc = parser.parseFromString(xmlText, "text/xml");
const parserError = doc.querySelector("parsererror");
if (parserError) {
setSamlStatus({ type: "error", message: "Unable to parse valid SAML IdP metadata from XML file" });
return;
}
const entityID = doc.documentElement.getAttribute("entityID") || "";
const ssoNodes = Array.from(doc.querySelectorAll("SingleSignOnService, *|SingleSignOnService"));
let ssoUrl = "";
for (const node of ssoNodes) {
const binding = node.getAttribute("Binding") || "";
const location = node.getAttribute("Location") || "";
if (location) {
ssoUrl = location;
if (binding.includes("HTTP-Redirect")) break;
}
}
const certNodes = Array.from(doc.querySelectorAll("X509Certificate, *|X509Certificate"));
let certStr = "";
if (certNodes.length > 0) {
certStr = certNodes[0].textContent.trim();
}
setSamlForm((prev) => ({
...prev,
samlEntryPoint: ssoUrl || prev.samlEntryPoint,
samlIssuer: prev.samlIssuer || "urn:9router:sp",
samlCert: certStr || prev.samlCert,
}));
setSamlStatus({
type: "success",
message: `IdP Metadata imported! (SSO URL: ${ssoUrl ? "found" : "not found"}, EntityID: ${entityID ? "found" : "not found"}, Cert: ${certStr ? "found" : "not found"})`,
});
} catch (err) {
setSamlStatus({ type: "error", message: "Error reading IdP Metadata XML file" });
}
};
reader.readAsText(file);
};
const handleCertFileUpload = (event) => {
const file = event.target.files?.[0];
if (certFileRef.current) certFileRef.current.value = "";
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result || "";
setSamlForm((prev) => ({ ...prev, samlCert: text.trim() }));
setSamlStatus({ type: "success", message: "Certificate file loaded into configuration." });
};
reader.readAsText(file);
};
const saveSamlSettings = async (targetAuthMode = oidcForm.authMode || "password") => {
setSamlLoading(true);
setSamlStatus({ type: "", message: "" });
setSamlTestStatus({ type: "", message: "" });
try {
const payload = {
authMode: targetAuthMode,
ssoType: "saml",
samlEntryPoint: samlForm.samlEntryPoint.trim(),
samlIssuer: samlForm.samlIssuer.trim() || "urn:9router:sp",
samlCert: samlForm.samlCert.trim(),
samlLoginLabel: samlForm.samlLoginLabel.trim() || "Sign in with SAML SSO",
samlAttributeEmail: samlForm.samlAttributeEmail.trim() || "email",
samlAttributeName: samlForm.samlAttributeName.trim() || "name",
};
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (res.ok) {
setSettings((prev) => ({ ...prev, ...data }));
setSamlForm({
samlEntryPoint: data?.samlEntryPoint || payload.samlEntryPoint,
samlIssuer: data?.samlIssuer || payload.samlIssuer,
samlCert: data?.samlCert || payload.samlCert,
samlLoginLabel: data?.samlLoginLabel || payload.samlLoginLabel,
samlAttributeEmail: data?.samlAttributeEmail || payload.samlAttributeEmail,
samlAttributeName: data?.samlAttributeName || payload.samlAttributeName,
});
setSamlStatus({
type: "success",
message:
targetAuthMode === "sso" || targetAuthMode === "saml"
? "SAML SSO login enabled"
: targetAuthMode === "both"
? "Password and SAML SSO login enabled"
: "SAML 2.0 settings saved",
});
} else {
setSamlStatus({ type: "error", message: data.error || "Failed to save SAML settings" });
}
} catch {
setSamlStatus({ type: "error", message: "An error occurred while saving SAML settings" });
} finally {
setSamlLoading(false);
}
};
const testSamlConnection = async () => {
setSamlTestLoading(true);
setSamlStatus({ type: "", message: "" });
setSamlTestStatus({ type: "", message: "" });
try {
const res = await fetch("/api/auth/saml/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
samlEntryPoint: samlForm.samlEntryPoint.trim(),
samlIssuer: samlForm.samlIssuer.trim(),
samlCert: samlForm.samlCert.trim(),
}),
});
const data = await res.json();
if (res.ok && data.ok) {
setSamlTestStatus({ type: "success", message: data.message || "SAML configuration verified!" });
} else {
setSamlTestStatus({ type: "error", message: data.error || "SAML configuration test failed" });
}
} catch {
setSamlTestStatus({ type: "error", message: "An error occurred while testing SAML configuration" });
} finally {
setSamlTestLoading(false);
}
};
const updateObservabilityEnabled = async (enabled) => {
try {
const res = await fetch("/api/settings", {
@ -752,7 +935,7 @@ export default function ProfilePage() {
</div>
</Card>
{/* OIDC */}
{/* Single Sign-On (SSO) */}
<Card>
<button
type="button"
@ -763,9 +946,13 @@ export default function ProfilePage() {
<span className="material-symbols-outlined text-[20px]">lock_open</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold">OIDC Dashboard Login</h3>
<h3 className="text-base sm:text-lg font-semibold">Single Sign-On (SSO)</h3>
<p className="text-xs text-text-muted">
{settings.authMode === "oidc" ? "OIDC active" : settings.authMode === "both" ? "Password + OIDC active" : "Optional SSO via Authentik/Keycloak/Google"}
{settings.authMode === "sso" || settings.authMode === "oidc" || settings.authMode === "saml"
? `${settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"} SSO active`
: settings.authMode === "both"
? `Password + ${settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"} active`
: "Optional SSO via Okta, Entra ID, Keycloak, or OIDC"}
</p>
</div>
<span className="material-symbols-outlined text-text-muted shrink-0">
@ -773,145 +960,472 @@ export default function ProfilePage() {
</span>
</button>
{oidcExpanded && (
<div className="flex flex-col gap-4 mt-4">
<p className="text-xs sm:text-sm text-text-muted">
Use Authentik or any OIDC provider to sign in to the dashboard. You can enable password-only, OIDC-only, or both for the dashboard; model API access still uses API keys.
</p>
<div className="flex flex-col gap-4 mt-4">
<p className="text-xs sm:text-sm text-text-muted">
Configure enterprise Single Sign-On (SSO) for dashboard access using SAML 2.0 or OIDC.
</p>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Auth Mode</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{[
{
value: "password",
title: "Password only",
desc: "Keep the legacy password login.",
},
{
value: "oidc",
title: "OIDC only",
desc: "Require OIDC for dashboard access.",
},
{
value: "both",
title: "Both",
desc: "Allow either password or OIDC.",
},
].map((option) => {
const active = oidcForm.authMode === option.value;
return (
{/* SSO Protocol Switcher Tabs */}
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">SSO Protocol</label>
<div className="flex p-1 rounded-lg bg-black/5 dark:bg-white/5 border border-border">
<button
type="button"
onClick={() => setSsoTypeTab("saml")}
className={cn(
"flex-1 py-1.5 px-3 rounded-md font-medium text-xs sm:text-sm transition-all text-center",
ssoTypeTab === "saml"
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
: "text-text-muted hover:text-text-main"
)}
>
SAML 2.0
</button>
<button
type="button"
onClick={() => setSsoTypeTab("oidc")}
className={cn(
"flex-1 py-1.5 px-3 rounded-md font-medium text-xs sm:text-sm transition-all text-center",
ssoTypeTab === "oidc"
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
: "text-text-muted hover:text-text-main"
)}
>
OIDC
</button>
</div>
</div>
{/* Auth Mode selection */}
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Auth Mode</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{[
{
value: "password",
title: "Password only",
desc: "Keep legacy password login.",
},
{
value: "sso",
title: `${ssoTypeTab === "saml" ? "SAML" : "OIDC"} only`,
desc: "Require SSO for dashboard access.",
},
{
value: "both",
title: "Both",
desc: "Allow password or SSO login.",
},
].map((option) => {
const currentMode = oidcForm.authMode;
const active =
option.value === "password"
? currentMode === "password"
: option.value === "sso"
? currentMode === "sso" || currentMode === "saml" || currentMode === "oidc"
: currentMode === "both";
return (
<button
key={option.value}
type="button"
onClick={() => updateOidcForm("authMode", option.value)}
className={cn(
"text-left rounded-lg border p-3 transition-colors",
active
? "border-primary bg-primary/5"
: "border-border bg-bg hover:bg-black/5 dark:hover:bg-white/5"
)}
disabled={loading || oidcLoading || samlLoading}
>
<p className="font-medium text-sm sm:text-base">{option.title}</p>
<p className="text-xs sm:text-sm text-text-muted mt-1">{option.desc}</p>
</button>
);
})}
</div>
</div>
{ssoTypeTab === "saml" ? (
/* SAML Configuration Panel */
<div className="flex flex-col gap-4 pt-2 border-t border-border/50">
{/* IdP Setup Guidelines Banner & Collapsible Drawer */}
<div className="rounded-lg border border-border bg-bg/80 overflow-hidden">
<button
key={option.value}
type="button"
onClick={() => updateOidcForm("authMode", option.value)}
className={cn(
"text-left rounded-lg border p-3 transition-colors",
active
? "border-primary bg-primary/5"
: "border-border bg-bg hover:bg-black/5 dark:hover:bg-white/5"
)}
disabled={loading || oidcLoading}
onClick={() => setShowSamlGuide((prev) => !prev)}
className="w-full p-3 flex items-center justify-between gap-2 text-left hover:bg-surface/50 transition-colors"
>
<p className="font-medium text-sm sm:text-base">{option.title}</p>
<p className="text-xs sm:text-sm text-text-muted mt-1">{option.desc}</p>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-lg">menu_book</span>
<div>
<p className="font-semibold text-xs sm:text-sm text-text-main">
IdP Setup Guidelines & Provider Configuration Instructions
</p>
<p className="text-[11px] text-text-muted">
Click to view setup steps for AWS IAM Identity Center, Okta, Entra ID, Keycloak, & Authentik
</p>
</div>
</div>
<span
className="material-symbols-outlined text-text-muted transition-transform text-lg"
style={{ transform: showSamlGuide ? "rotate(180deg)" : "none" }}
>
expand_more
</span>
</button>
);
})}
</div>
{showSamlGuide && (
<div className="p-4 border-t border-border bg-surface/30 text-xs text-text-main flex flex-col gap-3">
<div className="p-2.5 rounded border border-primary/20 bg-primary/5 text-primary text-xs">
<p className="font-semibold mb-1">🔑 Required Service Provider (SP) Values for your IdP Setup:</p>
<ul className="list-disc pl-4 space-y-1 font-mono text-[11px]">
<li>
<b>Assertion Consumer Service (ACS) URL:</b>{" "}
<code className="bg-bg px-1 py-0.5 rounded break-all">{samlAcsUrl}</code>
</li>
<li>
<b>SP Entity ID / Audience URI:</b>{" "}
<code className="bg-bg px-1 py-0.5 rounded break-all">{samlForm.samlIssuer || "urn:9router:sp"}</code>
</li>
<li>
<b>NameID Format:</b>{" "}
<code className="bg-bg px-1 py-0.5 rounded">EmailAddress</code> or <code className="bg-bg px-1 py-0.5 rounded">Unspecified</code>
</li>
</ul>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 pt-1">
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
<p className="font-semibold text-text-main flex items-center gap-1.5">
<span>☁️</span> AWS IAM Identity Center
</p>
<ol className="list-decimal pl-4 text-text-muted space-y-1">
<li>Applications → <b>Add application</b> → Select <b>Add custom SAML 2.0 application</b>.</li>
<li>Set <b>Application ACS URL</b> to <code className="text-text-main font-mono">{samlAcsUrl}</code>.</li>
<li>Set <b>Application SAML audience</b> to <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code>.</li>
<li>Under <i>Attribute mappings</i>, map <code className="text-text-main font-mono">Subject</code> or <code className="text-text-main font-mono">email</code> to <code className="text-text-main font-mono">${`{user:email}`}</code>.</li>
<li>Download <b>IAM Identity Center SAML metadata XML</b> file and use 1-Click Import below!</li>
</ol>
</div>
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
<p className="font-semibold text-text-main flex items-center gap-1.5">
<span>🔷</span> Microsoft Entra ID (Azure AD)
</p>
<ol className="list-decimal pl-4 text-text-muted space-y-1">
<li>Enterprise Applications → <b>New application</b> → <b>Create your own application</b>.</li>
<li>Select <b>Single sign-on</b> → <b>SAML</b>.</li>
<li><b>Identifier (Entity ID):</b> <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code></li>
<li><b>Reply URL (ACS):</b> <code className="text-text-main font-mono">{samlAcsUrl}</code></li>
<li>Download <b>Federation Metadata XML</b> and import or copy X.509 Certificate.</li>
</ol>
</div>
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
<p className="font-semibold text-text-main flex items-center gap-1.5">
<span>🟢</span> Okta / Auth0
</p>
<ol className="list-decimal pl-4 text-text-muted space-y-1">
<li>Applications → <b>Create App Integration</b> → Select <b>SAML 2.0</b>.</li>
<li><b>Single Sign-On URL:</b> <code className="text-text-main font-mono">{samlAcsUrl}</code></li>
<li><b>Audience URI (SP Entity ID):</b> <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code></li>
<li>Name ID format: <i>EmailAddress</i>.</li>
<li>Download Identity Provider metadata XML or copy the X.509 cert.</li>
</ol>
</div>
<div className="p-3 rounded border border-border bg-bg/50 flex flex-col gap-1.5">
<p className="font-semibold text-text-main flex items-center gap-1.5">
<span>🛡️</span> Keycloak / Authentik
</p>
<ol className="list-decimal pl-4 text-text-muted space-y-1">
<li>Clients → <b>Create client</b> → Select <b>SAML</b>.</li>
<li><b>Client ID:</b> <code className="text-text-main font-mono">{samlForm.samlIssuer || "urn:9router:sp"}</code></li>
<li><b>Master SAML Processing URL:</b> <code className="text-text-main font-mono">{samlAcsUrl}</code></li>
<li>Export SAML Descriptor XML or copy IDP Certificate PEM.</li>
</ol>
</div>
</div>
</div>
)}
</div>
{/* Quick Import Card */}
<div className="p-3 rounded-lg border border-dashed border-primary/40 bg-primary/5 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<p className="font-medium text-sm text-text-main">1-Click IdP Metadata XML Import</p>
<p className="text-xs text-text-muted">Auto-fill SSO URL, Issuer & Cert from XML metadata</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
icon="upload_file"
onClick={() => idpMetadataFileRef.current?.click()}
>
Upload Metadata XML
</Button>
<input
ref={idpMetadataFileRef}
type="file"
accept=".xml,application/xml,text/xml"
className="hidden"
onChange={handleIdpMetadataUpload}
/>
</div>
<div className="grid grid-cols-1 gap-4">
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Single Sign-On Service URL (samlEntryPoint)</label>
<Input
placeholder="https://idp.example.com/app/saml/sso/..."
value={samlForm.samlEntryPoint}
onChange={(e) => updateSamlForm("samlEntryPoint", e.target.value)}
disabled={loading || samlLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">SP Entity ID / Audience (samlIssuer)</label>
<Input
placeholder="urn:9router:sp"
value={samlForm.samlIssuer}
onChange={(e) => updateSamlForm("samlIssuer", e.target.value)}
disabled={loading || samlLoading}
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<label className="font-medium text-sm sm:text-base">IdP X.509 Certificate (samlCert)</label>
<Button
type="button"
variant="outline"
size="sm"
icon="file_upload"
onClick={() => certFileRef.current?.click()}
>
Upload Certificate
</Button>
<input
ref={certFileRef}
type="file"
accept=".crt,.pem,.cer,text/plain"
className="hidden"
onChange={handleCertFileUpload}
/>
</div>
<textarea
rows={4}
placeholder="-----BEGIN CERTIFICATE-----&#10;MIIC...&#10;-----END CERTIFICATE-----"
value={samlForm.samlCert}
onChange={(e) => updateSamlForm("samlCert", e.target.value)}
className="w-full p-2.5 rounded-lg border border-border bg-bg text-xs font-mono text-text-main focus:outline-none focus:border-primary"
disabled={loading || samlLoading}
/>
<p className="text-xs text-text-muted">Paste raw Base64 certificate or PEM block.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Login Button Label</label>
<Input
placeholder="Sign in with SAML SSO"
value={samlForm.samlLoginLabel}
onChange={(e) => updateSamlForm("samlLoginLabel", e.target.value)}
disabled={loading || samlLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Email Claim Attribute</label>
<Input
placeholder="email"
value={samlForm.samlAttributeEmail}
onChange={(e) => updateSamlForm("samlAttributeEmail", e.target.value)}
disabled={loading || samlLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Display Name Claim</label>
<Input
placeholder="name"
value={samlForm.samlAttributeName}
onChange={(e) => updateSamlForm("samlAttributeName", e.target.value)}
disabled={loading || samlLoading}
/>
</div>
</div>
</div>
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-bg text-xs sm:text-sm text-text-muted">
<div className="flex items-center justify-between gap-2">
<div>
<p className="font-medium text-text-main">ACS Callback URL</p>
<code className="block break-all font-mono text-xs">{samlAcsUrl}</code>
</div>
<Button
type="button"
variant="outline"
size="sm"
icon="content_copy"
onClick={() => {
navigator.clipboard.writeText(samlAcsUrl);
setSamlStatus({ type: "success", message: "ACS URL copied to clipboard!" });
}}
>
Copy
</Button>
</div>
<div className="flex items-center justify-between gap-2 pt-2 border-t border-border/50">
<div>
<p className="font-medium text-text-main">SP XML Metadata</p>
<code className="block break-all font-mono text-xs">{samlMetadataUrl}</code>
</div>
<a
href={samlMetadataUrl}
target="_blank"
rel="noopener noreferrer"
download="9router-sp-metadata.xml"
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
<span className="material-symbols-outlined text-[16px]">download</span>
Download XML
</a>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-border/50">
<Button
type="button"
variant="primary"
loading={samlLoading}
onClick={() => saveSamlSettings(oidcForm.authMode)}
className="w-full sm:w-auto"
>
Save SAML settings
</Button>
<Button
type="button"
variant="outline"
loading={samlTestLoading}
onClick={testSamlConnection}
className="w-full sm:w-auto"
>
Test SAML settings
</Button>
</div>
{samlTestStatus.message && (
<p className={`text-xs sm:text-sm ${samlTestStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
{samlTestStatus.message}
</p>
)}
{samlStatus.message && (
<p className={`text-xs sm:text-sm ${samlStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
{samlStatus.message}
</p>
)}
</div>
) : (
/* OIDC Panel */
<div className="flex flex-col gap-4 pt-2 border-t border-border/50">
<div className="grid grid-cols-1 gap-4">
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Issuer URL</label>
<Input
placeholder="https://auth.example.com/application/o/9router/"
value={oidcForm.oidcIssuerUrl}
onChange={(e) => updateOidcForm("oidcIssuerUrl", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Client ID</label>
<Input
placeholder="9router-dashboard"
value={oidcForm.oidcClientId}
onChange={(e) => updateOidcForm("oidcClientId", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Client Secret</label>
<Input
type="password"
placeholder="Leave blank to keep existing secret"
value={oidcClientSecret}
onChange={(e) => setOidcClientSecret(e.target.value)}
disabled={loading || oidcLoading}
/>
<p className="text-xs sm:text-sm text-text-muted">This value is write-only after saving.</p>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Scopes</label>
<Input
placeholder="openid profile email"
value={oidcForm.oidcScopes}
onChange={(e) => updateOidcForm("oidcScopes", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Login Button Label</label>
<Input
placeholder="Sign in with OIDC"
value={oidcForm.oidcLoginLabel}
onChange={(e) => updateOidcForm("oidcLoginLabel", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
</div>
<div className="rounded-lg border border-border bg-bg p-3 text-xs sm:text-sm text-text-muted">
<p className="font-medium text-text-main mb-1">Redirect URI</p>
<code className="block break-all font-mono">{oidcRedirectUri}</code>
</div>
<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-border/50">
<Button type="button" variant="primary" loading={oidcLoading} onClick={() => saveOidcSettings()} className="w-full sm:w-auto">
Save OIDC settings
</Button>
<Button type="button" variant="outline" loading={oidcTestLoading} onClick={testOidcConnection} className="w-full sm:w-auto">
Test connection
</Button>
</div>
{oidcTestStatus.message && (
<p className={`text-xs sm:text-sm ${oidcTestStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
{oidcTestStatus.message}
</p>
)}
{oidcStatus.message && (
<p className={`text-xs sm:text-sm ${oidcStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
{oidcStatus.message}
</p>
)}
</div>
)}
{settings.authMode === "oidc" || settings.authMode === "saml" || settings.authMode === "sso" ? (
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
SSO login ({settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"}) is currently active. Password login is disabled until you switch back.
</p>
) : null}
{settings.authMode === "both" && (
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
Password and SSO login ({settings.ssoType === "saml" ? "SAML 2.0" : "OIDC"}) are both active.
</p>
)}
</div>
<div className="grid grid-cols-1 gap-4">
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Issuer URL</label>
<Input
placeholder="https://auth.example.com/application/o/9router/"
value={oidcForm.oidcIssuerUrl}
onChange={(e) => updateOidcForm("oidcIssuerUrl", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Client ID</label>
<Input
placeholder="9router-dashboard"
value={oidcForm.oidcClientId}
onChange={(e) => updateOidcForm("oidcClientId", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Client Secret</label>
<Input
type="password"
placeholder="Leave blank to keep existing secret"
value={oidcClientSecret}
onChange={(e) => setOidcClientSecret(e.target.value)}
disabled={loading || oidcLoading}
/>
<p className="text-xs sm:text-sm text-text-muted">This value is write-only after saving.</p>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Scopes</label>
<Input
placeholder="openid profile email"
value={oidcForm.oidcScopes}
onChange={(e) => updateOidcForm("oidcScopes", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm sm:text-base">Login Button Label</label>
<Input
placeholder="Sign in with OIDC"
value={oidcForm.oidcLoginLabel}
onChange={(e) => updateOidcForm("oidcLoginLabel", e.target.value)}
disabled={loading || oidcLoading}
/>
</div>
</div>
<div className="rounded-lg border border-border bg-bg p-3 text-xs sm:text-sm text-text-muted">
<p className="font-medium text-text-main mb-1">Redirect URI</p>
<code className="block break-all font-mono">{oidcRedirectUri}</code>
</div>
<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-border/50">
<Button type="button" variant="primary" loading={oidcLoading} onClick={() => saveOidcSettings()} className="w-full sm:w-auto">
Save auth mode
</Button>
<Button type="button" variant="outline" loading={oidcTestLoading} onClick={testOidcConnection} className="w-full sm:w-auto">
Test connection
</Button>
</div>
{oidcTestStatus.message && (
<p className={`text-xs sm:text-sm ${oidcTestStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
{oidcTestStatus.message}
</p>
)}
{oidcStatus.message && (
<p className={`text-xs sm:text-sm ${oidcStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>
{oidcStatus.message}
</p>
)}
{settings.authMode === "oidc" && (
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
OIDC login is currently active. Password login is disabled until you switch back.
</p>
)}
{settings.authMode === "both" && (
<p className="text-xs sm:text-sm text-amber-600 dark:text-amber-400">
Password and OIDC login are both active.
</p>
)}
</div>
)}
</Card>

View file

@ -529,8 +529,7 @@ export default function TokenSaverClient() {
</p>
</div>
<Toggle
checked={headroomEnabled && headroomRunning}
disabled={!headroomRunning}
checked={headroomEnabled}
onChange={() => handleHeadroomEnabled(!headroomEnabled)}
/>
</div>

View file

@ -216,7 +216,7 @@ export default function ProviderLimits() {
);
// Fetch quota for a specific connection
const fetchQuota = useCallback(async (connectionId, provider) => {
const fetchQuota = useCallback(async (connectionId, provider, { force = false } = {}) => {
setLoading((prev) => ({ ...prev, [connectionId]: true }));
setErrors((prev) => ({ ...prev, [connectionId]: null }));
@ -224,7 +224,8 @@ export default function ProviderLimits() {
console.log(
`[ProviderLimits] Fetching quota for ${provider} (${connectionId})`,
);
const response = await fetch(`/api/usage/${connectionId}`);
const url = `/api/usage/${connectionId}${force ? "?force=1" : ""}`;
const response = await fetch(url);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
@ -295,7 +296,7 @@ export default function ProviderLimits() {
// Refresh quota for a specific provider
const refreshProvider = useCallback(
async (connectionId, provider) => {
await fetchQuota(connectionId, provider);
await fetchQuota(connectionId, provider, { force: true });
setLastUpdated(new Date());
},
[fetchQuota],

View file

@ -4,7 +4,7 @@ import { getModelsByProviderId } from "open-sse/config/providerModels.js";
export const QUOTA_CACHE_KEY = "quotaCacheData";
export const REFRESH_INTERVAL_MS = 60000;
// Claude usage/quota endpoint rate-limits; poll it less often than other providers
export const CLAUDE_REFRESH_INTERVAL_MS = 180000;
export const CLAUDE_REFRESH_INTERVAL_MS = 600000;
export const DEPLETED_QUOTA_THRESHOLD = 5;
export const AUTO_REFRESH_STORAGE_KEY = "quotaAutoRefresh";
export const CONNECTIONS_PAGE_SIZE = 20;
@ -36,6 +36,17 @@ export function getConnectionQuotaRemaining(connection, quotaData) {
return Number.POSITIVE_INFINITY;
}
// Stable group-by-provider: first-seen provider order, original order within group.
function groupByProviderStable(connections) {
const seen = new Map();
for (const conn of connections) {
const key = conn.provider || "";
if (!seen.has(key)) seen.set(key, []);
seen.get(key).push(conn);
}
return Array.from(seen.values()).flat();
}
export function sortVisibleConnections(
connections,
quotaData,
@ -58,7 +69,7 @@ export function sortVisibleConnections(
});
}
if (!expiringFirst) return connections;
if (!expiringFirst) return groupByProviderStable(connections);
const getEarliestResetTime = (connection) => {
const resetTimes = (quotaData[connection.id]?.quotas || [])

View file

@ -4,6 +4,7 @@ import bcrypt from "bcryptjs";
import { cookies } from "next/headers";
import { setDashboardAuthCookie } from "@/lib/auth/dashboardSession";
import { isOidcConfigured } from "@/lib/auth/oidc";
import { isSamlConfigured } from "@/lib/auth/saml.js";
import { checkLock, recordFail, recordSuccess, getClientIp } from "@/lib/auth/loginLimiter";
import { isLocalRequest } from "@/dashboardGuard";
@ -39,8 +40,14 @@ export async function POST(request) {
// Default password is '123456' if not set
const storedHash = settings.password;
if (settings.authMode === "oidc" && isOidcConfigured(settings)) {
return NextResponse.json({ error: "Password login is disabled. Use OIDC sign in." }, { status: 403 });
if (settings.authMode === "sso" || settings.authMode === "saml" || settings.authMode === "oidc") {
const ssoType = settings.ssoType || (settings.authMode === "saml" ? "saml" : "oidc");
if (ssoType === "saml" && isSamlConfigured(settings)) {
return NextResponse.json({ error: "Password login is disabled. Use SAML SSO sign in." }, { status: 403 });
}
if (ssoType === "oidc" && isOidcConfigured(settings)) {
return NextResponse.json({ error: "Password login is disabled. Use OIDC sign in." }, { status: 403 });
}
}
let isValid = false;
@ -54,15 +61,36 @@ export async function POST(request) {
if (isValid) {
recordSuccess(ip);
const cookieStore = await cookies();
await setDashboardAuthCookie(cookieStore, request);
// Default password still in use on a remote client → force a password
// change before the dashboard is exposed remotely (keeps local UX intact).
const mustChangePassword =
!storedHash && !process.env.INITIAL_PASSWORD && !isLocalRequest(request);
return NextResponse.json({ success: true, mustChangePassword }, { headers: NO_STORE_HEADERS });
if (mustChangePassword) {
// Do NOT issue a session token: a fresh install's default password is
// public knowledge ("123456"), so handing out a valid JWT would let any
// remote attacker authenticate and (e.g.) PATCH /api/settings to disable
// authentication entirely (CVE-2026-56679 class). Require the password
// to be changed first.
//
// NOTE: this intentionally leaves no remote self-service password-change
// path — the change-password flow (PATCH /api/settings) requires a JWT,
// which we deliberately withhold. A remote fresh-install user must either
// change the password from the local machine or set INITIAL_PASSWORD
// before first launch. This is a deliberate security trade-off, not an
// oversight: issuing any credential before the default password is
// rotated re-opens the exact attack chain this branch closes.
return NextResponse.json(
{ success: false, error: "Default password must be changed before remote access. Change it from the local machine (or set INITIAL_PASSWORD).", mustChangePassword },
{ status: 403, headers: NO_STORE_HEADERS }
);
}
const cookieStore = await cookies();
await setDashboardAuthCookie(cookieStore, request);
return NextResponse.json({ success: true, mustChangePassword: false }, { headers: NO_STORE_HEADERS });
}
const { remainingBeforeLock } = recordFail(ip);

View file

@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getSettings } from "@/lib/localDb";
import {
getSamlBaseUrl,
isSamlConfigured,
pickSamlDisplayName,
pickSamlEmail,
validateSamlResponse,
} from "@/lib/auth/saml.js";
import { setDashboardAuthCookie } from "@/lib/auth/dashboardSession";
import { checkLock, recordFail, recordSuccess, getClientIp } from "@/lib/auth/loginLimiter";
export async function POST(request) {
const settings = await getSettings();
const origin = getSamlBaseUrl(request, settings);
const ip = getClientIp(request);
const lock = checkLock(ip);
if (lock.locked) {
return NextResponse.redirect(
new URL(
`/login?error=${encodeURIComponent(`Too many failed attempts. Try again in ${lock.retryAfter}s.`)}`,
origin
)
);
}
const cookieStore = await cookies();
const storedRequestId = cookieStore.get("saml_state")?.value || "";
// Always clear saml_state cookie after attempt
cookieStore.delete("saml_state");
try {
const formData = await request.formData();
const SAMLResponse = formData.get("SAMLResponse");
if (!SAMLResponse) {
recordFail(ip);
return NextResponse.redirect(new URL("/login?error=saml_missing_response", origin));
}
if (!isSamlConfigured(settings)) {
recordFail(ip);
return NextResponse.redirect(new URL("/login?error=saml_not_configured", origin));
}
const profile = await validateSamlResponse(request, { SAMLResponse }, storedRequestId, settings);
const samlEmail = pickSamlEmail(profile, settings) || null;
const samlName = pickSamlDisplayName(profile, settings) || "SAML user";
recordSuccess(ip);
await setDashboardAuthCookie(cookieStore, request, {
saml: true,
samlEmail,
samlName,
});
return NextResponse.redirect(new URL("/dashboard", origin));
} catch (error) {
recordFail(ip);
return NextResponse.redirect(
new URL(`/login?error=${encodeURIComponent(error.message || "saml_acs_failed")}`, origin)
);
}
}

View file

@ -0,0 +1,25 @@
import { getSettings } from "@/lib/localDb";
import { generateSamlMetadata } from "@/lib/auth/saml";
export async function GET(request) {
try {
const settings = await getSettings();
const origin = new URL(request.url).origin;
const metadataXml = generateSamlMetadata(origin, settings);
return new Response(metadataXml, {
status: 200,
headers: {
"Content-Type": "application/xml",
"Cache-Control": "no-cache",
},
});
} catch (error) {
return new Response(`<?xml version="1.0"?><Error>${error.message || "Failed to generate metadata"}</Error>`, {
status: 500,
headers: {
"Content-Type": "application/xml",
},
});
}
}

View file

@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getSettings } from "@/lib/localDb";
import { buildSamlAuthorizeUrl, getSamlBaseUrl, isSamlConfigured } from "@/lib/auth/saml.js";
import { shouldUseSecureCookie } from "@/lib/auth/dashboardSession";
export async function GET(request) {
const settings = await getSettings();
const origin = getSamlBaseUrl(request, settings);
try {
if (!isSamlConfigured(settings)) {
return NextResponse.redirect(new URL("/login?error=saml_not_configured", origin));
}
const { authorizeUrl, requestId } = await buildSamlAuthorizeUrl(request, settings);
const cookieStore = await cookies();
cookieStore.set("saml_state", requestId, {
httpOnly: true,
secure: shouldUseSecureCookie(request),
sameSite: "lax",
path: "/",
maxAge: 10 * 60,
});
return NextResponse.redirect(authorizeUrl);
} catch (error) {
return NextResponse.redirect(
new URL(`/login?error=${encodeURIComponent(error.message || "saml_start_failed")}`, origin)
);
}
}

View file

@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getSettings } from "@/lib/localDb";
import { formatX509Certificate } from "@/lib/auth/saml.js";
import { verifyDashboardAuthToken } from "@/lib/auth/dashboardSession";
async function canAccessTestRoute() {
const settings = await getSettings();
if (settings.requireLogin === false) return true;
const cookieStore = await cookies();
const token = cookieStore.get("auth_token")?.value;
return await verifyDashboardAuthToken(token);
}
export async function POST(request) {
try {
if (!(await canAccessTestRoute())) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const settings = await getSettings();
const samlEntryPoint = String(body.samlEntryPoint || settings.samlEntryPoint || "").trim();
const samlIssuer = String(body.samlIssuer || settings.samlIssuer || "urn:9router:sp").trim();
const samlCert = String(
Object.prototype.hasOwnProperty.call(body, "samlCert")
? body.samlCert
: settings.samlCert || ""
).trim();
if (!samlEntryPoint) {
return NextResponse.json({ error: "Single Sign-On Service URL (samlEntryPoint) is required" }, { status: 400 });
}
try {
new URL(samlEntryPoint);
} catch {
return NextResponse.json({ error: "Single Sign-On Service URL must be a valid URL" }, { status: 400 });
}
if (!samlIssuer) {
return NextResponse.json({ error: "SP Entity ID / Issuer (samlIssuer) is required" }, { status: 400 });
}
if (!samlCert) {
return NextResponse.json({ error: "IdP X.509 Certificate (samlCert) is required" }, { status: 400 });
}
const formattedCert = formatX509Certificate(samlCert);
if (!formattedCert) {
return NextResponse.json({ error: "Invalid IdP X.509 Certificate format" }, { status: 400 });
}
const origin = new URL(request.url).origin;
const acsUrl = `${origin}/api/auth/saml/acs`;
const metadataUrl = `${origin}/api/auth/saml/metadata`;
return NextResponse.json({
ok: true,
samlEntryPoint,
samlIssuer,
certValid: true,
acsUrl,
metadataUrl,
message: "SAML 2.0 configuration verified successfully.",
});
} catch (error) {
return NextResponse.json({ error: error.message || "SAML test failed" }, { status: 500 });
}
}

View file

@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getSettings } from "@/lib/localDb";
import { isOidcConfigured } from "@/lib/auth/oidc";
import { isSamlConfigured } from "@/lib/auth/saml.js";
import { getDashboardAuthSession } from "@/lib/auth/dashboardSession";
export async function GET() {
@ -11,16 +12,29 @@ export async function GET() {
const session = await getDashboardAuthSession(cookieStore.get("auth_token")?.value);
const requireLogin = settings.requireLogin !== false;
const authMode = settings.authMode || "password";
const ssoType = settings.ssoType || "oidc";
const oidcName = String(session?.oidcName || "").trim();
const oidcEmail = String(session?.oidcEmail || "").trim();
const displayName = oidcName || oidcEmail || (session?.oidc ? "OIDC user" : "Password user");
const loginMethod = session?.oidc ? "OIDC" : "Password";
const samlName = String(session?.samlName || "").trim();
const samlEmail = String(session?.samlEmail || "").trim();
const displayName =
samlName ||
samlEmail ||
oidcName ||
oidcEmail ||
(session?.saml ? "SAML user" : session?.oidc ? "OIDC user" : "Password user");
const loginMethod = session?.saml ? "SAML" : session?.oidc ? "OIDC" : "Password";
return NextResponse.json({
requireLogin,
authMode,
ssoType,
oidcConfigured: isOidcConfigured(settings),
oidcLoginLabel: (settings.oidcLoginLabel || "Sign in with OIDC").trim() || "Sign in with OIDC",
samlConfigured: isSamlConfigured(settings),
samlLoginLabel: (settings.samlLoginLabel || "Sign in with SAML SSO").trim() || "Sign in with SAML SSO",
hasPassword: !!settings.password,
displayName,
loginMethod,
@ -28,13 +42,19 @@ export async function GET() {
oidcName: oidcName || null,
oidcEmail: oidcEmail || null,
oidcLogin: !!session?.oidc,
samlName: samlName || null,
samlEmail: samlEmail || null,
samlLogin: !!session?.saml,
});
} catch {
return NextResponse.json({
requireLogin: true,
authMode: "password",
ssoType: "oidc",
oidcConfigured: false,
oidcLoginLabel: "Sign in with OIDC",
samlConfigured: false,
samlLoginLabel: "Sign in with SAML SSO",
hasPassword: false,
displayName: "Password user",
loginMethod: "Password",
@ -42,6 +62,9 @@ export async function GET() {
oidcName: null,
oidcEmail: null,
oidcLogin: false,
samlName: null,
samlEmail: null,
samlLogin: false,
});
}
}

View file

@ -20,7 +20,7 @@ const getHermesEnvPath = () => path.join(getHermesDir(), ".env");
const MODEL_BLOCK_RE = /^model:[ \t]*\r?\n((?:[ \t]+.*\r?\n?|[ \t]*\r?\n)*)/m;
const buildModelBlock = (model, baseUrl) =>
`model:\n default: "${model}"\n provider: "custom"\n base_url: "${baseUrl}"\n`;
`model:\n default: "${model}"\n provider: "custom"\n base_url: "${baseUrl}"\n api_key: \${OPENAI_API_KEY}\n`;
// Parse current model block back to fields (best-effort, simple key:value)
const parseModelBlock = (yaml) => {
@ -35,6 +35,7 @@ const parseModelBlock = (yaml) => {
default: get("default"),
provider: get("provider"),
base_url: get("base_url"),
api_key: get("api_key"),
};
};

View file

@ -135,9 +135,11 @@ export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:$
headers,
body: JSON.stringify({
model,
// Claude-on-Copilot returns empty choices at max_tokens:1 (budget is spent
// before a content token emits), so a 1-token probe yields a false negative.
max_tokens: 16,
// 1024 tokens: reasoning models (ClinePass/kimi-k3, deepseek-v4-pro, etc.) spend
// their budget on chain-of-thought before emitting an answer. A tiny probe like
// max_tokens:16 starves the answer and yields a false "no choices" failure.
// See issue #3010.
max_tokens: 1024,
stream: false,
messages: [{ role: "user", content: "hi" }],
}),
@ -180,6 +182,21 @@ export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:$
}
const hasChoices = Array.isArray(parsed?.choices) && parsed.choices.length > 0;
// Soft-pass (issue #3010): a reasoning model may burn its whole budget on
// chain-of-thought and return finish_reason:"length" with empty content but
// non-empty reasoning/thinking. That's a successful connection, not a failure.
const firstChoice = parsed?.choices?.[0] || {};
const hasReasoning =
firstChoice.message?.reasoning ||
firstChoice.message?.reasoning_content ||
firstChoice.message?.thinking ||
firstChoice.message?.thinking_content;
const contentEmpty = !String(firstChoice.message?.content || "").trim();
if (hasChoices && firstChoice.finish_reason === "length" && contentEmpty && hasReasoning) {
return { ok: true, latencyMs, error: null, status: res.status, note: "reasoning-only response (length-limited)" };
}
if (!hasChoices) {
return {
ok: false,

View file

@ -788,6 +788,27 @@ async function testApiKeyConnection(connection, effectiveProxy = null) {
);
return { valid: exRes.ok, error: exRes.ok ? null : "Invalid Personal Access Token" };
}
case "llm7": {
const baseUrl = connection.providerSpecificData?.baseUrl || "https://api.llm7.io/v1";
const res = await fetchWithConnectionProxy(`${baseUrl.replace(/\/$/, "")}/models`, {
headers: { Authorization: `Bearer ${connection.apiKey}` },
}, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" };
}
case "kimchi": {
// Dual-auth: same validation endpoint as the OAuth flow — the token (API key
// or OAuth access token) is sent as Authorization: Bearer.
const url = KIMCHI_CONFIG.validationUrl || "https://api.cast.ai/v1/llm/openai/supported-providers";
const res = await fetchWithConnectionProxy(url, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${connection.apiKey}`,
"User-Agent": "kimchi/0.1.40",
},
}, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key", refreshed: false };
}
default:
return { valid: false, error: "Provider test not supported" };
}

View file

@ -123,6 +123,7 @@ export async function GET(request, { params }) {
let connection;
try {
const { connectionId } = await params;
const force = new URL(request.url).searchParams.get("force") === "1";
// Get connection from database
@ -168,7 +169,7 @@ export async function GET(request, { params }) {
}
// Fetch usage from provider API
let usage = await getUsageForProvider(connection, proxyOptions);
let usage = await getUsageForProvider(connection, proxyOptions, { force });
// If provider returned an auth-expired message instead of throwing,
// force-refresh token and retry once (OAuth only)
@ -176,7 +177,7 @@ export async function GET(request, { params }) {
try {
const retryResult = await refreshAndUpdateCredentials(connection, true, proxyOptions);
connection = retryResult.connection;
usage = await getUsageForProvider(connection, proxyOptions);
usage = await getUsageForProvider(connection, proxyOptions, { force });
} catch (retryError) {
console.warn(`[Usage] ${connection.provider}: force refresh failed: ${retryError.message}`);
}

View file

@ -47,8 +47,23 @@ export async function GET(request) {
if (endDate) filter.endDate = endDate;
const result = await getRequestDetails(filter);
return NextResponse.json(result);
// Redact conversation payloads: the stored details include full request
// bodies (user prompts, tool calls) and provider responses. Returning them
// wholesale lets any dashboard-authenticated user (or, if requireLogin is
// disabled, anyone) read every user's conversation history. Keep the
// metadata (model, tokens, latency, status) but drop message content.
const redactedDetails = (result.details || []).map((d) => {
const redacted = { ...d };
for (const key of ["request", "providerRequest", "providerResponse", "response"]) {
if (redacted[key] !== undefined) {
redacted[key] = { redacted: true };
}
}
return redacted;
});
return NextResponse.json({ ...result, details: redactedDetails });
} catch (error) {
console.error("[API] Failed to get request details:", error);
return NextResponse.json(

View file

@ -485,6 +485,27 @@ export async function buildModelsList(kindFilter, options = {}) {
|| capabilitiesFromServiceKind(customKind || liveKind)
|| (kind === LLM_KIND ? getCapabilitiesForModel(providerId, modelId) : null);
if (caps) model.capabilities = caps;
// Token limits under the snake_case names the OpenAI/OpenRouter
// convention uses. `capabilities.contextWindow` is camelCase and nested,
// so clients matching context_length find nothing, fall back to guessing
// the window from the model name, and guess high — a 372k model read as
// 1.05M never reaches its compaction threshold and hard-fails upstream.
// Emitted at top level because not every client recurses into nested
// objects; the camelCase `capabilities` block stays for compatibility.
if (kind === LLM_KIND || allowAsLlm) {
let contextWindow = caps?.contextWindow;
let maxOutput = caps?.maxOutput;
// Live-catalog and service-kind capabilities are usually partial
// (often just { tools: true }), so fill the gaps from the static
// table rather than emitting null and leaving clients to guess.
if (!Number.isFinite(contextWindow) || !Number.isFinite(maxOutput)) {
const fallback = getCapabilitiesForModel(providerId, modelId);
if (!Number.isFinite(contextWindow)) contextWindow = fallback.contextWindow;
if (!Number.isFinite(maxOutput)) maxOutput = fallback.maxOutput;
}
if (Number.isFinite(contextWindow)) model.context_length = contextWindow;
if (Number.isFinite(maxOutput)) model.max_completion_tokens = maxOutput;
}
models.push(model);
}

View file

@ -11,8 +11,11 @@ export default function LoginPage() {
const [loading, setLoading] = useState(false);
const [hasPassword, setHasPassword] = useState(null);
const [authMode, setAuthMode] = useState("password");
const [ssoType, setSsoType] = useState("oidc");
const [oidcConfigured, setOidcConfigured] = useState(false);
const [oidcLoginLabel, setOidcLoginLabel] = useState("Sign in with OIDC");
const [samlConfigured, setSamlConfigured] = useState(false);
const [samlLoginLabel, setSamlLoginLabel] = useState("Sign in with SAML SSO");
const [mustChange, setMustChange] = useState(false);
const [newPassword, setNewPassword] = useState("");
@ -43,8 +46,11 @@ export default function LoginPage() {
}
setHasPassword(!!data.hasPassword);
setAuthMode(data.authMode || "password");
setSsoType(data.ssoType || "oidc");
setOidcConfigured(data.oidcConfigured === true);
setOidcLoginLabel(data.oidcLoginLabel || "Sign in with OIDC");
setSamlConfigured(data.samlConfigured === true);
setSamlLoginLabel(data.samlLoginLabel || "Sign in with SAML SSO");
} else {
// Safe fallback on non-OK response to avoid infinite loading state.
setHasPassword(true);
@ -118,8 +124,18 @@ export default function LoginPage() {
window.location.href = "/api/auth/oidc/start";
};
const oidcAvailable = oidcConfigured && ["oidc", "both"].includes(authMode);
const passwordAvailable = authMode !== "oidc" || !oidcConfigured;
const handleSamlLogin = () => {
window.location.href = "/api/auth/saml/start";
};
const isSsoEnabled = ["sso", "oidc", "saml", "both"].includes(authMode);
const activeSsoType = ssoType || (authMode === "saml" ? "saml" : "oidc");
const samlAvailable = isSsoEnabled && activeSsoType === "saml" && samlConfigured;
const oidcAvailable = isSsoEnabled && activeSsoType === "oidc" && oidcConfigured;
const ssoAvailable = samlAvailable || oidcAvailable;
const passwordAvailable = authMode === "password" || authMode === "both" || !ssoAvailable;
// Show loading state while checking password
if (hasPassword === null) {
@ -141,7 +157,9 @@ export default function LoginPage() {
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-primary mb-2">9Router</h1>
<p className="text-text-muted">
{authMode === "oidc" && oidcConfigured
{samlAvailable
? "Sign in with SAML 2.0 Single Sign-On"
: oidcAvailable
? "Sign in with your OIDC provider to access the dashboard"
: "Enter your password to access the dashboard"}
</p>
@ -171,25 +189,31 @@ export default function LoginPage() {
</form>
) : (
<div className="flex flex-col gap-4">
{samlAvailable && (
<Button type="button" variant="primary" className="w-full" onClick={handleSamlLogin}>
{samlLoginLabel}
</Button>
)}
{oidcAvailable && (
<Button type="button" variant="primary" className="w-full" onClick={handleOidcLogin}>
{oidcLoginLabel}
</Button>
)}
{oidcAvailable && passwordAvailable && <div className="h-px bg-border/60" />}
{ssoAvailable && passwordAvailable && <div className="h-px bg-border/60" />}
{passwordAvailable ? (
<form onSubmit={handleLogin} className="flex flex-col gap-4">
{((authMode === "oidc" && !oidcConfigured) || (authMode === "both" && !oidcConfigured)) && (
{isSsoEnabled && !ssoAvailable && (
<p className="text-xs text-amber-600 dark:text-amber-400 text-center">
OIDC login is enabled, but the issuer/client fields are not configured yet. Password login is still available for recovery.
{activeSsoType === "saml" ? "SAML SSO" : "OIDC"} login is enabled, but configuration is incomplete. Password login is still available for recovery.
</p>
)}
{authMode === "both" && oidcConfigured && (
{authMode === "both" && ssoAvailable && (
<p className="text-xs text-text-muted text-center">
Password and OIDC login are both enabled.
Password and {activeSsoType === "saml" ? "SAML SSO" : "OIDC"} login are both enabled.
</p>
)}

View file

@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getSettings, validateApiKey } from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { verifyDashboardAuthToken } from "@/lib/auth/dashboardSession";
import { hasTrustedPeerHeaders } from "@/lib/auth/trustedPeer";
const CLI_TOKEN_HEADER = "x-9r-cli-token";
const CLI_TOKEN_SALT = "9r-cli-auth";
@ -27,6 +28,7 @@ const PUBLIC_API_PATHS = [
"/api/auth/logout",
"/api/auth/status",
"/api/auth/oidc",
"/api/auth/saml",
"/api/version",
"/api/settings/require-login",
];
@ -86,24 +88,40 @@ const LOCAL_ONLY_PATHS = [
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
// Accepts a Host header, a URL hostname or a raw socket address. Splitting on the first
// colon only works for IPv4 and would reduce every IPv6 form to "", so a dual-stack
// listener handing back ::ffff:127.0.0.1 would not read as loopback.
function isLoopbackHostname(h) {
if (!h) return false;
const name = h.split(":")[0].replace(/^\[|\]$/g, "").toLowerCase();
let name = String(h).trim().toLowerCase();
if (name.startsWith("[")) {
const end = name.indexOf("]");
if (end === -1) return false;
name = name.slice(1, end);
} else if (name.indexOf(":") !== -1 && name.indexOf(":") === name.lastIndexOf(":")) {
name = name.slice(0, name.indexOf(":"));
}
if (name.startsWith("::ffff:")) name = name.slice(7);
return LOOPBACK_HOSTS.has(name);
}
function isLoopbackPeer(request) {
if (hasTrustedPeerHeaders(request)) {
return isLoopbackHostname(request.headers.get("x-9r-real-ip"));
}
// Bare `next dev` forks its server, so the wrapper never loads and no peer address
// reaches us. Host is spoofable, so this stays confined to development.
if (process.env.NODE_ENV === "development") {
return isLoopbackHostname(request.headers.get("host"));
}
return false;
}
export function isLocalRequest(request) {
// Stamped by custom-server.js when forwarding headers exist: request came through
// a reverse proxy, so the loopback socket is the proxy hop, not the end-user.
if (request.headers.get("x-9r-via-proxy")) return false;
// Trusted peer IP from TCP socket (custom-server.js); unspoofable. Primary anchor for "local".
const realIp = request.headers.get("x-9r-real-ip");
if (realIp) {
if (!isLoopbackHostname(realIp)) return false;
} else if (!isLoopbackHostname(request.headers.get("host"))) {
// Fallback for bare server.js (dev) without custom-server: legacy Host-based check.
return false;
}
if (!isLoopbackPeer(request)) return false;
const origin = request.headers.get("origin");
if (origin) {
try {

View file

@ -1,4 +1,5 @@
// In-memory progressive lockout for dashboard login. Resets on process restart.
import { hasTrustedPeerHeaders } from "./trustedPeer.js";
const MAX_FAILS_BEFORE_LOCK = 5;
const LOCK_STEPS_MS = [30_000, 120_000, 600_000, 1_800_000]; // 30s, 2m, 10m, 30m
@ -46,9 +47,12 @@ export function recordSuccess(ip) {
}
export function getClientIp(request) {
// Trusted: set from TCP socket by custom-server.js (client cannot spoof).
const realIp = request.headers.get("x-9r-real-ip");
if (realIp) return realIp;
// Trusted only when custom-server.js proves it stamped the header from the TCP socket;
// otherwise a client could rotate the value to escape its own lockout bucket.
if (hasTrustedPeerHeaders(request)) {
const realIp = request.headers.get("x-9r-real-ip");
if (realIp) return realIp;
}
// Behind a trusted reverse proxy that overwrites XFF with the real client IP.
if (process.env.TRUST_PROXY === "true") {
const xff = request.headers.get("x-forwarded-for");

268
src/lib/auth/saml.js Normal file
View file

@ -0,0 +1,268 @@
import { SAML } from "@node-saml/node-saml";
import { getSettings } from "../db/repos/settingsRepo.js";
/**
* Formats a raw Base64 string or unformatted X.509 certificate into standard PEM format.
* @param {string} certStr
* @returns {string}
*/
export function formatX509Certificate(certStr) {
if (!certStr || typeof certStr !== "string") return "";
const clean = certStr
.replace(/-----BEGIN CERTIFICATE-----/gi, "")
.replace(/-----END CERTIFICATE-----/gi, "")
.replace(/[^A-Za-z0-9+/=]/g, "");
if (!clean) return "";
const lines = clean.match(/.{1,64}/g) || [];
return `-----BEGIN CERTIFICATE-----\n${lines.join("\n")}\n-----END CERTIFICATE-----`;
}
/**
* Checks whether SAML configuration has essential parameters (entryPoint & cert).
* @param {object} settings
* @returns {boolean}
*/
export function isSamlConfigured(settings) {
return Boolean(settings?.samlEntryPoint && settings?.samlCert);
}
/**
* Fetches settings and returns runtime status + settings.
* @returns {Promise<{ configured: boolean, settings: object }>}
*/
export async function getSamlRuntimeConfig() {
const settings = await getSettings();
return {
configured: isSamlConfigured(settings),
settings,
};
}
/**
* Creates a configured `@node-saml/node-saml` SAML instance with security defaults.
* @param {object} settings
* @param {string} origin
* @returns {SAML}
*/
const DUMMY_FALLBACK_CERT =
"-----BEGIN CERTIFICATE-----\nMIIC...DUMMY...\n-----END CERTIFICATE-----";
function trimTrailingSlashes(str) {
return (str || "").replace(/\/+$/, "");
}
/**
* Resolves the public Base URL / Origin for SAML requests.
* Respects settings.baseUrl, process.env.BASE_URL, x-forwarded-proto, and x-forwarded-host.
* @param {Request} request
* @param {object} settings
* @returns {string}
*/
export function getSamlBaseUrl(request, settings) {
const configuredBaseUrl =
(settings?.baseUrl || "").trim() ||
process.env.BASE_URL ||
process.env.NEXT_PUBLIC_BASE_URL ||
"";
if (configuredBaseUrl) {
return trimTrailingSlashes(configuredBaseUrl);
}
if (request) {
const forwardedProto = request?.headers?.get?.("x-forwarded-proto") || "";
const forwardedHost = request?.headers?.get?.("x-forwarded-host") || "";
const host = forwardedHost || request?.headers?.get?.("host") || "";
if (host) {
const protocol = (forwardedProto || new URL(request.url).protocol || "http:").replace(/:$/, "");
return `${protocol}://${host}`.replace(/\/+$/, "");
}
if (request.url) {
return trimTrailingSlashes(new URL(request.url).origin);
}
}
return "http://localhost:20128";
}
export function createSamlInstance(settings, origin) {
const cert = formatX509Certificate(settings?.samlCert || "") || DUMMY_FALLBACK_CERT;
const callbackUrl = `${origin}/api/auth/saml/acs`;
return new SAML({
entryPoint: settings?.samlEntryPoint || "https://example.com/sso",
issuer: settings?.samlIssuer || "urn:9router:sp",
idpCert: cert,
cert: cert,
callbackUrl: callbackUrl,
acceptedClockSkewMs: 60000,
wantAssertionsSigned: true,
validateInResponseTo: "never",
requestIdExpirationMs: 28800000, // 8 hours
});
}
/**
* Builds SAML AuthnRequest redirect URL and returns { authorizeUrl, requestId }.
* @param {Request} request
* @param {object} settings
* @returns {Promise<{ authorizeUrl: string, requestId: string }>}
*/
export async function buildSamlAuthorizeUrl(request, settings) {
const origin = getSamlBaseUrl(request, settings);
const samlInstance = createSamlInstance(settings, origin);
const xml = await samlInstance.generateAuthorizeRequestAsync(false, false);
const match = xml.match(/ID="([^"]+)"/);
const requestId = match ? match[1] : "";
const authorizeUrl = await samlInstance._requestToUrlAsync(xml, null, "authorize", {});
return { authorizeUrl, requestId };
}
/**
* Validates SAML POST response from IdP ACS callback and returns user profile.
* @param {Request} request
* @param {object} body - Parsed form body or object containing SAMLResponse
* @param {string} expectedRequestId - Request ID stored in saml_state cookie
* @param {object} settings
* @returns {Promise<object>}
*/
export async function validateSamlResponse(request, body, expectedRequestId, settings) {
if (!settings?.samlCert) {
throw new Error("IdP X.509 Certificate (samlCert) is missing or not configured");
}
const origin = getSamlBaseUrl(request, settings);
const samlInstance = createSamlInstance(settings, origin);
const container = typeof body === "object" && body !== null ? body : { SAMLResponse: body };
const rawSamlResponse = container.SAMLResponse;
if (!rawSamlResponse) {
throw new Error("Missing SAMLResponse parameter in assertion POST body");
}
// Parse response XML to inspect InResponseTo for replay protection
if (expectedRequestId) {
const xml = Buffer.from(rawSamlResponse, "base64").toString("utf8");
const match = xml.match(/InResponseTo=["']([^"']+)["']/i);
const inResponseTo = match ? match[1] : null;
if (!inResponseTo || inResponseTo !== expectedRequestId) {
throw new Error(`InResponseTo mismatch: expected ${expectedRequestId}, received ${inResponseTo || "none"}`);
}
}
const result = await samlInstance.validatePostResponseAsync({ SAMLResponse: rawSamlResponse });
const profile = result?.profile || result;
return profile;
}
/**
* Generates standard SP XML Metadata.
* @param {string} origin
* @param {object} settings
* @returns {string}
*/
export function generateSamlMetadata(origin, settings) {
const samlInstance = createSamlInstance(settings, origin);
return samlInstance.generateServiceProviderMetadata();
}
/**
* Extracts email claim from SAML profile assertion.
* @param {object} profile
* @param {object} settings
* @returns {string}
*/
export function pickSamlEmail(profile = {}, settings = {}) {
if (!profile) return "";
// 1. Configured custom attribute
const customAttr = settings.samlAttributeEmail;
if (customAttr && profile[customAttr]) {
const val = profile[customAttr];
return Array.isArray(val) ? val[0] : String(val);
}
// 2. Common email claims
const emailKeys = [
"email",
"emailAddress",
"mail",
"nameID",
"nameId",
"upn",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn",
];
for (const key of emailKeys) {
if (profile[key]) {
const val = profile[key];
return Array.isArray(val) ? val[0] : String(val);
}
}
// 3. Fallback: check attributes object if present
if (profile.attributes) {
for (const key of emailKeys) {
if (profile.attributes[key]) {
const val = profile.attributes[key];
return Array.isArray(val) ? val[0] : String(val);
}
}
}
return "";
}
/**
* Extracts display name claim from SAML profile assertion.
* @param {object} profile
* @param {object} settings
* @returns {string}
*/
export function pickSamlDisplayName(profile = {}, settings = {}) {
if (!profile) return "";
// 1. Configured custom attribute
const customAttr = settings.samlAttributeName;
if (customAttr && profile[customAttr]) {
const val = profile[customAttr];
return Array.isArray(val) ? val[0] : String(val);
}
// 2. Common name claims
const nameKeys = [
"displayName",
"name",
"cn",
"commonName",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
];
for (const key of nameKeys) {
if (profile[key]) {
const val = profile[key];
return Array.isArray(val) ? val[0] : String(val);
}
}
// 3. Combined givenName + surname
if (profile.givenName || profile.sn || profile.surname) {
const given = profile.givenName || "";
const surname = profile.sn || profile.surname || "";
const combined = `${given} ${surname}`.trim();
if (combined) return combined;
}
// 4. Fallback to email
return pickSamlEmail(profile, settings);
}

View file

@ -0,0 +1,7 @@
// x-9r-real-ip is only trustworthy when custom-server.js stamped it from the TCP socket.
// It proves that by echoing the per-process secret it generated at boot, which a client
// cannot guess. Without the proof the header is just attacker-supplied input.
export function hasTrustedPeerHeaders(request) {
const token = process.env.NINEROUTER_PEER_TOKEN;
return Boolean(token) && request.headers.get("x-9r-peer-token") === token;
}

View file

@ -68,6 +68,8 @@ function sanitizeHeaders(headers) {
return sanitized;
}
export const __test__ = { sanitizeHeaders };
function generateDetailId(model) {
const timestamp = new Date().toISOString();
const random = Math.random().toString(36).substring(2, 8);

View file

@ -27,11 +27,18 @@ const DEFAULT_SETTINGS = {
requireApiKey: true,
tunnelDashboardAccess: true,
authMode: "password",
ssoType: "oidc",
oidcIssuerUrl: "",
oidcClientId: "",
oidcClientSecret: "",
oidcScopes: "openid profile email",
oidcLoginLabel: "Sign in with OIDC",
samlEntryPoint: "",
samlIssuer: "urn:9router:sp",
samlCert: "",
samlLoginLabel: "Sign in with SAML SSO",
samlAttributeEmail: "email",
samlAttributeName: "name",
enableObservability: false,
observabilityMaxRecords: 1000,
observabilityBatchSize: 20,
@ -63,7 +70,7 @@ async function readRaw() {
}
// Merge raw settings with defaults; backward-compat for missing keys
function mergeWithDefaults(raw) {
export function mergeWithDefaults(raw) {
const merged = { ...DEFAULT_SETTINGS, ...(raw || {}) };
for (const [key, defVal] of Object.entries(DEFAULT_SETTINGS)) {
if (merged[key] === undefined) {

View file

@ -26,10 +26,28 @@ const TARGET_HOSTS = [
const URL_PATTERNS = {
antigravity: [":generateContent", ":streamGenerateContent"],
copilot: ["/chat/completions", "/v1/messages", "/responses"],
// Legacy path form. Kiro IDE 1.0.228+ posts to `/` with x-amz-target instead —
// see isChatRequest() for the header-based match.
kiro: ["/generateAssistantResponse"],
cursor: ["/BidiAppend", "/RunSSE", "/RunPoll", "/Run"],
};
/**
* Whether this request is a chat turn we should intercept (vs passthrough).
* Kiro Runtime moved GenerateAssistantResponse from path `/generateAssistantResponse`
* to `POST /` + `x-amz-target: KiroRuntimeService.GenerateAssistantResponse`
* (verified via live mitmproxy capture of Kiro IDE 1.0.228).
*/
function isChatRequest(tool, req) {
const patterns = URL_PATTERNS[tool] || [];
if (patterns.some((p) => (req.url || "").includes(p))) return true;
if (tool === "kiro") {
const target = String(req.headers?.["x-amz-target"] || "");
return target.includes("GenerateAssistantResponse");
}
return false;
}
// Synonym map: rawModel from request → canonical alias key in mitmAlias DB
const MODEL_SYNONYMS = {
antigravity: {
@ -37,6 +55,9 @@ const MODEL_SYNONYMS = {
"gemini-3.5-flash-high": "gemini-3-flash-agent",
"gemini-3.5-flash-medium": "gemini-3.5-flash-low",
"gemini-3.5-flash-extra-low": "gemini-3.5-flash-extra-low",
"gemini-3.7-flash-high": "gemini-3.7-flash-high",
"gemini-3.7-flash-medium": "gemini-3.7-flash-medium",
"gemini-3.7-flash-low": "gemini-3.7-flash-low",
"gemini-3.1-pro-high": "gemini-pro-agent",
"gemini-3-pro-high": "gemini-pro-agent",
"gemini-3-pro-low": "gemini-3.1-pro-low",
@ -113,13 +134,15 @@ function extractModel(url, body) {
return parsed.conversationState.currentMessage?.userInputMessage?.modelId || null;
}
const model = urlModel || parsed.model || null;
if (String(model).replace(/^models\//, "") === "gemini-3.6-flash-tiered") {
const cleanModelName = String(model).replace(/^models\//, "");
if (cleanModelName === "gemini-3.6-flash-tiered" || cleanModelName === "gemini-3.7-flash-tiered") {
const ver = cleanModelName.includes("3.7") ? "3.7" : "3.6";
const rawLevel = parsed.request?.generationConfig?.thinkingConfig?.thinkingLevel
|| parsed.generationConfig?.thinkingConfig?.thinkingLevel;
const level = ["high", "medium", "low"].includes(String(rawLevel).toLowerCase())
? String(rawLevel).toLowerCase()
: "medium";
return `gemini-3.6-flash-${level}`;
return `gemini-${ver}-flash-${level}`;
}
return model;
} catch {
@ -127,4 +150,4 @@ function extractModel(url, body) {
}
}
module.exports = { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, LOG_BLACKLIST_URL_PARTS, getToolForHost, extractModel };
module.exports = { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, LOG_BLACKLIST_URL_PARTS, getToolForHost, isChatRequest, extractModel };

View file

@ -43,7 +43,8 @@ function initKiroState(modelId) {
finishSent: false, // Whether termination has been emitted
usage: null, // Accumulated usage from usage-only chunks
inThink: false, // Whether inside a <thinking> block
thinkBuf: "" // Buffer for partial thinking content
thinkBuf: "", // Buffer for partial thinking content
initialSent: false, // Whether initial-response frame was emitted
};
}
@ -130,9 +131,9 @@ function encodeHeader(name, value) {
* The SmithyMessageDecoderStream layer requires three system headers on every frame:
* :message-type = "event" (or "exception" / "error")
* :event-type = e.g. "assistantResponseEvent"
* :content-type = "application/json"
* :content-type = "application/json" (initial-response uses x-amz-json-1.0)
*/
function buildEventStreamFrame(eventType, payload) {
function buildEventStreamFrame(eventType, payload, contentType = "application/json") {
const payloadBuf = Buffer.from(
typeof payload === "string" ? payload : JSON.stringify(payload),
"utf8"
@ -142,7 +143,7 @@ function buildEventStreamFrame(eventType, payload) {
const headersBuf = Buffer.concat([
encodeHeader(":message-type", "event"),
encodeHeader(":event-type", eventType),
encodeHeader(":content-type", "application/json"),
encodeHeader(":content-type", contentType),
]);
const headersLen = headersBuf.length;
@ -159,6 +160,24 @@ function buildEventStreamFrame(eventType, payload) {
return frame;
}
/** Real Kiro Runtime always starts the stream with this frame (capture of IDE 1.0.228). */
function buildInitialResponseFrame(conversationId = "") {
return buildEventStreamFrame(
"initial-response",
{ conversationId: conversationId || "" },
"application/x-amz-json-1.0"
);
}
/** Prepend initial-response once per stream so Smithy decoder is happy. */
function withInitialFrame(state, frames) {
const list = frames == null ? [] : Array.isArray(frames) ? frames : [frames];
if (state.initialSent) return list.length === 0 ? null : list.length === 1 ? list[0] : list;
state.initialSent = true;
const out = [buildInitialResponseFrame(""), ...list];
return out.length === 1 ? out[0] : out;
}
// ─── CodeWhisperer → OpenAI conversion ───────────────────────────────────────
/**
@ -321,12 +340,12 @@ function convertOpenAIToKiro(chunk, state) {
state.inThink = false;
const thinking = state.thinkBuf;
state.thinkBuf = "";
return buildEventStreamFrame("reasoningContentEvent", {
return withInitialFrame(state, buildEventStreamFrame("reasoningContentEvent", {
content: thinking,
modelId: state.modelId || "kiro-unknown"
});
}));
}
return buildEventStreamFrame("messageStopEvent", {});
return withInitialFrame(state, buildEventStreamFrame("messageStopEvent", {}));
}
const frames = [];
@ -408,8 +427,12 @@ function convertOpenAIToKiro(chunk, state) {
}
}
if (frames.length === 0) return null;
return frames.length === 1 ? frames[0] : frames;
if (frames.length === 0) {
// اولین چانک ممکنه فقط role/empty باشه — initial رو همون‌جا بفرست
if (!state.initialSent) return withInitialFrame(state, null);
return null;
}
return withInitialFrame(state, frames.length === 1 ? frames[0] : frames);
}
/**

View file

@ -7,7 +7,7 @@ const dns = require("dns");
const { promisify } = require("util");
const { execSync } = require("child_process");
const { log, err, dumpRequest, createResponseDumper, clearDumpDir } = require("./logger");
const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost, extractModel } = require("./config");
const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost, isChatRequest, extractModel } = require("./config");
const { DATA_DIR, MITM_DIR } = require("./paths");
const { generateCert, getCertForDomain } = require("./cert/generate");
const { getMitmAlias } = require("./dbReader");
@ -311,9 +311,8 @@ const server = https.createServer(sslOptions, async (req, res) => {
const tool = getToolForHost(req.headers.host);
if (!tool) return passthrough(req, res, bodyBuffer);
const patterns = URL_PATTERNS[tool] || [];
const isChat = patterns.some(p => req.url.includes(p));
if (!isChat) return passthrough(req, res, bodyBuffer);
// Kiro IDE posts chat to `/` with x-amz-target (not path /generateAssistantResponse)
if (!isChatRequest(tool, req)) return passthrough(req, res, bodyBuffer);
// Cursor uses binary proto — model extraction not possible at this layer.
// Delegate directly to handler which decodes proto internally.

View file

@ -198,7 +198,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
if (!res.ok) return;
const data = await res.json();
if (!cancelled) {
setDisplayName(data?.displayName || data?.oidcName || data?.oidcEmail || "");
setDisplayName(data?.displayName || data?.samlName || data?.samlEmail || data?.oidcName || data?.oidcEmail || "");
setLoginMethod(data?.loginMethod || "");
}
} catch {
@ -303,12 +303,15 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
{/* Right actions */}
<div className="flex items-center gap-1 shrink-0">
{displayName && loginMethod === "OIDC" && (
<div className="hidden sm:flex items-center max-w-[220px] px-3 py-1.5 rounded-full border border-border bg-surface/70 text-xs text-text-muted truncate">
{displayName && (loginMethod === "OIDC" || loginMethod === "SAML") && (
<div
className="hidden sm:flex items-center max-w-[220px] px-3 py-1.5 rounded-full border border-border bg-surface/70 text-xs text-text-muted truncate"
title={displayName}
>
<span className="material-symbols-outlined text-[14px] mr-1.5 text-primary">person</span>
<span className="truncate">{displayName}</span>
<span className="ml-2 shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
OIDC
{loginMethod}
</span>
</div>
)}

View file

@ -8,7 +8,7 @@ export const MITM_TOOLS = {
description: "Google Antigravity IDE with MITM",
configType: "mitm",
mitmDomain: "daily-cloudcode-pa.googleapis.com",
modelAliases: ["gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"],
modelAliases: ["gemini-3.7-flash-high", "gemini-3.7-flash-medium", "gemini-3.7-flash-low", "gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"],
defaultModels: [
{ id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", alias: "gemini-3.6-flash-high" },
{ id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", alias: "gemini-3.6-flash-medium" },
@ -57,8 +57,13 @@ export const MITM_TOOLS = {
color: "#FF6B00",
description: "Kiro IDE with MITM",
configType: "mitm",
mitmDomain: "q.us-east-1.amazonaws.com",
mitmDomain: "runtime.us-east-1.kiro.dev",
defaultModels: [
// Kiro's agent/"vibe" mode sends modelId "auto" for the main turn and "simple-task"
// for background sub-tasks (verified via MITM request dump of generateAssistantResponse).
// Both need a mappable slot — otherwise getMappedModel returns null and the chat call
// is passed through to AWS instead of being routed to the chosen provider.
{ id: "auto", name: "Auto (Kiro Agent)", alias: "auto" },
{ id: "claude-sonnet-5", name: "Claude Sonnet 5", alias: "claude-sonnet-5" },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", alias: "claude-sonnet-4.5" },
{ id: "claude-sonnet-4", name: "Claude Sonnet 4", alias: "claude-sonnet-4" },

View file

@ -122,6 +122,7 @@
"alicode": "alicode",
"alicode-intl": "alicode-intl",
"alims-intl": "alims-intl",
"alitp-intl": "alitp-intl",
"anthropic": "anthropic",
"antigravity": "ag",
"api-airforce": "af",
@ -206,6 +207,7 @@
"alicode",
"alicode-intl",
"alims-intl",
"alitp-intl",
"anthropic",
"assemblyai",
"black-forest-labs",

View file

@ -708,7 +708,37 @@
"opencode-go": {
"baseUrl": "https://opencode.ai/zen/go/v1/chat/completions",
"headers": {},
"format": "openai"
"format": "openai",
"transports": [
{
"format": "openai",
"baseUrl": "https://opencode.ai/zen/go/v1/chat/completions",
"auth": {
"combined": true,
"header": "Authorization",
"scheme": "bearer"
}
},
{
"format": "claude",
"baseUrl": "https://opencode.ai/zen/go/v1/messages",
"auth": {
"combined": true,
"header": "x-api-key",
"scheme": "raw",
"anthropicVersion": true
}
},
{
"format": "openai-responses",
"baseUrl": "https://opencode.ai/zen/go/v1/responses",
"auth": {
"combined": true,
"header": "Authorization",
"scheme": "bearer"
}
}
]
},
"opencode": {
"baseUrl": "https://opencode.ai",
@ -968,7 +998,15 @@
"tokenrouter": {
"baseUrl": "https://api.tokenrouter.com/v1/chat/completions",
"validateUrl": "https://api.tokenrouter.com/v1/models",
"thinkingFormat": "openai",
"thinkingFormat": "tokenrouter",
"format": "openai"
},
"alitp-intl": {
"baseUrl": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
"headers": {},
"quirks": {
"preserveCacheControl": true
},
"format": "openai"
}
}

40
tests/auth/saml.test.js Normal file
View file

@ -0,0 +1,40 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
formatX509Certificate,
isSamlConfigured,
generateSamlMetadata,
pickSamlEmail,
pickSamlDisplayName,
} from "../../src/lib/auth/saml.js";
test("formatX509Certificate normalizes Base64 strings into PEM blocks", () => {
const rawBase64 = "MIIC1234567890123456789012345678901234567890123456789012345678901234567890";
const formatted = formatX509Certificate(rawBase64);
assert.match(formatted, /-----BEGIN CERTIFICATE-----/);
assert.match(formatted, /-----END CERTIFICATE-----/);
assert.equal(formatX509Certificate(""), "");
});
test("isSamlConfigured checks required fields", () => {
assert.equal(isSamlConfigured({ samlEntryPoint: "https://idp.com/sso", samlCert: "cert" }), true);
assert.equal(isSamlConfigured({ samlEntryPoint: "https://idp.com/sso" }), false);
assert.equal(isSamlConfigured({}), false);
});
test("generateSamlMetadata produces valid SP XML", () => {
const settings = {
samlEntryPoint: "https://idp.example.com/sso",
samlIssuer: "urn:9router:sp",
samlCert: "MIIC123456789012345678901234567890123456789012345678901234567890",
};
const xml = generateSamlMetadata("https://localhost:20127", settings);
assert.match(xml, /entityID="urn:9router:sp"/);
assert.match(xml, /Location="https:\/\/localhost:20127\/api\/auth\/saml\/acs"/);
});
test("Claims Extraction pickSamlEmail & pickSamlDisplayName", () => {
const profile = { email: "test@example.com", name: "Test User" };
assert.equal(pickSamlEmail(profile, {}), "test@example.com");
assert.equal(pickSamlDisplayName(profile, {}), "Test User");
});

View file

@ -38,6 +38,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > alicode-intl → hea
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > alims-intl → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > anthropic → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -66,6 +85,31 @@ exports[`GOLDEN buildHeaders (default executor providers) > anthropic → header
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > api-airforce → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"HTTP-Referer": "https://endpoint-proxy.local",
"X-Title": "Endpoint Proxy",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"HTTP-Referer": "https://endpoint-proxy.local",
"X-Title": "Endpoint Proxy",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"HTTP-Referer": "https://endpoint-proxy.local",
"X-Title": "Endpoint Proxy",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > assemblyai → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -85,6 +129,44 @@ exports[`GOLDEN buildHeaders (default executor providers) > assemblyai → heade
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > baidu → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > bazaarlink → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > blackbox → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -104,6 +186,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > blackbox → headers
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > bluesminds → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > byteplus → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -268,6 +369,52 @@ exports[`GOLDEN buildHeaders (default executor providers) > cline → headers (a
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > clinepass → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"HTTP-Referer": "https://cline.bot",
"User-Agent": "9Router/0.5.50",
"X-CLIENT-TYPE": "9router",
"X-CLIENT-VERSION": "0.5.50",
"X-CORE-VERSION": "0.5.50",
"X-IS-MULTIROOT": "false",
"X-PLATFORM": "linux",
"X-PLATFORM-VERSION": "v24.15.0",
"X-Title": "Cline",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"HTTP-Referer": "https://cline.bot",
"User-Agent": "9Router/0.5.50",
"X-CLIENT-TYPE": "9router",
"X-CLIENT-VERSION": "0.5.50",
"X-CORE-VERSION": "0.5.50",
"X-IS-MULTIROOT": "false",
"X-PLATFORM": "linux",
"X-PLATFORM-VERSION": "v24.15.0",
"X-Title": "Cline",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"HTTP-Referer": "https://cline.bot",
"User-Agent": "9Router/0.5.50",
"X-CLIENT-TYPE": "9router",
"X-CLIENT-VERSION": "0.5.50",
"X-CORE-VERSION": "0.5.50",
"X-IS-MULTIROOT": "false",
"X-PLATFORM": "linux",
"X-PLATFORM-VERSION": "v24.15.0",
"X-Title": "Cline",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > cloudflare-ai → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -324,6 +471,43 @@ exports[`GOLDEN buildHeaders (default executor providers) > codebuddy-cn → hea
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > codebuddy-intl → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1",
"X-IDE-Name": "IDE",
"X-IDE-Type": "IDE",
"X-Product": "SaaS",
"x-codebuddy-request": "1",
"x-requested-with": "XMLHttpRequest",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1",
"X-IDE-Name": "IDE",
"X-IDE-Type": "IDE",
"X-Product": "SaaS",
"x-codebuddy-request": "1",
"x-requested-with": "XMLHttpRequest",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1",
"X-IDE-Name": "IDE",
"X-IDE-Type": "IDE",
"X-Product": "SaaS",
"x-codebuddy-request": "1",
"x-requested-with": "XMLHttpRequest",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > cohere → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -381,6 +565,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > deepseek → headers
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > featherless → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > fireworks → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -482,6 +685,34 @@ exports[`GOLDEN buildHeaders (default executor providers) > glm-cn → headers (
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > grok-cli → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "grok-shell/0.2.99 (linux; x86_64)",
"x-grok-client-identifier": "grok-shell",
"x-grok-client-version": "0.2.99",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "grok-shell/0.2.99 (linux; x86_64)",
"x-grok-client-identifier": "grok-shell",
"x-grok-client-version": "0.2.99",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "grok-shell/0.2.99 (linux; x86_64)",
"x-grok-client-identifier": "grok-shell",
"x-grok-client-version": "0.2.99",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > groq → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -520,6 +751,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > hyperbolic → heade
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > kilo-gateway → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > kilocode → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -539,6 +789,28 @@ exports[`GOLDEN buildHeaders (default executor providers) > kilocode → headers
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > kimchi → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "kimchi/0.1.50",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "kimchi/0.1.50",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "kimchi/0.1.50",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > kimi → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -601,6 +873,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > kimi-coding → head
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > llm7 → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > minimax → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -689,6 +980,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > mmf → headers (api
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > morph → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > nanobanana → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -828,6 +1138,63 @@ exports[`GOLDEN buildHeaders (default executor providers) > perplexity → heade
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > perplexity-agent → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > poolside → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > sambanova → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -847,6 +1214,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → head
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > tencent → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > together → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -866,6 +1252,44 @@ exports[`GOLDEN buildHeaders (default executor providers) > together → headers
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > tokenrouter → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > venice → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > vercel-ai-gateway → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
@ -942,6 +1366,28 @@ exports[`GOLDEN buildHeaders (default executor providers) > xiaomi-mimo → head
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > zed → headers (apiKey / oauth) 1`] = `
{
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "<CRED>",
"Content-Type": "application/json",
"content-type": "application/json",
},
"nonStream": {
"Authorization": "<CRED>",
"Content-Type": "application/json",
"content-type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "<CRED>",
"Content-Type": "application/json",
"content-type": "application/json",
},
}
`;
exports[`GOLDEN buildUrl (default executor providers) > alicode → url (stream + non-stream) 1`] = `
{
"nonStream": "https://coding.dashscope.aliyuncs.com/v1/chat/completions",
@ -956,6 +1402,13 @@ exports[`GOLDEN buildUrl (default executor providers) > alicode-intl → url (st
}
`;
exports[`GOLDEN buildUrl (default executor providers) > alims-intl → url (stream + non-stream) 1`] = `
{
"nonStream": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
"stream": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > anthropic → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.anthropic.com/v1/messages",
@ -963,6 +1416,13 @@ exports[`GOLDEN buildUrl (default executor providers) > anthropic → url (strea
}
`;
exports[`GOLDEN buildUrl (default executor providers) > api-airforce → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.airforce/v1/chat/completions",
"stream": "https://api.airforce/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > assemblyai → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.assemblyai.com/v1/audio/transcriptions",
@ -970,6 +1430,20 @@ exports[`GOLDEN buildUrl (default executor providers) > assemblyai → url (stre
}
`;
exports[`GOLDEN buildUrl (default executor providers) > baidu → url (stream + non-stream) 1`] = `
{
"nonStream": "https://qianfan.baidubce.com/v2/chat/completions",
"stream": "https://qianfan.baidubce.com/v2/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > bazaarlink → url (stream + non-stream) 1`] = `
{
"nonStream": "https://bazaarlink.ai/api/v1/chat/completions",
"stream": "https://bazaarlink.ai/api/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > blackbox → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.blackbox.ai/chat/completions",
@ -977,6 +1451,13 @@ exports[`GOLDEN buildUrl (default executor providers) > blackbox → url (stream
}
`;
exports[`GOLDEN buildUrl (default executor providers) > bluesminds → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.bluesminds.com/v1/chat/completions",
"stream": "https://api.bluesminds.com/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > byteplus → url (stream + non-stream) 1`] = `
{
"nonStream": "https://ark.ap-southeast.bytepluses.com/api/coding/v3/chat/completions",
@ -1012,6 +1493,13 @@ exports[`GOLDEN buildUrl (default executor providers) > cline → url (stream +
}
`;
exports[`GOLDEN buildUrl (default executor providers) > clinepass → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.cline.bot/api/v1/chat/completions",
"stream": "https://api.cline.bot/api/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > cloudflare-ai → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.cloudflare.com/client/v4/accounts/ACC123/ai/v1/chat/completions",
@ -1026,6 +1514,13 @@ exports[`GOLDEN buildUrl (default executor providers) > codebuddy-cn → url (st
}
`;
exports[`GOLDEN buildUrl (default executor providers) > codebuddy-intl → url (stream + non-stream) 1`] = `
{
"nonStream": "https://www.codebuddy.ai/v2/chat/completions",
"stream": "https://www.codebuddy.ai/v2/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > cohere → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.cohere.ai/v1/chat/completions",
@ -1047,6 +1542,13 @@ exports[`GOLDEN buildUrl (default executor providers) > deepseek → url (stream
}
`;
exports[`GOLDEN buildUrl (default executor providers) > featherless → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.featherless.ai/v1/chat/completions",
"stream": "https://api.featherless.ai/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > fireworks → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.fireworks.ai/inference/v1/chat/completions",
@ -1082,6 +1584,13 @@ exports[`GOLDEN buildUrl (default executor providers) > glm-cn → url (stream +
}
`;
exports[`GOLDEN buildUrl (default executor providers) > grok-cli → url (stream + non-stream) 1`] = `
{
"nonStream": "https://cli-chat-proxy.grok.com/v1/responses",
"stream": "https://cli-chat-proxy.grok.com/v1/responses",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > groq → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.groq.com/openai/v1/chat/completions",
@ -1096,6 +1605,13 @@ exports[`GOLDEN buildUrl (default executor providers) > hyperbolic → url (stre
}
`;
exports[`GOLDEN buildUrl (default executor providers) > kilo-gateway → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.kilo.ai/api/gateway/chat/completions",
"stream": "https://api.kilo.ai/api/gateway/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > kilocode → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.kilo.ai/api/openrouter/chat/completions",
@ -1103,6 +1619,13 @@ exports[`GOLDEN buildUrl (default executor providers) > kilocode → url (stream
}
`;
exports[`GOLDEN buildUrl (default executor providers) > kimchi → url (stream + non-stream) 1`] = `
{
"nonStream": "https://llm.kimchi.dev/openai/v1/chat/completions",
"stream": "https://llm.kimchi.dev/openai/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > kimi → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.kimi.com/coding/v1/messages?beta=true",
@ -1117,6 +1640,13 @@ exports[`GOLDEN buildUrl (default executor providers) > kimi-coding → url (str
}
`;
exports[`GOLDEN buildUrl (default executor providers) > llm7 → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.llm7.io/v1/chat/completions",
"stream": "https://api.llm7.io/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > minimax → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.minimax.io/anthropic/v1/messages?beta=true",
@ -1145,6 +1675,13 @@ exports[`GOLDEN buildUrl (default executor providers) > mmf → url (stream + no
}
`;
exports[`GOLDEN buildUrl (default executor providers) > morph → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.morphllm.com/v1/chat/completions",
"stream": "https://api.morphllm.com/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > nanobanana → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.nanobananaapi.ai/v1/chat/completions",
@ -1194,6 +1731,27 @@ exports[`GOLDEN buildUrl (default executor providers) > perplexity → url (stre
}
`;
exports[`GOLDEN buildUrl (default executor providers) > perplexity-agent → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.perplexity.ai/v1/responses",
"stream": "https://api.perplexity.ai/v1/responses",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > poolside → url (stream + non-stream) 1`] = `
{
"nonStream": "https://inference.poolside.ai/v1/chat/completions",
"stream": "https://inference.poolside.ai/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > sambanova → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.sambanova.ai/v1/chat/completions",
"stream": "https://api.sambanova.ai/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > siliconflow → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.siliconflow.com/v1/chat/completions",
@ -1201,6 +1759,13 @@ exports[`GOLDEN buildUrl (default executor providers) > siliconflow → url (str
}
`;
exports[`GOLDEN buildUrl (default executor providers) > tencent → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.hunyuan.cloud.tencent.com/v1/chat/completions",
"stream": "https://api.hunyuan.cloud.tencent.com/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > together → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.together.xyz/v1/chat/completions",
@ -1208,6 +1773,20 @@ exports[`GOLDEN buildUrl (default executor providers) > together → url (stream
}
`;
exports[`GOLDEN buildUrl (default executor providers) > tokenrouter → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.tokenrouter.com/v1/chat/completions",
"stream": "https://api.tokenrouter.com/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > venice → url (stream + non-stream) 1`] = `
{
"nonStream": "https://api.venice.ai/api/v1/chat/completions",
"stream": "https://api.venice.ai/api/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > vercel-ai-gateway → url (stream + non-stream) 1`] = `
{
"nonStream": "https://ai-gateway.vercel.sh/v1/chat/completions",
@ -1235,3 +1814,10 @@ exports[`GOLDEN buildUrl (default executor providers) > xiaomi-mimo → url (str
"stream": "https://api.xiaomimimo.com/v1/chat/completions",
}
`;
exports[`GOLDEN buildUrl (default executor providers) > zed → url (stream + non-stream) 1`] = `
{
"nonStream": "https://cloud.zed.dev/completions",
"stream": "https://cloud.zed.dev/completions",
}
`;

View file

@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import REGISTRY from "../../open-sse/providers/registry/index.js";
import { PROVIDERS, PROVIDER_MODELS } from "../../open-sse/providers/index.js";
describe("Alibaba Token Plan provider", () => {
const entry = REGISTRY.find((e) => e.id === "alitp-intl");
it("is registered as an OpenAI-compatible apikey provider", () => {
expect(entry).toBeDefined();
expect(entry.category).toBe("apikey");
expect(PROVIDERS["alitp-intl"]).toBeDefined();
expect(PROVIDERS["alitp-intl"].format).toBe("openai");
});
it("targets the Singapore Token Plan host in compatible mode", () => {
// eu-central-1 answers IllegalEndpoint; the plan is Singapore-only.
expect(PROVIDERS["alitp-intl"].baseUrl).toBe(
"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
);
});
it("does not collide with the other three Alibaba key types", () => {
const hosts = ["alicode", "alicode-intl", "alims-intl", "alitp-intl"]
.map((id) => new URL(PROVIDERS[id].baseUrl).host);
expect(new Set(hosts).size).toBe(hosts.length);
});
it("exposes the models the plan actually serves", () => {
const ids = (PROVIDER_MODELS["alitp-intl"] || []).map((m) => m.id);
expect(ids).toEqual(expect.arrayContaining([
"qwen3.8-max-preview",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.6-flash",
"glm-5.2",
"deepseek-v4-pro",
]));
});
it("keeps every registry id unique after adding the provider", () => {
const ids = REGISTRY.map((e) => e.id);
expect(new Set(ids).size).toBe(ids.length);
});
});

View file

@ -0,0 +1,54 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/usageDb.js", () => ({
appendRequestLog: vi.fn(async () => {}),
saveRequestDetail: vi.fn(async () => {}),
saveRequestUsage: vi.fn(async () => {})
}));
const { extractUsageFromResponse } = await import("../../open-sse/handlers/chatCore/requestDetail.js");
const USAGE_METADATA = {
promptTokenCount: 1234,
candidatesTokenCount: 56,
cachedContentTokenCount: 78,
thoughtsTokenCount: 90,
};
const EXPECTED = {
prompt_tokens: 1234,
completion_tokens: 56,
cached_tokens: 78,
reasoning_tokens: 90,
};
describe("#3260 non-streaming usage extraction for enveloped Gemini responses", () => {
it("reads usageMetadata out of the antigravity { response } envelope", () => {
expect(extractUsageFromResponse({ response: { usageMetadata: USAGE_METADATA } })).toEqual(EXPECTED);
});
it("still reads a top-level usageMetadata", () => {
expect(extractUsageFromResponse({ usageMetadata: USAGE_METADATA })).toEqual(EXPECTED);
});
it("prefers the top-level metadata when both are present", () => {
const enveloped = { ...USAGE_METADATA, promptTokenCount: 1 };
const out = extractUsageFromResponse({
usageMetadata: USAGE_METADATA,
response: { usageMetadata: enveloped },
});
expect(out.prompt_tokens).toBe(1234);
});
it("leaves the OpenAI and Claude shapes alone", () => {
expect(extractUsageFromResponse({ usage: { prompt_tokens: 10, completion_tokens: 2 } }))
.toMatchObject({ prompt_tokens: 10, completion_tokens: 2 });
expect(extractUsageFromResponse({ usage: { input_tokens: 10, output_tokens: 2 } }))
.toMatchObject({ prompt_tokens: 10, completion_tokens: 2 });
});
it("returns null when there is no usage anywhere", () => {
expect(extractUsageFromResponse({ response: { candidates: [] } })).toBeNull();
expect(extractUsageFromResponse(null)).toBeNull();
});
});

View file

@ -0,0 +1,61 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const proxyAwareFetch = vi.fn(async (url) => ({
ok: true,
status: 200,
json: async () => url.includes(":loadCodeAssist")
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" } }
: {
models: {
"gemini-3.7-flash-high": {
displayName: "Gemini 3.7 Flash (High)",
quotaInfo: { remainingFraction: 0.85, resetTime: "2026-08-25T12:00:00Z" },
},
"gemini-3.7-flash-medium": {
displayName: "Gemini 3.7 Flash (Medium)",
quotaInfo: { remainingFraction: 0.6, resetTime: "2026-08-25T12:00:00Z" },
},
"gemini-3.7-flash-low": {
displayName: "Gemini 3.7 Flash (Low)",
quotaInfo: { remainingFraction: 0.35, resetTime: "2026-08-25T12:00:00Z" },
},
"internal-model": {
displayName: "Internal",
isInternal: true,
quotaInfo: { remainingFraction: 0.5 },
},
},
},
text: async () => "{}",
}));
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
proxyAwareFetch,
}));
describe("Antigravity quota tracker: Gemini 3.7 Flash usage bars", () => {
beforeEach(() => proxyAwareFetch.mockClear());
it("returns Gemini 3.7 Flash tier quotas so the dashboard can render usage bars", async () => {
const { getAntigravityUsage } = await import("../../open-sse/services/usage/google.js");
const usage = await getAntigravityUsage("access-token", {});
expect(usage.quotas["gemini-3.7-flash-high"]).toMatchObject({
used: 150,
total: 1000,
remainingPercentage: 85,
displayName: "Gemini 3.7 Flash (High)",
});
expect(usage.quotas["gemini-3.7-flash-medium"]).toMatchObject({
used: 400,
total: 1000,
remainingPercentage: 60,
});
expect(usage.quotas["gemini-3.7-flash-low"]).toMatchObject({
used: 650,
total: 1000,
remainingPercentage: 35,
});
});
});

View file

@ -0,0 +1,86 @@
// custom-server.js is the only thing that makes x-9r-real-ip trustworthy. Boot a real
// HTTP server through it and confirm a client cannot smuggle its own peer headers in.
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createRequire } from "node:module";
import http from "node:http";
import { __test__ as requestDetails } from "@/lib/db/repos/requestDetailsRepo.js";
const require = createRequire(import.meta.url);
let server;
let baseUrl;
let seenHeaders;
beforeAll(async () => {
require("../../custom-server.js");
server = http.createServer((req, res) => {
seenHeaders = req.headers;
res.end("ok");
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
baseUrl = `http://127.0.0.1:${server.address().port}`;
});
afterAll(async () => {
await new Promise((resolve) => server.close(resolve));
});
async function get(headers = {}) {
await fetch(baseUrl, { headers });
return seenHeaders;
}
describe("custom-server peer header sanitizing", () => {
it("generates a peer trust token at boot", () => {
expect(process.env.NINEROUTER_PEER_TOKEN).toMatch(/^[0-9a-f]{48}$/);
});
it("replaces a client-supplied x-9r-real-ip with the socket address", async () => {
const headers = await get({ "x-9r-real-ip": "203.0.113.55" });
expect(headers["x-9r-real-ip"]).toMatch(/^(::ffff:)?127\.0\.0\.1$/);
});
it("stamps the trust token so downstream can tell the wrapper ran", async () => {
const headers = await get();
expect(headers["x-9r-peer-token"]).toBe(process.env.NINEROUTER_PEER_TOKEN);
});
it("drops a client-supplied peer trust token", async () => {
const headers = await get({ "x-9r-peer-token": "forged-token" });
expect(headers["x-9r-peer-token"]).toBe(process.env.NINEROUTER_PEER_TOKEN);
expect(headers["x-9r-peer-token"]).not.toBe("forged-token");
});
it("drops a client-supplied x-9r-via-proxy marker", async () => {
const headers = await get({ "x-9r-via-proxy": "1" });
expect(headers["x-9r-via-proxy"]).toBeUndefined();
});
it("marks via-proxy and adopts the forwarded IP for a loopback proxy hop", async () => {
const headers = await get({ "x-forwarded-for": "203.0.113.9, 10.0.0.1" });
expect(headers["x-9r-via-proxy"]).toBe("1");
expect(headers["x-9r-real-ip"]).toBe("203.0.113.9");
expect(headers["x-forwarded-for"]).toBeUndefined();
});
// chat.js snapshots every client header into the request detail. Anything that grants
// access must not survive into a record the dashboard renders and cloud sync uploads.
it("keeps the peer token out of persisted request details", () => {
const sanitized = requestDetails.sanitizeHeaders({
"x-9r-peer-token": "secret",
"x-9r-cli-token": "secret",
"authorization": "Bearer sk-x",
"x-9r-real-ip": "127.0.0.1",
});
expect(sanitized["x-9r-peer-token"]).toBeUndefined();
expect(sanitized["x-9r-cli-token"]).toBeUndefined();
expect(sanitized["authorization"]).toBeUndefined();
expect(sanitized["x-9r-real-ip"]).toBe("127.0.0.1");
});
});

View file

@ -35,6 +35,8 @@ vi.mock("@/lib/auth/dashboardSession", () => ({
const { proxy, __test__ } = await import("../../src/dashboardGuard.js");
const PEER_TOKEN = "peer-token-fixture";
function request(pathname, headers = {}) {
const normalizedHeaders = new Headers(headers);
return {
@ -45,9 +47,16 @@ function request(pathname, headers = {}) {
};
}
// A request that actually came through custom-server.js: peer IP stamped from the TCP
// socket and proven by the per-process secret.
function localRequest(pathname, headers = {}) {
return request(pathname, { "x-9r-peer-token": PEER_TOKEN, "x-9r-real-ip": "127.0.0.1", ...headers });
}
describe("dashboard guard public LLM API access", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
mocks.getSettings.mockResolvedValue({ requireLogin: true });
mocks.validateApiKey.mockResolvedValue(false);
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
@ -55,14 +64,14 @@ describe("dashboard guard public LLM API access", () => {
});
it("allows loopback public LLM API without API key", async () => {
const response = await proxy(request("/v1/chat/completions", { host: "localhost:20128" }));
const response = await proxy(localRequest("/v1/chat/completions", { host: "localhost:20128" }));
expect(response).toBe(mocks.nextResponse);
expect(mocks.validateApiKey).not.toHaveBeenCalled();
});
it("rejects remote Host-spoof when real peer IP is non-loopback", async () => {
const response = await proxy(request("/v1/chat/completions", {
const response = await proxy(localRequest("/v1/chat/completions", {
host: "localhost",
"x-9r-real-ip": "10.204.111.34",
}));
@ -72,7 +81,7 @@ describe("dashboard guard public LLM API access", () => {
});
it("allows loopback peer IP regardless of Host", async () => {
const response = await proxy(request("/v1/chat/completions", {
const response = await proxy(localRequest("/v1/chat/completions", {
host: "localhost:20128",
"x-9r-real-ip": "127.0.0.1",
}));
@ -89,7 +98,7 @@ describe("dashboard guard public LLM API access", () => {
});
it("allows loopback rewritten public LLM API without API key", async () => {
const response = await proxy(request("/api/v1/chat/completions", { host: "localhost:20128" }));
const response = await proxy(localRequest("/api/v1/chat/completions", { host: "localhost:20128" }));
expect(response).toBe(mocks.nextResponse);
expect(mocks.validateApiKey).not.toHaveBeenCalled();
@ -191,6 +200,7 @@ describe("dashboard guard public LLM API access", () => {
describe("dashboard guard local-only access", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
mocks.getSettings.mockResolvedValue({ requireLogin: true });
mocks.validateApiKey.mockResolvedValue(false);
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
@ -207,7 +217,7 @@ describe("dashboard guard local-only access", () => {
});
it("rejects local-only route on loopback when requireLogin=true and no JWT", async () => {
const response = await proxy(request("/api/mcp/filesystem/sse", {
const response = await proxy(localRequest("/api/mcp/filesystem/sse", {
host: "localhost:20128",
origin: "http://localhost:20128",
}));
@ -219,7 +229,7 @@ describe("dashboard guard local-only access", () => {
it("allows local-only route on loopback when requireLogin=false", async () => {
mocks.getSettings.mockResolvedValue({ requireLogin: false });
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
const response = await proxy(localRequest("/api/cli-tools/antigravity-mitm", {
host: "localhost:20128",
origin: "http://localhost:20128",
}));
@ -240,7 +250,7 @@ describe("dashboard guard local-only access", () => {
it("rejects local-only route when Origin is non-loopback (CSRF block)", async () => {
mocks.getSettings.mockResolvedValue({ requireLogin: false });
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
const response = await proxy(localRequest("/api/cli-tools/antigravity-mitm", {
host: "localhost:20128",
origin: "http://evil.example.com",
}));

View file

@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import REGISTRY from "../../open-sse/providers/registry/index.js";
import { PROVIDER_MEDIA } from "../../open-sse/providers/index.js";
import { FORMAT_HANDLERS } from "../../open-sse/handlers/ttsProviders/genericFormats.js";
import { AI_PROVIDERS } from "@/shared/constants/providers";
const AUDIO = new Uint8Array(256).fill(7);
function okResponse() {
return {
ok: true,
status: 200,
headers: new Headers({ "content-type": "audio/mpeg" }),
arrayBuffer: async () => AUDIO.buffer,
};
}
describe("Fish Audio TTS provider", () => {
const entry = REGISTRY.find((e) => e.id === "fish-audio");
it("is registered as a TTS-only apikey provider", () => {
expect(entry).toBeDefined();
expect(entry.category).toBe("apikey");
expect(entry.serviceKinds).toEqual(["tts"]);
expect(PROVIDER_MEDIA["fish-audio"]?.ttsConfig?.baseUrl).toBe("https://api.fish.audio/v1/tts");
});
it("is visible to the generic dispatcher, which reads AI_PROVIDERS", () => {
// synthesizeViaConfig() looks the provider up here, not in PROVIDER_MEDIA.
expect(AI_PROVIDERS["fish-audio"]?.ttsConfig?.format).toBe("fish-audio");
expect(typeof FORMAT_HANDLERS["fish-audio"]).toBe("function");
});
it("exposes the four documented models", () => {
const ids = (entry.ttsConfig.models || []).map((m) => m.id);
expect(ids).toEqual(["s2.1-pro-free", "s2.1-pro", "s2-pro", "s1"]);
});
it("keeps registry ids and aliases unique", () => {
const ids = REGISTRY.map((e) => e.id);
expect(new Set(ids).size).toBe(ids.length);
const aliases = REGISTRY.map((e) => e.alias).filter(Boolean);
expect(new Set(aliases).size).toBe(aliases.length);
});
});
describe("Fish Audio TTS request shape", () => {
const handler = FORMAT_HANDLERS["fish-audio"];
let fetchMock;
beforeEach(() => {
fetchMock = vi.fn(async () => okResponse());
global.fetch = fetchMock;
});
const callArgs = () => {
const [url, init] = fetchMock.mock.calls.at(-1);
return { url, init, body: JSON.parse(init.body) };
};
it("sends the model as an HTTP header, not in the body", async () => {
await handler({
baseUrl: "https://api.fish.audio/v1/tts",
apiKey: "sk-test",
text: "xin chào",
modelId: "s1",
voiceId: "",
});
const { url, init, body } = callArgs();
expect(url).toBe("https://api.fish.audio/v1/tts");
expect(init.headers.model).toBe("s1");
expect(init.headers.Authorization).toBe("Bearer sk-test");
expect(body).toEqual({ text: "xin chào", format: "mp3" });
});
it("maps the voice onto reference_id, and omits it when unset", async () => {
await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "voice-abc" });
expect(callArgs().body.reference_id).toBe("voice-abc");
await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "" });
expect(callArgs().body).not.toHaveProperty("reference_id");
});
it("defaults to the free model when none is given", async () => {
await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "", voiceId: "" });
expect(callArgs().init.headers.model).toBe("s2.1-pro-free");
});
it("returns base64 audio with its format", async () => {
const out = await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "" });
expect(out.format).toBe("mp3");
expect(typeof out.base64).toBe("string");
expect(out.base64.length).toBeGreaterThan(0);
});
it("surfaces the upstream error message", async () => {
global.fetch = vi.fn(async () => ({
ok: false,
status: 402,
text: async () => JSON.stringify({ message: "Insufficient credit" }),
}));
await expect(
handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "" }),
).rejects.toThrow("Insufficient credit");
});
});

View file

@ -0,0 +1,83 @@
// Issue #3024 — Fusion combo must strip `stream_options` from panel requests
// when running non-streaming, or DeepSeek rejects with
// "stream_options should be set along with stream = true".
import { describe, it, expect, vi } from "vitest";
import { handleFusionChat } from "../../open-sse/services/combo.js";
// Minimal logger stub (combo.js calls log.info/warn).
const log = { info: () => {}, warn: () => {}, error: () => {} };
function makeBody(extra = {}) {
return {
model: "combo/gemseek",
stream: true,
stream_options: { include_usage: true },
messages: [{ role: "user", content: "hi" }],
...extra,
};
}
describe("Fusion strips stream_options (#3024)", () => {
it("removes stream_options before fanning out to panel models", async () => {
let capturedPanelBody = null;
const handleSingleModel = vi.fn(async (panelBody, model, isPanel) => {
if (isPanel) capturedPanelBody = panelBody;
// Simulate a successful non-stream JSON answer for the panel.
if (isPanel) {
return new Response(JSON.stringify({ choices: [{ message: { content: `ans-${model}` } }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
// Judge leg: return a final answer.
return new Response(JSON.stringify({ choices: [{ message: { content: "final" } }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
const res = await handleFusionChat({
body: makeBody(),
models: ["ds/deepseek-v4-flash", "gemini/gemini-3.5-flash-lite"],
handleSingleModel,
log,
comboName: "GemSeek",
judgeModel: "gemini/gemini-3.5-flash-lite",
});
expect(res).toBeInstanceOf(Response);
expect(capturedPanelBody).not.toBeNull();
// Critical assertion: stream_options must NOT leak into panel requests.
expect(capturedPanelBody.stream_options).toBeUndefined();
expect(capturedPanelBody.stream).toBe(false);
// Ensure the original client body still had it (proves we stripped deliberately).
expect(makeBody().stream_options).toBeDefined();
});
it("does not throw for a 2-model fusion with stream_options present", async () => {
const handleSingleModel = vi.fn(async (panelBody, model, isPanel) => {
if (isPanel) {
return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ choices: [{ message: { content: "final" } }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
const res = await handleFusionChat({
body: makeBody({ stream_options: { include_usage: true } }),
models: ["ds/deepseek-v4-pro", "ds/deepseek-v4-flash"],
handleSingleModel,
log,
comboName: "GemSeek",
judgeModel: "ds/deepseek-v4-pro",
});
expect(res.status).toBe(200);
});
});

View file

@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
import antigravityRegistry from "../../open-sse/providers/registry/antigravity.js";
import geminiRegistry from "../../open-sse/providers/registry/gemini.js";
import { MODEL_PRICING } from "../../open-sse/providers/pricing.js";
describe("Gemini 3.7 Flash Support & Config (#3286, #3281)", () => {
it("registers gemini-3.7-flash tiered models in antigravity provider registry", () => {
const agIds = antigravityRegistry.models.map(m => m.id);
expect(agIds).toContain("gemini-3.7-flash-high");
expect(agIds).toContain("gemini-3.7-flash-medium");
expect(agIds).toContain("gemini-3.7-flash-low");
expect(agIds).not.toContain("gemini-3.7-flash");
});
it("registers gemini-3.7-flash in gemini provider registry", () => {
const geminiIds = geminiRegistry.models.map(m => m.id);
expect(geminiIds).toContain("gemini-3.7-flash");
});
it("resolves capabilities correctly for gemini-3.7 models with official limits", () => {
const caps = getCapabilitiesForModel("antigravity", "gemini-3.7-flash-high");
expect(caps.vision).toBe(true);
expect(caps.reasoning).toBe(true);
expect(caps.thinkingFormat).toBe("gemini-level");
expect(caps.contextWindow).toBe(1048576);
expect(caps.maxOutput).toBe(65536);
});
it("defines pricing matching gemini-3.6-flash baseline", () => {
expect(MODEL_PRICING["gemini-3.7-flash"]).toEqual(MODEL_PRICING["gemini-3.6-flash"]);
expect(MODEL_PRICING["gemini-3.7-flash-high"]).toEqual(MODEL_PRICING["gemini-3.6-flash-high"]);
expect(MODEL_PRICING["gemini-3.7-flash-medium"]).toEqual(MODEL_PRICING["gemini-3.6-flash-medium"]);
expect(MODEL_PRICING["gemini-3.7-flash-low"]).toEqual(MODEL_PRICING["gemini-3.6-flash-low"]);
});
});

View file

@ -0,0 +1,115 @@
import { describe, it, expect } from "vitest";
import { detectRequiredCapabilities } from "../../open-sse/services/combo.js";
import { augmentModelsWithCapacityAdapter } from "../../open-sse/services/capacityAdapter.js";
import { stripUnsupportedModalities } from "../../open-sse/translator/concerns/modality.js";
import { FORMATS } from "../../open-sse/translator/formats.js";
describe("Hermes Vision Image Detection", () => {
it("detects vision from Ollama / Hermes images array", () => {
const body = {
messages: [
{
role: "user",
content: "Please analyze this image from Hermes",
images: ["iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="],
},
],
};
const caps = detectRequiredCapabilities(body);
expect(caps.has("vision")).toBe(true);
});
it("detects vision from Vercel AI SDK / Hermes experimental_attachments", () => {
const body = {
messages: [
{
role: "user",
content: "Describe this attachment",
experimental_attachments: [
{
contentType: "image/png",
url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
},
],
},
],
};
const caps = detectRequiredCapabilities(body);
expect(caps.has("vision")).toBe(true);
});
it("detects vision from Hermes attachments array", () => {
const body = {
messages: [
{
role: "user",
content: "Look at this photo",
attachments: [
{
mediaType: "image/jpeg",
url: "https://example.com/photo.jpg",
},
],
},
],
};
const caps = detectRequiredCapabilities(body);
expect(caps.has("vision")).toBe(true);
});
it("detects vision from embedded data:image URI in string content", () => {
const body = {
messages: [
{
role: "user",
content: "Here is an inline image: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
},
],
};
const caps = detectRequiredCapabilities(body);
expect(caps.has("vision")).toBe(true);
});
it("auto-switches non-vision model (deepseek-v4-pro) to Vision Adapter model (Kimi-K3)", () => {
const body = {
messages: [
{
role: "user",
content: "Analyze image",
images: ["base64data..."],
},
],
};
const reqCaps = detectRequiredCapabilities(body);
const settings = {
capacityAdapter: {
vision: {
enabled: true,
models: ["cmc/moonshotai/Kimi-K3"],
},
},
};
const augmented = augmentModelsWithCapacityAdapter(["cmc/deepseek/deepseek-v4-pro"], reqCaps, settings);
expect(augmented).toEqual(["cmc/moonshotai/Kimi-K3", "cmc/deepseek/deepseek-v4-pro"]);
});
it("strips msg.images and attachments when model does not support vision", () => {
const body = {
messages: [
{
role: "user",
content: "Test text",
images: ["base64..."],
experimental_attachments: [{ contentType: "image/png", url: "data:image/png;base64,..." }],
},
],
};
const noVisionCaps = { vision: false, pdf: false, audioInput: false };
stripUnsupportedModalities(body, FORMATS.OPENAI, noVisionCaps);
expect(body.messages[0].images).toBeUndefined();
expect(body.messages[0].experimental_attachments).toHaveLength(0);
});
});

View file

@ -14,6 +14,13 @@ describe("Kiro MITM model slots", () => {
expect(Array.isArray(kiro.defaultModels)).toBe(true);
});
it("offers a mappable slot for the agent default model id 'auto'", () => {
// اسلات auto برای vibe mode لازمه — وگرنه درخواست میره AWS
const auto = kiro.defaultModels.find((m) => m.id === "auto");
expect(auto).toBeTruthy();
expect(auto.alias).toBe("auto");
});
it("offers a mappable slot for Claude Sonnet 5", () => {
const sonnet5 = kiro.defaultModels.find((m) => m.id === "claude-sonnet-5");
expect(sonnet5).toBeTruthy();

View file

@ -0,0 +1,369 @@
/**
* Five Kiro defects, all on the OAuth/social Kiro route (kr/claude-sonnet-4.5
* with a >100k context). Production shape: 402 of 2156 usageHistory rows for
* Kiro recorded completionTokens 0, and the 25 newest rows all sat pinned at
* exactly 1 output token against prompts of 80k-103k -- i.e. the router was not
* measuring the answer, it was measuring nothing and rounding up.
*
* A. OUT 0 / OUT 1. finish() estimates completion tokens as
* totalContentLength / 4, but tool-call bytes were never added to
* totalContentLength. A turn whose entire answer is a tool call therefore
* measured as an empty answer (Math.max(1, ...) is where the 1 comes from).
*
* B. Truncation threw away a complete-enough answer. stopDisposition() maps
* model_context_window_exceeded (and max_tokens alongside tool calls) to
* terminal_incomplete, which hard-fails the turn -- even when the model had
* already streamed text. A truncated turn is what finish_reason "length" is
* for. Both disposition gates needed the bypass: the declared-stop-reason
* gate runs first and returns, so patching only finish() would be dead code.
*
* C. One bad tool fragment killed every good one. Three separate latches:
* emitTools() validated per turn and threw out of the loop; the frame-loop
* catch cleared state.tools wholesale; and the toolUseEvent branch returned
* early forever once toolValidationError was set. Net effect for the client:
* a turn that answered nothing.
*
* D. Cache tokens dropped on the kiro:claude route. kiro-to-claude built usage
* from prompt_tokens/completion_tokens only, so Claude clients lost
* cache_read_input_tokens / cache_creation_input_tokens and could neither
* price the turn nor size their prompt cache.
*
* E. Defence-in-depth only: both request translators discarded canonical.valid.
* canonicalizeKiroConversation() self-heals every failure mode it can detect
* (see the test below), so the guard is unreachable by construction today --
* it exists so a future validator rule cannot ship an unusable body silently.
*
* Kiro also answers an unusable conversation with 400 {"message":"Improperly
* formed request.","reason":"REQUEST_BODY_INVALID"}, which cools down every
* account that reports it. That is a property of ERROR_RULES rather than of this
* executor -- the same body fails identically on any account -- so it belongs to
* the request-scoped `fallback: false` rule kind, not here.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const fetchMock = vi.fn();
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
proxyAwareFetch: (...args) => fetchMock(...args)
}));
const { KiroExecutor } = await import("../../open-sse/executors/kiro.js");
const { kiroToClaudeResponse, kiroToClaudeNonStreaming } = await import(
"../../open-sse/translator/response/kiro-to-claude.js"
);
const { validateKiroConversation, canonicalizeKiroConversation } = await import(
"../../open-sse/translator/concerns/kiroConversation.js"
);
const encoder = new TextEncoder();
const credentials = {
accessToken: "test-token",
providerSpecificData: { kiroToolCallRepair: true }
};
function crc32(bytes) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit++) {
crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function encodeHeader(name, value) {
const nameBytes = encoder.encode(name);
const valueBytes = encoder.encode(value);
const bytes = new Uint8Array(1 + nameBytes.length + 3 + valueBytes.length);
let offset = 0;
bytes[offset++] = nameBytes.length;
bytes.set(nameBytes, offset);
offset += nameBytes.length;
bytes[offset++] = 7;
new DataView(bytes.buffer).setUint16(offset, valueBytes.length, false);
offset += 2;
bytes.set(valueBytes, offset);
return bytes;
}
function concat(chunks) {
const output = new Uint8Array(chunks.reduce((size, chunk) => size + chunk.byteLength, 0));
let offset = 0;
for (const chunk of chunks) {
output.set(chunk, offset);
offset += chunk.byteLength;
}
return output;
}
function checksum(bytes) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
view.setUint32(8, crc32(bytes.subarray(0, 8)), false);
view.setUint32(bytes.byteLength - 4, crc32(bytes.subarray(0, bytes.byteLength - 4)), false);
return bytes;
}
function frameFromEntries(entries, payload) {
const headers = concat(entries.map(([name, value]) => encodeHeader(name, value)));
const payloadBytes = encoder.encode(JSON.stringify(payload));
const totalLength = 12 + headers.byteLength + payloadBytes.byteLength + 4;
const frame = new Uint8Array(totalLength);
const view = new DataView(frame.buffer);
view.setUint32(0, totalLength, false);
view.setUint32(4, headers.byteLength, false);
frame.set(headers, 12);
frame.set(payloadBytes, 12 + headers.byteLength);
return checksum(frame);
}
function frame(eventType, payload) {
return frameFromEntries([[":event-type", eventType]], payload);
}
function response(frames, status = 200) {
return new Response(new ReadableStream({
start(controller) {
for (const value of frames) controller.enqueue(value);
controller.close();
}
}), { status, statusText: status === 200 ? "OK" : "Upstream Error" });
}
async function execute(executor = new KiroExecutor(), overrides = {}) {
return executor.execute({
model: "kr/claude-opus-4.8",
body: { systemPrompt: "base", conversationState: {} },
stream: true,
credentials,
...overrides
});
}
// Output is held behind the ": kiro-validation" heartbeat until clean EOF, so
// every executor assertion has to drain the whole response.
async function run(frames) {
fetchMock.mockResolvedValueOnce(response(frames));
return await (await execute()).response.text();
}
// Same as run(), but with the bounded tool-call repair retry disabled, so a
// hard failure is surfaced from the first attempt instead of triggering a
// second upstream fetch.
async function runNoRepair(frames) {
fetchMock.mockResolvedValueOnce(response(frames));
const result = await execute(new KiroExecutor(), {
credentials: { accessToken: "test-token", providerSpecificData: { kiroToolCallRepair: false } }
});
return await result.response.text();
}
// The estimator only runs when metering and context usage both arrived and the
// upstream reported no token totals of its own -- the exact production shape.
const METERED = [
frame("meteringEvent", { usage: 2, unit: "credit" }),
frame("contextUsageEvent", { contextUsagePercentage: 10 })
];
function usageFrom(body) {
const usages = body
.split("\n")
.filter(line => line.startsWith("data: ") && line.includes('"usage"'))
.map(line => JSON.parse(line.slice(6)).usage)
.filter(Boolean);
return usages[usages.length - 1];
}
beforeEach(() => {
fetchMock.mockReset();
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("A: tool-call bytes count as output tokens", () => {
it("does not report a tool-only turn as OUT 1", async () => {
const body = await run([
frame("toolUseEvent", {
toolUseId: "call_a",
name: "tool_call",
input: { name: "mcp_search", arguments: { query: "why is the router reporting zero output" } }
}),
frame("metadataEvent", { stopReason: "tool_use" }),
...METERED
]);
const usage = usageFrom(body);
expect(usage).toBeDefined();
// Was 1: the Math.max floor over a totalContentLength of 0.
expect(usage.completion_tokens).toBeGreaterThan(10);
expect(usage.total_tokens).toBe(usage.prompt_tokens + usage.completion_tokens);
});
it("still counts plain text output", async () => {
const body = await run([
frame("assistantResponseEvent", { content: "x".repeat(400) }),
frame("metadataEvent", { stopReason: "end_turn" }),
...METERED
]);
expect(usageFrom(body).completion_tokens).toBe(100);
});
});
describe("C: one unusable tool fragment does not take the whole turn with it", () => {
it("ships the valid call and drops only the invalid one", async () => {
const body = await run([
frame("toolUseEvent", {
toolUseId: "good",
name: "tool_call",
input: { name: "mcp_search", arguments: { q: "router" } }
}),
// No nested MCP name -> unusable, cannot be forwarded to the client.
frame("toolUseEvent", { toolUseId: "bad", name: "tool_call", input: { arguments: { q: "router" } } }),
frame("metadataEvent", { stopReason: "tool_use" }),
...METERED
]);
expect(body).toContain('\\"name\\":\\"mcp_search\\"');
expect(body).not.toContain('"id":"bad"');
expect(body).toContain('"finish_reason":"tool_calls"');
});
it("keeps streamed text when the only tool call is unusable", async () => {
const body = await run([
frame("assistantResponseEvent", { content: "Here is what I found." }),
frame("toolUseEvent", { toolUseId: "bad", name: "tool_call", input: { arguments: {} } }),
frame("metadataEvent", { stopReason: "tool_use" }),
...METERED
]);
expect(body).toContain("Here is what I found.");
expect(body).not.toContain("invalid_kiro_tool_call");
});
it("still hard-fails when nothing usable was produced at all", async () => {
const body = await runNoRepair([
frame("toolUseEvent", { toolUseId: "bad", name: "tool_call", input: { arguments: {} } }),
frame("metadataEvent", { stopReason: "tool_use" })
]);
expect(body).toContain("invalid_kiro_tool_call");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe("B: truncation after output closes as length, not as a failure", () => {
it("keeps the text and reports finish_reason length", async () => {
const body = await run([
frame("assistantResponseEvent", { content: "Partial but usable answer." }),
frame("metadataEvent", { stopReason: "model_context_window_exceeded" }),
...METERED
]);
expect(body).toContain("Partial but usable answer.");
expect(body).toContain('"finish_reason":"length"');
expect(body).not.toContain("kiro_terminal_incomplete");
});
it("still fails a truncation that produced nothing", async () => {
const body = await run([
frame("metadataEvent", { stopReason: "model_context_window_exceeded" })
]);
expect(body).toContain("kiro_terminal_incomplete");
});
});
describe("D: cache tokens survive the kiro -> claude translation", () => {
const finishChunk = (usage) => ({
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage
});
function finalUsage(usage) {
const state = {};
// Usage rides an earlier chunk in the real stream; feed it the same way.
kiroToClaudeResponse({ choices: [{ index: 0, delta: { content: "hi" } }], usage }, state);
const events = kiroToClaudeResponse(finishChunk(usage), state) || [];
return events.find(e => e.type === "message_delta")?.usage;
}
it("forwards the flat Chat spelling the executor emits", () => {
expect(finalUsage({
prompt_tokens: 103000,
completion_tokens: 640,
cache_read_input_tokens: 98000,
cache_creation_input_tokens: 1912
})).toEqual({
input_tokens: 103000,
output_tokens: 640,
cache_read_input_tokens: 98000,
cache_creation_input_tokens: 1912
});
});
it("also accepts the nested details spelling used on passthrough", () => {
expect(finalUsage({
prompt_tokens: 500,
completion_tokens: 20,
prompt_tokens_details: { cached_tokens: 480, cache_creation_tokens: 20 }
})).toEqual({
input_tokens: 500,
output_tokens: 20,
cache_read_input_tokens: 480,
cache_creation_input_tokens: 20
});
});
it("omits the cache keys when the upstream reported none", () => {
expect(finalUsage({ prompt_tokens: 500, completion_tokens: 20 }))
.toEqual({ input_tokens: 500, output_tokens: 20 });
});
it("preserves cache on the non-streaming path too", () => {
const message = kiroToClaudeNonStreaming({
choices: [{ message: { content: "hi" } }],
usage: { prompt_tokens: 90, completion_tokens: 4, cache_read_input_tokens: 80 }
});
expect(message.usage).toMatchObject({
input_tokens: 90,
output_tokens: 4,
cache_read_input_tokens: 80
});
expect(message.usage).not.toHaveProperty("cache_creation_input_tokens");
});
});
describe("E: the translator valid-guard is defence-in-depth", () => {
const SPECS = [{ toolSpecification: { name: "read_file", inputSchema: { json: { type: "object" } } } }];
it("validateKiroConversation names the offending turn", () => {
// Hand-built, NOT normalized: assistant first, then a tool call with no
// matching result and a name no spec declares.
const result = validateKiroConversation(
[{ assistantResponseMessage: { content: "hi", toolUses: [{ toolUseId: "t1", name: "ghost" }] } }],
{ userInputMessage: { content: "go" } },
SPECS
);
expect(result.valid).toBe(false);
expect(result.errors).toContain("role:0");
expect(result.errors).toContain("pair:0");
expect(result.errors).toContain("spec:0");
});
it("canonicalizeKiroConversation heals that same conversation", () => {
// This is why the guard cannot fire today: normalizeTurns() forces
// user-first/user-last alternation and non-empty content, and the
// second-chance pass flattens every structured tool turn to text.
const out = canonicalizeKiroConversation({
history: [{ assistantResponseMessage: { content: "hi", toolUses: [{ toolUseId: "t1", name: "ghost" }] } }],
currentMessage: { userInputMessage: { content: "" } },
modelId: "claude-sonnet-4-5",
toolSpecs: SPECS
});
expect(out.valid).toBe(true);
expect(out.errors).toEqual([]);
expect(out.currentMessage.userInputMessage.content).toBe("continue");
expect(out.history[0].userInputMessage).toBeDefined();
});
});

View file

@ -0,0 +1,211 @@
// GHSA-pjm4-8fpg-f9p6 (#3294): `next start` leaves custom-server.js out of the request
// path, so x-9r-real-ip arrives straight from the client and a remote caller can claim to
// be loopback. Host is spoofable the same way, so it cannot be the production fallback.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const mocks = vi.hoisted(() => ({
nextResponse: Symbol("next"),
jsonResponse: vi.fn((body, init) => ({ status: init?.status || 200, body })),
getSettings: vi.fn(),
validateApiKey: vi.fn(),
getConsistentMachineId: vi.fn(),
verifyDashboardAuthToken: vi.fn(),
}));
vi.mock("next/server", () => ({
NextResponse: {
next: vi.fn(() => mocks.nextResponse),
json: mocks.jsonResponse,
redirect: vi.fn((url) => ({ status: 307, url })),
},
}));
vi.mock("@/lib/localDb", () => ({
getSettings: mocks.getSettings,
validateApiKey: mocks.validateApiKey,
}));
vi.mock("@/shared/utils/machineId", () => ({
getConsistentMachineId: mocks.getConsistentMachineId,
}));
vi.mock("@/lib/auth/dashboardSession", () => ({
verifyDashboardAuthToken: mocks.verifyDashboardAuthToken,
}));
const { proxy } = await import("../../src/dashboardGuard.js");
const { getClientIp } = await import("../../src/lib/auth/loginLimiter.js");
const PEER_TOKEN = "peer-token-fixture";
function request(pathname, headers = {}) {
return {
nextUrl: { pathname, searchParams: new URL(`http://localhost${pathname}`).searchParams },
headers: new Headers(headers),
cookies: { get: vi.fn(() => undefined) },
url: `http://localhost${pathname}`,
};
}
const originalNodeEnv = process.env.NODE_ENV;
describe("peer header trust", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
process.env.NODE_ENV = "production";
mocks.getSettings.mockResolvedValue({ requireLogin: true });
mocks.validateApiKey.mockResolvedValue(false);
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
mocks.verifyDashboardAuthToken.mockResolvedValue(false);
});
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
delete process.env.NINEROUTER_PEER_TOKEN;
});
it("rejects a spoofed loopback peer IP that carries no trust proof", async () => {
const response = await proxy(request("/api/v1/models", {
host: "172.18.192.1:20140",
"x-9r-real-ip": "127.0.0.1",
}));
expect(response.status).toBe(401);
expect(response.body.error).toBe("API key required for remote API access");
});
it("rejects a spoofed loopback peer IP carrying a wrong trust token", async () => {
const response = await proxy(request("/api/v1/models", {
host: "172.18.192.1:20140",
"x-9r-real-ip": "127.0.0.1",
"x-9r-peer-token": "guessed-token",
}));
expect(response.status).toBe(401);
});
it("rejects a spoofed loopback Host in production", async () => {
const response = await proxy(request("/api/v1/models", { host: "localhost" }));
expect(response.status).toBe(401);
});
it("rejects a spoofed loopback peer IP when the wrapper never booted", async () => {
delete process.env.NINEROUTER_PEER_TOKEN;
const response = await proxy(request("/api/v1/models", {
host: "172.18.192.1:20140",
"x-9r-real-ip": "127.0.0.1",
"x-9r-peer-token": "any-token",
}));
expect(response.status).toBe(401);
});
it("keeps serving a genuinely local request stamped by the wrapper", async () => {
const response = await proxy(request("/api/v1/models", {
host: "localhost:20128",
"x-9r-real-ip": "127.0.0.1",
"x-9r-peer-token": PEER_TOKEN,
}));
expect(response).toBe(mocks.nextResponse);
expect(mocks.validateApiKey).not.toHaveBeenCalled();
});
// A dual-stack listener reports loopback as ::ffff:127.0.0.1, which the old
// split-on-first-colon check reduced to "".
it.each(["::ffff:127.0.0.1", "::1", "[::1]", "127.0.0.1", "::FFFF:127.0.0.1"])(
"treats %s as a loopback peer",
async (peerIp) => {
const response = await proxy(request("/api/v1/models", {
host: "localhost:20128",
"x-9r-real-ip": peerIp,
"x-9r-peer-token": PEER_TOKEN,
}));
expect(response).toBe(mocks.nextResponse);
}
);
it.each(["::ffff:10.204.111.34", "2001:db8::1", "[2001:db8::1]", "10.204.111.34"])(
"refuses %s as a peer",
async (peerIp) => {
const response = await proxy(request("/api/v1/models", {
host: "localhost:20128",
"x-9r-real-ip": peerIp,
"x-9r-peer-token": PEER_TOKEN,
}));
expect(response.status).toBe(401);
}
);
it("still refuses a stamped non-loopback peer IP", async () => {
const response = await proxy(request("/api/v1/models", {
host: "localhost:20128",
"x-9r-real-ip": "10.204.111.34",
"x-9r-peer-token": PEER_TOKEN,
}));
expect(response.status).toBe(401);
});
it("blocks spoofed local-only routes that would otherwise spawn processes", async () => {
mocks.getSettings.mockResolvedValue({ requireLogin: false });
const response = await proxy(request("/api/mcp/filesystem/sse", {
host: "172.18.192.1:20140",
"x-9r-real-ip": "127.0.0.1",
}));
expect(response.status).toBe(403);
expect(response.body.error).toBe("Local only: CLI token required");
});
it("accepts the legacy Host fallback only in development", async () => {
process.env.NODE_ENV = "development";
const response = await proxy(request("/api/v1/models", { host: "localhost:20127" }));
expect(response).toBe(mocks.nextResponse);
});
});
describe("login limiter client IP", () => {
beforeEach(() => {
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
delete process.env.TRUST_PROXY;
});
afterEach(() => {
delete process.env.NINEROUTER_PEER_TOKEN;
delete process.env.TRUST_PROXY;
});
it("buckets spoofed peer IPs together so lockout cannot be rotated away", () => {
const first = getClientIp(request("/api/auth/login", { "x-9r-real-ip": "1.1.1.1" }));
const second = getClientIp(request("/api/auth/login", { "x-9r-real-ip": "2.2.2.2" }));
expect(first).toBe("unknown");
expect(second).toBe("unknown");
});
it("keys on the stamped peer IP when the wrapper proved it", () => {
const ip = getClientIp(request("/api/auth/login", {
"x-9r-real-ip": "203.0.113.9",
"x-9r-peer-token": PEER_TOKEN,
}));
expect(ip).toBe("203.0.113.9");
});
it("still honours TRUST_PROXY for operators fronting 9router with a reverse proxy", () => {
process.env.TRUST_PROXY = "true";
const ip = getClientIp(request("/api/auth/login", { "x-forwarded-for": "198.51.100.7, 10.0.0.1" }));
expect(ip).toBe("198.51.100.7");
});
});

View file

@ -0,0 +1,52 @@
/**
* Some providers (e.g. codebuddy / cbcn) attach `tool_calls: []` to every
* streaming chunk. An empty array is truthy in JS, so the guard
* `if (delta.tool_calls)` closed the message on the first content token,
* emitting `output_text.done` early and truncating the answer. This mirrors
* the real repro: `codex exec -m cbcn/kimi-k3` answered only "cod" instead
* of "codex-ok".
*/
import { describe, it, expect } from "vitest";
import { openaiToOpenAIResponsesResponse } from "../../open-sse/translator/response/openai-responses.js";
import { initState } from "../../open-sse/translator/index.js";
import { FORMATS } from "../../open-sse/translator/formats.js";
describe("OpenAI Chat stream → Responses: empty tool_calls arrays", () => {
it("does not emit output_text.done early when every chunk carries tool_calls: []", () => {
const state = initState(FORMATS.OPENAI_RESPONSES);
const chunks = [
{ id: "cmb-test", choices: [{ index: 0, delta: { role: "assistant", content: "", reasoning_content: "", tool_calls: [] }, finish_reason: null }] },
{ id: "cmb-test", choices: [{ index: 0, delta: { content: "", reasoning_content: "thinking", tool_calls: [] }, finish_reason: null }] },
{ id: "cmb-test", choices: [{ index: 0, delta: { content: "cod", reasoning_content: "", tool_calls: [] }, finish_reason: null }] },
{ id: "cmb-test", choices: [{ index: 0, delta: { content: "ex", reasoning_content: "", tool_calls: [] }, finish_reason: null }] },
{ id: "cmb-test", choices: [{ index: 0, delta: { content: "-ok", reasoning_content: "", tool_calls: [] }, finish_reason: null }] },
{ id: "cmb-test", choices: [{ index: 0, delta: { content: "", reasoning_content: "", tool_calls: [] }, finish_reason: "stop" }] },
];
const events = chunks.flatMap((chunk) => openaiToOpenAIResponsesResponse(chunk, state));
const textDone = events.filter((e) => e.event === "response.output_text.done");
const textDeltas = events.filter((e) => e.event === "response.output_text.delta");
expect(textDone).toHaveLength(1);
expect(textDone[0].data.text).toBe("codex-ok");
expect(textDeltas.map((e) => e.data.delta).join("")).toBe("codex-ok");
// done must come after every delta
expect(events.indexOf(textDone[0])).toBe(events.indexOf(textDeltas[textDeltas.length - 1]) + 1);
});
it("still closes the message before a real tool call", () => {
const state = initState(FORMATS.OPENAI_RESPONSES);
const chunks = [
{ id: "cmb-test", choices: [{ index: 0, delta: { content: "Let me run that.", tool_calls: [] }, finish_reason: null }] },
{ id: "cmb-test", choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "exec", arguments: "" } }] }, finish_reason: null }] },
{ id: "cmb-test", choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] },
];
const events = chunks.flatMap((chunk) => openaiToOpenAIResponsesResponse(chunk, state));
const added = events.find((e) => e.event === "response.output_item.added" && e.data.item?.type === "function_call");
const textDone = events.find((e) => e.event === "response.output_text.done");
expect(added).toBeTruthy();
expect(textDone.data.text).toBe("Let me run that.");
});
});

View file

@ -1,71 +1,97 @@
import { describe, expect, it } from "vitest";
import { PROVIDER_MODELS, getModelTargetFormat } from "../../open-sse/config/providerModels.js";
import { OpenCodeGoExecutor } from "../../open-sse/executors/opencode-go.js";
import { PROVIDER_MODELS, getModelSupportedFormats } from "../../open-sse/config/providerModels.js";
import { PROVIDERS } from "../../open-sse/config/providers.js";
import { resolveTransport } from "../../open-sse/services/provider.js";
const CHAT_MODELS = [
"glm-5.2",
"glm-5.1",
// OpenCode Go docs' endpoint table currently says kimi-k2.7, but its
// config example and the live API use kimi-k2.7-code.
"kimi-k2.7-code",
"kimi-k2.6",
"deepseek-v4-pro",
"deepseek-v4-flash",
"mimo-v2.5",
"mimo-v2.5-pro",
];
// Chat-only models (no /messages, no /responses support on opencode-go)
const CHAT_ONLY = ["glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "mimo-v2.5", "mimo-v2.5-pro"];
// Models that also expose the Anthropic /messages endpoint
const CLAUDE_CAPABLE = ["minimax-m3", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus"];
// Models that also expose the OpenAI /responses endpoint
const RESPONSES_CAPABLE = ["deepseek-v4-pro", "deepseek-v4-flash"];
const MESSAGES_MODELS = [
"minimax-m3",
"minimax-m2.7",
"minimax-m2.5",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.6-plus",
];
// Mirror of chatCore's per-model transport guard: use the sourceFormat-matched
// transport only when the model declares support for that sourceFormat.
function pickTransport(provider, sourceFormat, alias, model) {
const supported = getModelSupportedFormats(alias, model);
const rt = resolveTransport(provider, sourceFormat);
return supported?.includes(sourceFormat) ? rt : null;
}
describe("OpenCode Go official model catalog", () => {
it("matches the documented OpenCode Go model IDs", () => {
const ids = (PROVIDER_MODELS["opencode-go"] || []).map((model) => model.id);
expect(ids).toEqual([...CHAT_MODELS, ...MESSAGES_MODELS]);
describe("OpenCode Go model catalog", () => {
it("matches the documented model IDs", () => {
const ids = (PROVIDER_MODELS["opencode-go"] || []).map((m) => m.id);
expect(ids).toEqual([
"glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6",
"deepseek-v4-pro", "deepseek-v4-flash",
"mimo-v2.5", "mimo-v2.5-pro",
"minimax-m3", "minimax-m2.7", "minimax-m2.5",
"qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus",
]);
});
});
it("marks documented Qwen and MiniMax models as Anthropic messages format", () => {
for (const model of MESSAGES_MODELS) {
expect(getModelTargetFormat("opencode-go", model)).toBe("claude");
describe("OpenCode Go per-model supportedFormats", () => {
it("declares [openai, claude] for MiniMax + Qwen models", () => {
for (const m of CLAUDE_CAPABLE) {
expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai", "claude"]);
}
});
it("keeps GLM, Kimi, DeepSeek, and MiMo on OpenAI-compatible chat format", () => {
for (const model of CHAT_MODELS) {
expect(getModelTargetFormat("opencode-go", model)).toBeNull();
it("declares [openai, claude, openai-responses] for DeepSeek models", () => {
for (const m of RESPONSES_CAPABLE) {
expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai", "claude", "openai-responses"]);
}
});
it("declares [openai] only for chat-only models (GLM/Kimi/MiMo) → guards /messages routing", () => {
for (const m of CHAT_ONLY) {
expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai"]);
}
});
});
describe("OpenCode Go endpoint routing", () => {
it("routes Qwen and MiniMax models to the messages endpoint with x-api-key auth", () => {
const executor = new OpenCodeGoExecutor();
describe("OpenCode Go multi-endpoint transports", () => {
it("declares openai / claude / openai-responses transports", () => {
const formats = (PROVIDERS["opencode-go"].transports || []).map((t) => t.format);
expect(formats).toEqual(["openai", "claude", "openai-responses"]);
});
for (const model of MESSAGES_MODELS) {
expect(executor.buildUrl(model)).toBe("https://opencode.ai/zen/go/v1/messages");
const headers = executor.buildHeaders({ apiKey: "sk-test" }, false);
expect(headers["x-api-key"]).toBe("sk-test");
expect(headers["anthropic-version"]).toBeDefined();
expect(headers.Authorization).toBeUndefined();
it("resolveTransport picks the endpoint matching the client sourceFormat", () => {
expect(resolveTransport("opencode-go", "claude").baseUrl).toBe("https://opencode.ai/zen/go/v1/messages");
expect(resolveTransport("opencode-go", "openai-responses").baseUrl).toBe("https://opencode.ai/zen/go/v1/responses");
expect(resolveTransport("opencode-go", "openai").baseUrl).toBe("https://opencode.ai/zen/go/v1/chat/completions");
});
it("uses x-api-key + anthropicVersion on the claude transport", () => {
const t = resolveTransport("opencode-go", "claude");
expect(t.auth.header).toBe("x-api-key");
expect(t.auth.anthropicVersion).toBe(true);
});
});
describe("OpenCode Go per-model transport guard (chatCore logic)", () => {
it("routes MiniMax/Qwen + claude-format client to /messages", () => {
for (const m of CLAUDE_CAPABLE) {
expect(pickTransport("opencode-go", "claude", "opencode-go", m)?.baseUrl).toBe("https://opencode.ai/zen/go/v1/messages");
}
});
it("routes GLM, Kimi, DeepSeek, and MiMo models to chat/completions with bearer auth", () => {
const executor = new OpenCodeGoExecutor();
it("does NOT route chat-only models to /messages on a claude-format request", () => {
for (const m of CHAT_ONLY) {
expect(pickTransport("opencode-go", "claude", "opencode-go", m)).toBeNull();
}
});
for (const model of CHAT_MODELS) {
expect(executor.buildUrl(model)).toBe("https://opencode.ai/zen/go/v1/chat/completions");
const headers = executor.buildHeaders({ apiKey: "sk-test" }, false);
expect(headers.Authorization).toBe("Bearer sk-test");
expect(headers["x-api-key"]).toBeUndefined();
expect(headers["anthropic-version"]).toBeUndefined();
it("routes DeepSeek + responses-format client to /responses", () => {
for (const m of RESPONSES_CAPABLE) {
expect(pickTransport("opencode-go", "openai-responses", "opencode-go", m)?.baseUrl).toBe("https://opencode.ai/zen/go/v1/responses");
}
});
it("does NOT route MiniMax (no responses support) to /responses", () => {
for (const m of CLAUDE_CAPABLE) {
expect(pickTransport("opencode-go", "openai-responses", "opencode-go", m)).toBeNull();
}
});
});

View file

@ -0,0 +1,77 @@
// Issue #3010 — Dashboard "Test" button fails for reasoning models because of a
// tiny max_tokens probe. pingModelByKind must use a sane budget (1024) and treat a
// reasoning-only (length-limited) response as a successful connection.
//
// The route module pulls in Next.js-only deps (@/lib/localDb, etc.) that don't
// resolve under raw vitest, so we mock them and exercise the exported function.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock the heavy Next.js-dependent imports BEFORE importing ping.js.
vi.mock("@/lib/localDb", () => ({ getApiKeys: vi.fn(async () => [{ key: "test-key", isActive: true }]) }));
vi.mock("@/shared/constants/config", () => ({ UPDATER_CONFIG: { appPort: 20127 } }));
vi.mock("@/shared/utils/machineId", () => ({ getConsistentMachineId: vi.fn(async () => "cli-token") }));
const { pingModelByKind } = await import("../../src/app/api/models/test/ping.js");
describe("pingModelByKind reasoning models (#3010)", () => {
let fetchMock;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function jsonResponse(obj) {
return {
ok: true,
status: 200,
text: async () => JSON.stringify(obj),
json: async () => obj,
};
}
it("uses a 1024-token budget for the chat completions probe", async () => {
fetchMock.mockResolvedValue(jsonResponse({ choices: [{ message: { content: "Hi there!" } }] }));
await pingModelByKind("cline-pass/kimi-k3", "llm", "http://127.0.0.1:20127");
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.max_tokens).toBe(1024);
});
it("treats a reasoning-only (length-limited) response as ok:true", async () => {
fetchMock.mockResolvedValue(
jsonResponse({
choices: [
{
finish_reason: "length",
message: { content: "", reasoning: "The user said hi — a simple greeting..." },
},
],
})
);
const result = await pingModelByKind("cline-pass/kimi-k3", "llm", "http://127.0.0.1:20127");
expect(result.ok).toBe(true);
expect(result.note).toMatch(/reasoning-only/);
});
it("still fails when there are no choices and no reasoning", async () => {
fetchMock.mockResolvedValue(jsonResponse({ choices: [] }));
const result = await pingModelByKind("some/model", "llm", "http://127.0.0.1:20127");
expect(result.ok).toBe(false);
expect(result.error).toMatch(/no completion choices/);
});
it("passes a normal answer with the larger budget", async () => {
fetchMock.mockResolvedValue(jsonResponse({ choices: [{ message: { content: "Hello!" } }] }));
const result = await pingModelByKind("openai/gpt-4o", "llm", "http://127.0.0.1:20127");
expect(result.ok).toBe(true);
});
});

View file

@ -0,0 +1,147 @@
/**
* Unit tests for qoder billing error detection.
*
* Ensures that billing blocks (code 112, 10605, pricingUrl) are detected
* on the first SSE frame and returned as 403 responses so chatCore can
* mark the connection unavailable and trigger combo failover.
*/
import { describe, it, expect } from "vitest";
import { __test__ as qoderExecutorInternals } from "../../open-sse/executors/qoder.js";
describe("isBillingBlock", () => {
const { isBillingBlock } = qoderExecutorInternals;
it("detects code 112 (quota exhausted)", () => {
const msg = '{"code":"112","message":"Quota exhausted","pricingUrl":"..."}';
expect(isBillingBlock(msg)).toBe(true);
});
it("detects code 10605 (queue throttle)", () => {
const msg = '{"code":"10605","message":"Queue limit"}';
expect(isBillingBlock(msg)).toBe(true);
});
it("detects pricingUrl field", () => {
const msg = '{"message":"Upgrade required","pricingUrl":"https://..."}';
expect(isBillingBlock(msg)).toBe(true);
});
it("returns false for normal errors without billing markers", () => {
const msg = '{"code":"500","message":"Internal error"}';
expect(isBillingBlock(msg)).toBe(false);
});
it("returns false for empty or non-string input", () => {
expect(isBillingBlock("")).toBe(false);
expect(isBillingBlock(null)).toBe(false);
expect(isBillingBlock(undefined)).toBe(false);
});
});
describe("wrapQoderSSE billing detection", () => {
const { wrapQoderSSE } = qoderExecutorInternals;
function makeResponse(lines, { status = 200 } = {}) {
const body = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
for (const line of lines) controller.enqueue(encoder.encode(line));
controller.close();
},
});
return new Response(body, { status });
}
it("returns 403 response when first frame is billing block (code 112)", async () => {
const billingEnv = JSON.stringify({
statusCodeValue: 403,
body: '{"code":"112","message":"Quota exhausted","pricingUrl":"https://qoder.sh/pricing"}',
});
const upstream = `data: ${billingEnv}\n\n`;
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/ultimate");
expect(wrapped.status).toBe(403);
expect(wrapped.ok).toBe(false);
const json = await wrapped.json();
expect(json.error).toBeDefined();
expect(json.error.message).toContain("112");
});
it("returns 403 response when first frame is billing block (code 10605)", async () => {
const billingEnv = JSON.stringify({
statusCodeValue: 429,
body: '{"code":"10605","message":"Queue limit"}',
});
const upstream = `data: ${billingEnv}\n\n`;
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/ultimate");
expect(wrapped.status).toBe(403);
expect(wrapped.ok).toBe(false);
});
it("returns 403 response when first frame has pricingUrl", async () => {
const billingEnv = JSON.stringify({
statusCodeValue: 402,
body: '{"message":"Payment required","pricingUrl":"https://..."}',
});
const upstream = `data: ${billingEnv}\n\n`;
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/ultimate");
expect(wrapped.status).toBe(403);
});
it("passes through normal errors (non-billing) as wrapped SSE", async () => {
const errorEnv = JSON.stringify({
statusCodeValue: 500,
body: "Internal server error",
});
const upstream = `data: ${errorEnv}\n\n`;
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/ultimate");
// Normal error: still 200 response, error text in SSE body
expect(wrapped.status).toBe(200);
expect(wrapped.ok).toBe(true);
const reader = wrapped.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
}
buf += decoder.decode();
expect(buf).toContain("[qoder error 500");
expect(buf).toContain("data: [DONE]");
});
it("passes through successful responses unchanged", async () => {
const inner = JSON.stringify({ choices: [{ delta: { content: "hello" } }] });
const successEnv = JSON.stringify({ statusCodeValue: 200, body: inner });
const upstream = `data: ${successEnv}\n\n`;
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/ultimate");
expect(wrapped.status).toBe(200);
expect(wrapped.ok).toBe(true);
const reader = wrapped.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
}
buf += decoder.decode();
expect(buf).toContain(`data: ${inner}`);
expect(buf).toContain("data: [DONE]");
});
});

View file

@ -401,7 +401,7 @@ describe("wrapQoderSSE", () => {
it("forwards an OpenAI envelope chunk and emits [DONE] in flush", async () => {
const inner = JSON.stringify({ choices: [{ delta: { content: "hi" } }] });
const upstream = `data: ${JSON.stringify({ statusCodeValue: 200, body: inner })}\n\n`;
const wrapped = wrapQoderSSE(makeResponse([upstream]), "qoder/auto");
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/auto");
const out = await drain(wrapped);
expect(out).toContain(`data: ${inner}\n\n`);
expect(out).toContain("data: [DONE]\n\n");
@ -413,7 +413,7 @@ describe("wrapQoderSSE", () => {
const inner = JSON.stringify({ choices: [{ delta: { content: "tail" } }], finish_reason: "stop" });
// Note: NO trailing \n on the final line.
const upstream = `data: ${JSON.stringify({ statusCodeValue: 200, body: inner })}`;
const wrapped = wrapQoderSSE(makeResponse([upstream]), "qoder/auto");
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/auto");
const out = await drain(wrapped);
expect(out).toContain(`data: ${inner}\n\n`);
});
@ -426,7 +426,7 @@ describe("wrapQoderSSE", () => {
const errorEnv = JSON.stringify({ statusCodeValue: 500, body: "boom" });
const validInner = JSON.stringify({ choices: [{ delta: { content: "leak" } }] });
const validEnv = JSON.stringify({ statusCodeValue: 200, body: validInner });
const wrapped = wrapQoderSSE(
const wrapped = await wrapQoderSSE(
makeResponse([`data: ${errorEnv}\n\ndata: ${validEnv}\n\n`]),
"qoder/auto",
);
@ -443,7 +443,7 @@ describe("wrapQoderSSE", () => {
it("strips embedded newlines from inner body before forwarding", async () => {
const innerWithNewlines = '{"choices":[{"delta":{"content":"a\nb"}}]}';
const env = JSON.stringify({ statusCodeValue: 200, body: innerWithNewlines });
const wrapped = wrapQoderSSE(makeResponse([`data: ${env}\n\n`]), "qoder/auto");
const wrapped = await wrapQoderSSE(makeResponse([`data: ${env}\n\n`]), "qoder/auto");
const out = await drain(wrapped);
// The forwarded data: line should be a single event terminated by \n\n
// and contain no internal \n other than the trailing pair.
@ -455,15 +455,15 @@ describe("wrapQoderSSE", () => {
it("upstream error envelope produces an error chunk + [DONE]", async () => {
const env = JSON.stringify({ statusCodeValue: 503, body: "service unavailable" });
const wrapped = wrapQoderSSE(makeResponse([`data: ${env}\n\n`]), "qoder/lite");
const wrapped = await wrapQoderSSE(makeResponse([`data: ${env}\n\n`]), "qoder/lite");
const out = await drain(wrapped);
expect(out).toContain("[qoder error 503");
expect(out).toContain("data: [DONE]\n\n");
});
it("non-ok responses are returned unchanged (no transform)", () => {
it("non-ok responses are returned unchanged (no transform)", async () => {
const r = new Response("not ok", { status: 500 });
const wrapped = wrapQoderSSE(r, "qoder/auto");
const wrapped = await wrapQoderSSE(r, "qoder/auto");
expect(wrapped).toBe(r);
});
});

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