Compare commits

..

23 Commits

Author SHA1 Message Date
dependabot[bot]
b8d9f845da chore(deps): bump github/codeql-action from 4.37.6 to 4.37.7
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.6 to 4.37.7.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4.37.6...v4.37.7)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-21 01:04:55 +00:00
Webman
118840131d fix(deps): upgrade @atjsh/llmlingua-2 to 2.0.5 and drop @tensorflow/tfjs (#10610)
Implements #10536: upgrade @atjsh/llmlingua-2 2.0.3 → 2.0.5 and drop @tensorflow/tfjs from the LLMLingua SLM optional stack.

Validated in an isolated worktree boarded onto origin/release/v3.8.50 (0 conflicts, 20 files):
- 48/48 focused llmlingua/colocate/docker unit tests pass (author-reported, reproduced).
- check-file-size, check-changelog-integrity: OK.
- grep confirms no remaining source imports of @tensorflow/tfjs.
- typecheck:core: clean.
- check-complexity / check-cognitive-complexity: OK, both under baseline.

Co-authored-by: jonlwheat2-gif <jonlwheat2-gif@users.noreply.github.com>
2026-08-20 22:02:14 -03:00
Octopus
3fed9e837a fix(sse): add the missing minimax-music dispatch to music generation (#10650)
Obrigado — bug real: MUSIC_PROVIDERS.minimax declara format "minimax-music" e seus modelos são publicados pelo catálogo, mas handleMusicGeneration nunca teve um branch para esse format — todo request minimax/* caía no guard final com "Unsupported music format", modelos anunciados mas inalcançáveis. Handler completo cobrindo os dois output formats (url/hex), envelope base_resp, endpoint regional, e guarda local de credencial ausente.

Validação (worktree própria a partir de origin/release/v3.8.50, merge limpo, 0 conflitos):
- typecheck:core limpo, complexity/cognitive-complexity dentro do baseline
- tests/unit/minimax-music-generation.test.ts — 9/9 passando
2026-08-20 21:37:54 -03:00
Diego Rodrigues de Sa e Souza
1f04e73a1c Merge pull request #10922 from diegosouzapw/fix/10877-quota-alias-lookup
fix(sse): canonicalize alias provider ids before quota fetcher lookup (#10877)
2026-08-20 21:20:00 -03:00
Diego Rodrigues de Sa e Souza
f66c986dbd Merge pull request #10919 from diegosouzapw/fix/10849-search-provider-400
fix(api): POST /v1/search names unknown providers instead of opaque 400 (#10849)
2026-08-20 21:19:31 -03:00
Diego Rodrigues de Sa e Souza
3669df2ca5 Merge pull request #10914 from diegosouzapw/fix/10848-image-scan-cookie-bridge
fix(config): exclude cookie-auth image bridges from unprefixed model scan (#10848)
2026-08-20 21:19:17 -03:00
Diego Rodrigues de Sa e Souza
590cbbe7b1 Merge pull request #10918 from diegosouzapw/fix/10815-kiro-oauth-dedup
fix(db): disambiguate Kiro OAuth dedup by profileArn (#10815)
2026-08-20 21:19:10 -03:00
Diego Rodrigues de Sa e Souza
c7e264e1a6 Merge pull request #10911 from diegosouzapw/fix/10788-opencode-effort-tiers
fix(open-sse): declare Ollama Cloud reasoning models' supportedThinkingEfforts (#10788)
2026-08-20 21:19:03 -03:00
Diego Rodrigues de Sa e Souza
699be22e1e Merge pull request #10915 from diegosouzapw/fix/10765-rtk-unconditional-stats
fix: skip expensive RTK compression stats computation on no-op runs (#10765)
2026-08-20 21:18:55 -03:00
Diego Rodrigues de Sa e Souza
7756aef970 Merge pull request #10917 from diegosouzapw/fix/10597-combo-log-error-body
fix: log upstream error body in COMBO per-target failure warnings (#10597)
2026-08-20 21:18:48 -03:00
Diego Rodrigues de Sa e Souza
050c7c0021 Merge pull request #10913 from diegosouzapw/fix/10592-playground-endpoint-selector
fix: route Playground ChatTab Send to the selected endpoint (#10592)
2026-08-20 21:18:41 -03:00
Diego Rodrigues de Sa e Souza
c535df9076 Merge pull request #10923 from diegosouzapw/fix/10156-responses-commentary-sse
fix(sse): strip commentary items from Responses response.completed snapshot (#10156)
2026-08-20 21:18:28 -03:00
Diego Rodrigues de Sa e Souza
5d9998eb31 Merge pull request #10912 from diegosouzapw/fix/10095-antigravity-multiaccount-quota
fix(domain): treat unreported Antigravity quota fraction as unknown, not exhausted (#10095)
2026-08-20 21:18:16 -03:00
Markus Hartung
19741775ee fix(sse): strip commentary items from Responses response.completed snapshot (#10156)
Live SSE frames for a phase:"commentary" message were already dropped
per #6199, but the terminal response.completed.response.output array was
forwarded verbatim whenever the upstream echoed the same item back
non-empty, since backfillResponsesCompletedOutput only fills an empty
array. Reuse the existing isResponsesCommentaryMessageItem predicate to
filter the terminal snapshot's output array (and, defensively, the
backfill buffer it can be seeded from) so both representations agree.

Regression test added to tests/unit/responses-commentary-passthrough-6199.test.ts
reproducing the exact upstream shape from the issue.
2026-08-20 20:42:02 -03:00
Markus Hartung
b668d91364 fix(sse): canonicalize alias provider ids before quota fetcher lookup (#10877) 2026-08-20 20:41:59 -03:00
Markus Hartung
4ec080dc19 fix(api): POST /v1/search names unknown providers instead of opaque 400 (#10849)
v1SearchSchema.provider was a hard-coded z.enum that rejected any id outside
its list before the route's own resolveSearchProvider() check ever ran,
so unknown/short-alias provider ids (grok, brave, serper, ...) always
surfaced a generic "Invalid request" instead of the informative
"Unknown search provider: <id>" message. Relax the schema to a free-form
string and let resolveSearchProvider() own runtime validation (as it
already did for ids that passed the enum). Also extend
SEARCH_PROVIDER_ALIASES with short-form aliases mirroring the existing
jina/jina-ai pattern (brave, serper, perplexity, exa, tavily, google-pse,
linkup, ollama, searchapi, youcom, searxng, zai, duckduckgo), and surface
the first Zod validation issue's field name instead of the generic
message for other still-invalid fields (e.g. search_type).
2026-08-20 20:37:22 -03:00
Markus Hartung
0458c5ac4c fix(db): disambiguate Kiro OAuth dedup by profileArn (#10815) 2026-08-20 20:34:59 -03:00
Markus Hartung
9603ec1bf1 fix(sse): log upstream error body in COMBO per-target failure warnings (#10597) 2026-08-20 20:34:38 -03:00
Markus Hartung
6d043674c2 fix: skip expensive RTK compression stats computation on no-op runs (#10765) 2026-08-20 20:34:19 -03:00
Markus Hartung
bed4d24049 fix(config): exclude cookie-auth image bridges from unprefixed model scan (#10848) 2026-08-20 20:33:19 -03:00
Markus Hartung
018badc3b3 fix: route Playground ChatTab Send to the selected endpoint, not just chat.completions (#10592) 2026-08-20 20:30:40 -03:00
Markus Hartung
87719f2381 fix(domain): treat unreported Antigravity quota fraction as unknown, not exhausted (#10095) 2026-08-20 20:29:38 -03:00
Yawar
eb6f319712 feat(providers): add tabitoken gateway and serve hcnsec's four protocols (#10668)
Obrigado — PR muito bem documentado e verificado. Adiciona o gateway TabiToken (Anthropic-first, /v1/messages, x-api-key) e estende hcnsec de 1 para 4 protocolos (Chat, Responses, Anthropic Messages, Gemini). AlternateFormat ganha o hook urlBuilder opcional (necessário para o path model-scoped do Gemini), compartilhado com o provider gemini nativo em vez de duplicado.

Reconciliado nesta sessão contra o release tip atualizado (base drift real: 343→345 canônicos entre quando o PR foi criado e o merge, mais os PRs #10673/#10658 mergeados nesse meio-tempo). Conflitos em contagens de providers (docs, file-size baseline, teste de partição) resolvidos additivamente.

Validação (reconciliação a partir de origin/release/v3.8.50):
- typecheck:core limpo, complexity/cognitive-complexity dentro do baseline
- npm run check:provider-consistency — OK (266 REGISTRY entries, 346 providers canônicos, 0 exceções)
- 40/40 testes passando (newapi-gateway-providers, hcnsec-provider, providers-constants-split, alternate-formats)
2026-08-20 20:24:42 -03:00
81 changed files with 2279 additions and 601 deletions

View File

@@ -50,13 +50,13 @@ updates:
# bumps; majors here need their own PR and a deliberate migration review.
- dependency-name: "ioredis"
update-types: ["version-update:semver-major"]
# @huggingface/transformers is HARD-PINNED at 3.5.2 (exact, no caret) — FROZEN.
# It is load-bearing for the LLMLingua ONNX compression engine (open-sse/services/
# compression/engines/llmlingua/ — worker.ts pins @huggingface/transformers@3.5.2)
# and for local memory embeddings (src/lib/memory/embedding/transformersLocal.ts),
# and was VPS-validated at 3.5.2 (#4014). 4.x breaks both, and even 3.x minors must
# be re-validated on the VPS — so freeze ALL auto-bumps (no update-types = ignore
# every version). Migrate it intentionally, not via dependabot (#4050).
# @huggingface/transformers is VPS-validated at ^4.2.0 (migrated intentionally in
# #9962). It is load-bearing for the LLMLingua ONNX compression engine (open-sse/
# services/compression/engines/llmlingua/ — @atjsh/llmlingua-2@2.0.5 peers on
# "@huggingface/transformers": "^3.5.2 || ^4.0.0") and for local memory embeddings
# (src/lib/memory/embedding/transformersLocal.ts). Further majors must be re-validated
# on the VPS — so keep auto-bumps frozen (no update-types = ignore every version).
# Migrate it intentionally, not via dependabot (#4050).
- dependency-name: "@huggingface/transformers"
- package-ecosystem: "github-actions"

View File

@@ -372,7 +372,7 @@ jobs:
- name: Upload Trivy SARIF to Security tab
if: needs.prepare.outputs.version != 'main'
continue-on-error: true
uses: github/codeql-action/upload-sarif@v4.37.6
uses: github/codeql-action/upload-sarif@v4.37.7
with:
sarif_file: trivy-results.sarif
category: trivy-image

View File

@@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below.
## Project at a Glance
**OmniRoute** — unified AI proxy/router. One endpoint, 343 LLM providers, auto-fallback.
**OmniRoute** — unified AI proxy/router. One endpoint, 346 LLM providers, auto-fallback.
| Layer | Location | Purpose |
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |

View File

@@ -173,7 +173,7 @@ COPY . ./
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-next-cache,target=/app/.build/next/cache \
mkdir -p /app/data \
&& npm run build \
&& node --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const standaloneRoot = '/app/.build/next/standalone/node_modules/'; const require = createRequire('/app/.build/next/standalone/package.json'); for (const pkg of ['@atjsh/llmlingua-2', '@huggingface/transformers', '@tensorflow/tfjs', 'js-tiktoken']) { const resolved = require.resolve(pkg); if (!resolved.startsWith(standaloneRoot)) throw new Error(pkg + ' resolved outside standalone: ' + resolved); await import(pathToFileURL(resolved).href); } const onnxRuntime = require.resolve('onnxruntime-node'); if (!onnxRuntime.startsWith(standaloneRoot)) throw new Error('onnxruntime-node resolved outside standalone: ' + onnxRuntime); await import(pathToFileURL(onnxRuntime).href);"
&& node --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const standaloneRoot = '/app/.build/next/standalone/node_modules/'; const require = createRequire('/app/.build/next/standalone/package.json'); for (const pkg of ['@atjsh/llmlingua-2', '@huggingface/transformers', 'js-tiktoken']) { const resolved = require.resolve(pkg); if (!resolved.startsWith(standaloneRoot)) throw new Error(pkg + ' resolved outside standalone: ' + resolved); await import(pathToFileURL(resolved).href); } const onnxRuntime = require.resolve('onnxruntime-node'); if (!onnxRuntime.startsWith(standaloneRoot)) throw new Error('onnxruntime-node resolved outside standalone: ' + onnxRuntime); await import(pathToFileURL(onnxRuntime).href);"
# ── Runner base ────────────────────────────────────────────────────────────
FROM base AS runner-base

View File

@@ -7,7 +7,7 @@
# 🚀 OmniRoute — The Free AI Gateway
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 343 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 1595% tokens (~89% avg) — never hit limits. 343 AI providers · 90+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start."/>
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 346 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 1595% tokens (~89% avg) — never hit limits. 346 AI providers · 90+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start."/>
</div>
@@ -101,7 +101,7 @@
<tr>
<td align="right"><b>⚙️ Features</b></td>
<td align="center"><a href="#-combos--the-flagship">🎯 Combos</a></td>
<td align="center"><a href="#-343-ai-providers--90-free">🌐 Providers</a></td>
<td align="center"><a href="#-346-ai-providers--90-free">🌐 Providers</a></td>
<td align="center"><a href="#-full-cli--a2a--mcp">🔌 CLI &amp; MCP</a></td>
</tr>
<tr>
@@ -210,7 +210,7 @@ curl http://localhost:20128/v1/chat/completions \
</div>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 343 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 343 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 1595%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 56 free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 109 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 346 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 346 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 1595%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 56 free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 109 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
<br/>
<br/>
@@ -461,7 +461,7 @@ All **19** strategies — mix & match per combo step:
</div>
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 343 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 109 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project&apos;s docs."/>
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 346 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 109 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project&apos;s docs."/>
<sub>📊 Full methodology &amp; per-feature detail vs 9router, OpenRouter, CLIProxyAPI &amp; LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
@@ -559,7 +559,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
- **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md)
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind, EdgeTTS. → [API Reference](docs/reference/API_REFERENCE.md)
- **🌍 Deployment & ops** — reverse-proxy `basePath`, browser-language auto-detect, per-key device tracking, root-less MITM trust, zh-TW localization. → [Environment](docs/reference/ENVIRONMENT.md)
- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **343-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **346-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
- **📡 Routing transparency** — every response carries an `X-OmniRoute-Decision` header naming the strategy/provider/latency that served it, a new `cache-optimized` combo strategy + Auto-Combo `cacheAffinity` factor route repeat requests back to the connection holding the cached prefix, and a read-only `/v1/auto-combo/{channel}/candidates` endpoint exposes an `auto/*` channel's live candidate pool. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
- **⚡ Local performance & infra** — one-click local Redis, Cloudflare Workers / Deno Deploy relay deployers, Bifrost & Mux as supervised embedded services. → [Embedded Services](docs/frameworks/EMBEDDED-SERVICES.md)
@@ -642,11 +642,11 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
<div align="center">
## 🌐 343 AI Providers — 90+ Free
## 🌐 346 AI Providers — 90+ Free
</div>
> The most complete catalog of any open-source router: **343 providers**, **90+ with a free tier**, **56 free forever**.
> The most complete catalog of any open-source router: **346 providers**, **90+ with a free tier**, **56 free forever**.
<div align="center">

View File

@@ -0,0 +1,2 @@
- **feat(providers):** add the TabiToken NewAPI gateway (`tabitoken`) and teach the existing HCNSec entry (`hcnsec`) the three further protocols it actually serves. TabiToken leaves the NewAPI pricing endpoint public, so its catalog is read from the host rather than guessed: four Claude models, each reporting the Anthropic and OpenAI protocols. HCNSec shipped OpenAI-only; probing the host showed `/v1/messages`, `/v1/responses` and the Gemini `/v1beta` path all reach its token layer, so each is now declared as an alternate format — with its default format, base URL, auth scheme and regional catalog classification untouched. ([#10668](https://github.com/diegosouzapw/OmniRoute/pull/10668)) — thanks @yawar-aquil
- **feat(sse):** allow an alternate protocol to build its own upstream URL. `AlternateFormat` gained an optional `urlBuilder`, because the Gemini protocol carries the model inside the path (`{base}/{model}:generateContent`) and the existing `chatPath`/`urlSuffix` fields are constants that cannot express it. The route builder is extracted as `buildGeminiGenerateContentUrl` and shared with the native `gemini` provider so the two consumers cannot drift on the `?alt=sse` streaming suffix. ([#10668](https://github.com/diegosouzapw/OmniRoute/pull/10668)) — thanks @yawar-aquil

View File

@@ -0,0 +1 @@
- fix(domain): stop treating an unreported Antigravity quota fraction (`fractionReported:false`) as 0% remaining in `quotaCache.ts`, which was falsely marking every fresh/newly-connected account as exhausted and blocking multi-account rotation (#10095)

View File

@@ -0,0 +1 @@
- **fix(sse):** Responses-passthrough `response.completed` snapshots now drop `phase:"commentary"` items the same way live SSE frames already do, so the terminal `response.output` array no longer echoes internal commentary text that was already suppressed from the stream (#10156).

View File

@@ -0,0 +1 @@
- **fix(deps):** upgrade `@atjsh/llmlingua-2` from 2.0.3 to 2.0.5 and remove `@tensorflow/tfjs` from the LLMLingua SLM stack — 2.0.5 adds official Transformers.js v4 support (peers `@huggingface/transformers` at `^3.5.2 || ^4.0.0`) and 2.0.4+ no longer requires TensorFlow.js, restoring compatibility with OmniRoute's Transformers.js v4 while dropping the largest single contributor to the optional runtime footprint ([#10536](https://github.com/diegosouzapw/OmniRoute/issues/10536))

View File

@@ -0,0 +1 @@
- fix(dashboard): route the Playground's ChatTab "Send" through the endpoint actually selected in StudioConfigPane (`search`, `web.fetch`, etc.) instead of always POSTing to `/api/v1/chat/completions`, fixing the false "No active credentials for provider" 404 when testing search-only providers (#10592)

View File

@@ -0,0 +1 @@
- **fix(sse):** Include the redacted upstream error body in the per-target COMBO failure log (`Model X failed, trying next`) so operators can triage a 400/500 without reproducing the request ([#10597](https://github.com/diegosouzapw/OmniRoute/issues/10597))

View File

@@ -0,0 +1 @@
- fix(compression): skip the expensive `createCompressionStats()` pass in RTK when no message was actually compressed, matching every sibling stacked engine (#10765)

View File

@@ -0,0 +1 @@
- fix(db): disambiguate `createProviderConnection()`'s OAuth email dedup by `providerSpecificData.profileArn` in addition to `username`, so adding a second Kiro/AWS profile with the same email creates a new connection instead of silently merging into the first (#10815)

View File

@@ -0,0 +1 @@
- fix(config): exclude cookie-auth image bridges (chatgpt-web, gemini-web) from the unprefixed model scan so a bare id never silently binds to an unofficial web bridge (#10848)

View File

@@ -0,0 +1 @@
- fix(api): POST /v1/search now replies with a named `Unknown search provider: <id>` error (and field-named validation messages) instead of an opaque `Invalid request` for unrecognized or short-alias provider ids like `brave`/`serper` (#10849)

View File

@@ -0,0 +1 @@
- **fix(sse):** `getResetAwareProvider()` and the auto-combo quota lookup in `combo.ts` now canonicalize the provider id via `resolveProviderId()` before calling `getQuotaFetcher()`, so a fetcher registered under a provider's canonical id (e.g. `ollama-cloud`, `codex`) is found for combo targets stored under an alias spelling (e.g. `ollamacloud`, `cx`) instead of silently degrading reset-aware/reset-window/auto quota-aware routing to plain priority ordering (#10877)

View File

@@ -0,0 +1 @@
- **fix(sse):** MiniMax music models now generate audio instead of failing with `Unsupported music format: minimax-music` — the provider entry was registered in the music registry (and advertised by `/v1/models`), but `handleMusicGeneration` had no branch for its format, so every `minimax/*` music request fell through the dispatch chain to a 400. Adds the missing dispatch: a single synchronous POST with the `base_resp` envelope check (a non-zero `status_code` arrives on HTTP 200 too), `data.status` handling (an unfinished generation is reported instead of polled — the operation has no task id and no query endpoint), `url` and `hex` output formats (hex normalized to base64), `mp3`/`wav`/`pcm` containers via `audio_setting`, and the regional endpoint through the per-connection base-URL override, which is also the only host that accepts `aigc_watermark`. The registry entry gains the generation and cover model ids it was missing and drops a query URL that does not exist for this operation. Regression guard: `tests/unit/minimax-music-generation.test.ts` (9 tests).

View File

@@ -20,7 +20,6 @@
"@stryker-mutator/tap-runner",
"@swc/helpers",
"@tailwindcss/postcss",
"@tensorflow/tfjs",
"@testing-library/jest-dom",
"@testing-library/react",
"@toon-format/toon",

View File

@@ -444,14 +444,15 @@
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1062,
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 1051,
"src/shared/components/ModelSelectModal.tsx": 1138,
"src/shared/constants/providers/apikey/gateways.ts": 1268,
"src/shared/constants/providers/apikey/gateways.ts": 1283,
"open-sse/vendor/codex-chatgpt-web/bridge.ts": 1387,
"_rebaseline_2026_08_11_v3850_merge_storm_provider_registry": "DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legitima acima do cap; gateways.ts = god-file de catalogo de providers que cresceu com PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o proprio PR #9421 quebrou o arquivo); bridge.ts (PR #8949) = ponte Chromium vendored; proxyFetch.ts 1207->1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).",
"src/lib/modelCapabilities.ts": 1006,
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014,
"open-sse/config/imageRegistry.ts": 1034,
"src/sse/handlers/chatHelpers.ts": 1017,
"src/shared/middleware/chatBodyAdmission.ts": 1005
"src/shared/middleware/chatBodyAdmission.ts": 1005,
"_rebaseline_2026_08_20_10668_tabitoken_gateway": "#10668 (yawar-aquil) own catalog growth: src/shared/constants/providers/apikey/gateways.ts 1268->1283 (+15, entirely this PR diff -- one new tabitoken gateway entry, data lines only; base moved from 1255 to 1268 via other merges since the PR forked). Not combination drift: reproducible on the PR branch alone, so the WS5.5 release-captain rule does not apply. Extraction is not available -- the file is pure data (own header: \"Pure data; merged by apikey/index.ts via spread\") and already split into 6 family files under apikey/. Same precedent as _rebaseline_2026_08_14_imagetotext_servicekinds (#10275/#10291, gateways.ts 1250->1255, data lines only) and _rebaseline_2026_08_11_v3850_merge_storm_provider_registry (owner-authorized for this same file)."
},
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",

View File

@@ -28,12 +28,12 @@ The `omniglyph` engine (package `omniglyph`, 1.4.0+) accepts a named semantic pr
globally through `omniglyph.profile` in the compression settings or per step through the
stacked pipeline's step config:
| Profile | Boundary |
| -------------- | --------------------------------------------------------------------------- |
| `aggressive` | Default. The policy the published receipts measured — images system, tool docs and dense history |
| `balanced` | Keeps live state native, protects the last 8 turns, collapses older closed history |
| `coding-safe` | Keeps authority, tool schemas and live tool output native, protects the last 12 turns |
| `passthrough` | Routes without transforming; the engine is skipped |
| Profile | Boundary |
| ------------- | ------------------------------------------------------------------------------------------------ |
| `aggressive` | Default. The policy the published receipts measured — images system, tool docs and dense history |
| `balanced` | Keeps live state native, protects the last 8 turns, collapses older closed history |
| `coding-safe` | Keeps authority, tool schemas and live tool output native, protects the last 12 turns |
| `passthrough` | Routes without transforming; the engine is skipped |
The profile is a **ceiling, not a floor**: `mergeCompressionProfileOptions` in the package
refuses to let a caller override reopen a lossy lane the profile closed, so a per-step
@@ -170,22 +170,22 @@ override points it at a local copy instead (offline / air-gapped installs).
### Optional dependencies & on-demand install
The prunable LLMLingua runtime peer stack is **optional**. Three packages are declared as
The prunable LLMLingua runtime peer stack is **optional**. Two packages are declared as
`optionalDependencies` in `package.json` and kept **external** by the production build
(`scripts/build/prepublish.ts` does not bundle them):
| Package | Version (pin) | Notes |
| -------------------- | ------------- | ---------------------------------------------- |
| `@atjsh/llmlingua-2` | `2.0.3` | Entry package; declares the others as peers |
| `@tensorflow/tfjs` | `4.22.0` | Heaviest dep — dominates the ~800 MB footprint |
| `js-tiktoken` | `^1.0.20` | Tokenizer |
| Package | Version (pin) | Notes |
| -------------------- | ------------- | ------------------------------------------- |
| `@atjsh/llmlingua-2` | `2.0.5` | Entry package; declares the others as peers |
| `js-tiktoken` | `^1.0.20` | Tokenizer |
`@huggingface/transformers` is pinned at `3.5.2` as an **optional** dependency (shared with
the local embeddings path and also traced into the standalone bundle). Keeping it optional prevents
`onnxruntime-node` CUDA provider postinstall failures on CUDA 11 hosts from aborting the whole
OmniRoute install; when the optional stack is absent, LLMLingua still fail-opens. Only the three
packages above are prunable SLM peers. A standard `npm install` (dev) installs the optional stack
automatically unless optional dependencies are omitted.
`@huggingface/transformers` is pinned at `^4.2.0` (shared with the local embeddings path and
also traced into the standalone bundle); `@atjsh/llmlingua-2@2.0.5` peers on it with
`"^3.5.2 || ^4.0.0"`, so both Transformers.js v3 and v4 are supported. Since 2.0.4,
`@atjsh/llmlingua-2` no longer requires `@tensorflow/tfjs`, which removed the largest single
contributor (TensorFlow.js) from the SLM stack. Only the two packages above are prunable SLM
peers. A standard `npm install` (dev) installs the optional stack automatically unless optional
dependencies are omitted.
**Why on-demand:** the npm-published package, the standalone bundle, and the Docker image
ship **without** these deps to stay slim. When they are absent, the worker's dependency
@@ -195,11 +195,12 @@ error logged). To activate it in a pruned environment, install the optional stac
```bash
# pin to the versions declared in package.json optionalDependencies
npm install @atjsh/llmlingua-2@2.0.3 @tensorflow/tfjs@4.22.0 js-tiktoken
npm install @atjsh/llmlingua-2@2.0.5 js-tiktoken
```
Roughly **~800 MB** total: the TensorFlow.js + transformers runtimes dominate; the
TinyBERT model adds ~57 MB downloaded at first use (not via npm).
The `@tensorflow/tfjs` removal (2.0.4+) eliminates the previously dominant ~800 MB
contributor — the remaining footprint is the transformers.js + onnxruntime-node runtimes,
plus the TinyBERT model (~57 MB) downloaded at first use (not via npm).
Per environment:

View File

@@ -142,22 +142,22 @@ wskazuje zamiast tego lokalną kopię (instalacje offline / air-gapped).
### Opcjonalne zależności i instalacja on-demand
Przycinany stos peerów runtime LLMLingua jest **opcjonalny**. Trzy pakiety są zadeklarowane jako
Przycinany stos peerów runtime LLMLingua jest **opcjonalny**. Dwa pakiety są zadeklarowane jako
`optionalDependencies` w `package.json` i utrzymywane jako **external** przez build produkcyjny
(`scripts/build/prepublish.ts` ich nie bundluje):
| Package | Version (pin) | Notes |
| -------------------- | ------------- | ------------------------------------------------- |
| `@atjsh/llmlingua-2` | `2.0.3` | Pakiet wejściowy; deklaruje pozostałe jako peery |
| `@tensorflow/tfjs` | `4.22.0` | Najcięższa zależność — dominuje footprint ~800 MB |
| `js-tiktoken` | `^1.0.20` | Tokenizer |
| Package | Version (pin) | Notes |
| -------------------- | ------------- | ------------------------------------------- |
| `@atjsh/llmlingua-2` | `2.0.5` | Pakiet wejściowy; deklaruje pozostałe jako peery |
| `js-tiktoken` | `^1.0.20` | Tokenizer |
`@huggingface/transformers` jest pinowany na `3.5.2` jako **opcjonalna** zależność (współdzielona ze
ścieżką lokalnych embeddings i również śledzona do standalone bundle). Utrzymanie jej jako optional
zapobiega awariom postinstall providera CUDA `onnxruntime-node` na hostach CUDA 11, które przerywałyby
całą instalację OmniRoute; gdy opcjonalny stos jest nieobecny, LLMLingua nadal fail-openuje. Tylko trzy
powyższe pakiety to przycinane peery SLM. Standardowe `npm install` (dev) instaluje opcjonalny stos
automatycznie, o ile opcjonalne zależności nie zostaną pominięte.
`@huggingface/transformers` jest pinowany na `^4.2.0` (współdzielony ze ścieżką lokalnych embeddings
i również śledzony do standalone bundle); `@atjsh/llmlingua-2@2.0.5` peeruje na nim przez
`"^3.5.2 || ^4.0.0"`, więc obsługiwane są zarówno Transformers.js v3, jak i v4. Od 2.0.4
`@atjsh/llmlingua-2` nie wymaga już `@tensorflow/tfjs`, co usunęło największy pojedynczy wkład
(TensorFlow.js) ze stosu SLM. Tylko dwa powyższe pakiety to przycinane peery SLM. Standardowe
`npm install` (dev) instaluje opcjonalny stos automatycznie, o ile opcjonalne zależności nie zostaną
pominięte.
**Dlaczego on-demand:** pakiet publikowany w npm, standalone bundle i obraz Docker
dostarczane są **bez** tych zależności, aby pozostać lekkie. Gdy ich brakuje, bramka zależności
@@ -167,11 +167,12 @@ logowanego błędu). Aby aktywować go w przyciętym środowisku, zainstaluj opc
```bash
# pin to the versions declared in package.json optionalDependencies
npm install @atjsh/llmlingua-2@2.0.3 @tensorflow/tfjs@4.22.0 js-tiktoken
npm install @atjsh/llmlingua-2@2.0.5 js-tiktoken
```
Łącznie mniej więcej **~800 MB**: dominują runtimey TensorFlow.js + transformers; model
TinyBERT dodaje ~57 MB pobierane przy pierwszym użyciu (nie przez npm).
Usunięcie `@tensorflow/tfjs` (2.0.4+) eliminuje wcześniej dominujący wkład ~800 MB — pozostały
footprint to runtimey transformers.js + onnxruntime-node oraz model TinyBERT (~57 MB) pobierany
przy pierwszym użyciu (nie przez npm).
Per środowisko:

View File

@@ -326,13 +326,11 @@ Przed wypuszczeniem dowolnego wydania v3.8.x zweryfikuj te dodatkowe pozycje:
- [ ] `npm install -g omniroute@<this-version>` uruchamia postinstall bez fatalnego wyjścia
- [ ] Ścieżka update zachowuje optional deps: `omniroute update --apply` i auto-updater
uruchamiają `npm install -g … --include=optional`, żeby `optionalDependencies` (better-sqlite3,
keytar, tls-client oraz stack SLM llmlingua: `@atjsh/llmlingua-2`,
`@huggingface/transformers@3.5.2`, `@tensorflow/tfjs`, `js-tiktoken`) przeżyły update.
`@huggingface/transformers` zostaje optional, żeby jego postinstall providera CUDA `onnxruntime-node`
nie mógł przerwać instalacji na hostach CUDA 11. Tier ultra `modelPath` SLM potrzebuje też
keytar, tls-client oraz stack SLM llmlingua: `@atjsh/llmlingua-2@2.0.5`,
`js-tiktoken`) przeżyły update. Tier ultra `modelPath` SLM potrzebuje też
modelu tinybert, auto-pobieranego do `${DATA_DIR}/models/llmlingua` przy pierwszym użyciu. Postinstall
(`scripts/build/colocateOptionals.mjs`) następnie ko-lokuje opcjonalne zamknięcie SLM do
`dist/node_modules`, żeby worker rozwiązywał JEDNĄ opcjonalną instancję `@huggingface/transformers` 3.5.2
`dist/node_modules`, żeby worker rozwiązywał JEDNĄ instancję `@huggingface/transformers` ^4.2.0
— standalone trace bundluje tylko transformers, nie dynamicznie importowane
optionals, więc bez tego worker załadowałby llmlingua-2 przeciw transformers z roota
i tier SLM cicho fail-openowałby.

View File

@@ -275,14 +275,12 @@ npm run build:release
- [ ] `npm install -g omniroute@<this-version>` 运行 postinstall 无致命退出
- [ ] 更新路径保留可选依赖:`omniroute update --apply` 以及自动更新器
运行 `npm install -g … --include=optional` 以确保 `optionalDependencies`better-sqlite3、
keytar、tls-client 以及 llmlingua SLM 栈:`@atjsh/llmlingua-2`、
`@huggingface/transformers@3.5.2`、`@tensorflow/tfjs`、`js-tiktoken`)在更新后仍然存在。
`@huggingface/transformers` 保持为可选依赖,这样其 `onnxruntime-node` CUDA provider postinstall
不会在 CUDA 11 主机上中断安装。Ultra 模式的 `modelPath` SLM 层还需要
keytar、tls-client 以及 llmlingua SLM 栈:`@atjsh/llmlingua-2@2.0.5`、
`js-tiktoken`)在更新后仍然存在。Ultra 模式的 `modelPath` SLM 层还需要
tinybert 模型,首次使用时自动下载到 `${DATA_DIR}/models/llmlingua`。postinstall
`scripts/build/colocateOptionals.mjs`)随后将 SLM 可选依赖闭包共置到
`dist/node_modules`,使 Worker 解析单一的 `@huggingface/transformers` 3.5.2
可选实例 — standalone trace 仅打包 transformers不包含动态导入的
`dist/node_modules`,使 Worker 解析单一的 `@huggingface/transformers` ^4.2.0
实例 — standalone trace 仅打包 transformers不包含动态导入的
可选依赖,否则 Worker 会基于根目录的 transformers 加载 llmlingua-2
SLM 层将静默失效。
- [ ] `omniroute status` 在无 `.env` 的情况下正常工作CLI Token 路径,仅 loopback

View File

@@ -322,14 +322,12 @@ npm run build:release
- [ ] `npm install -g omniroute@<此版本>` 執行 postinstall 而不會致命退出
- [ ] 更新路徑保留選擇性依賴:`omniroute update --apply` 和自動更新器
執行 `npm install -g … --include=optional`,因此 `optionalDependencies`better-sqlite3、
keytar、tls-client以及 llmlingua SLM 堆疊:`@atjsh/llmlingua-2`、
`@huggingface/transformers@3.5.2`、`@tensorflow/tfjs`、`js-tiktoken`)在更新後仍會保留。
`@huggingface/transformers` 維持選擇性,因此其 `onnxruntime-node` CUDA 提供者的 postinstall
不會在 CUDA 11 主機上中斷安裝。Ultra `modelPath` SLM 層還需要
keytar、tls-client以及 llmlingua SLM 堆疊:`@atjsh/llmlingua-2@2.0.5`、
`js-tiktoken`)在更新後仍會保留。Ultra `modelPath` SLM 層還需要
tinybert 模型,會在首次使用時自動下載到 `${DATA_DIR}/models/llmlingua`。Postinstall
`scripts/build/colocateOptionals.mjs`)接著將 SLM 選擇性閉包複製到
`dist/node_modules`,使工作者解析到**單一** `@huggingface/transformers` 3.5.2
選擇性實例——獨立追蹤僅捆綁 transformers而非動態匯入的
`dist/node_modules`,使工作者解析到**單一** `@huggingface/transformers` ^4.2.0
實例——獨立追蹤僅捆綁 transformers而非動態匯入的
選擇性套件,因此若無此步驟,工作者會載入 llmlingua-2 並使用根目錄的 transformers
導致 SLM 層靜默地失敗但仍保持運作。
- [ ] `omniroute status` 在無 `.env` 的情況下正常運作(僅限 CLI 權杖路徑,迴環介面)

View File

@@ -351,14 +351,12 @@ Before shipping any v3.8.x release, verify these additional items:
- [ ] `npm install -g omniroute@<this-version>` runs postinstall without fatal exit
- [ ] Update path keeps optional deps: `omniroute update --apply` and the auto-updater
run `npm install -g … --include=optional` so `optionalDependencies` (better-sqlite3,
keytar, tls-client, and the llmlingua SLM stack: `@atjsh/llmlingua-2`,
`@huggingface/transformers@3.5.2`, `@tensorflow/tfjs`, `js-tiktoken`) survive an update.
`@huggingface/transformers` stays optional so its `onnxruntime-node` CUDA provider postinstall
cannot abort installation on CUDA 11 hosts. The ultra `modelPath` SLM tier also needs the
keytar, tls-client, and the llmlingua SLM stack: `@atjsh/llmlingua-2@2.0.5`,
`js-tiktoken`) survive an update. The ultra `modelPath` SLM tier also needs the
tinybert model, auto-downloaded to `${DATA_DIR}/models/llmlingua` on first use. Postinstall
(`scripts/build/colocateOptionals.mjs`) then co-locates the SLM optional closure into
`dist/node_modules` so the worker resolves a SINGLE `@huggingface/transformers` 3.5.2
optional instance — the standalone trace bundles only transformers, not the dynamically-imported
`dist/node_modules` so the worker resolves a SINGLE `@huggingface/transformers` ^4.2.0
instance — the standalone trace bundles only transformers, not the dynamically-imported
optionals, so without this the worker would load llmlingua-2 against the root's transformers
and the SLM tier would silently fail-open.
- [ ] `omniroute status` works with no `.env` (CLI token path, loopback only)

View File

@@ -10,7 +10,7 @@ lastUpdated: 2026-08-20
> Regenerate with: `npm run gen:provider-reference`
> **Last generated:** 2026-08-20
Total providers: **343**. See category breakdown below.
Total providers: **346**. See category breakdown below.
## Categories
@@ -120,7 +120,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `zai-web` | `zw` | Z.ai Web | Web cookie | [link](https://chat.z.ai) | Copy the "token" value from chat.z.ai → DevTools → Application → Local Storage. Do not copy cookies; OmniRoute handles the per-request CAPTCHA through its browser transport. | — |
| `zenmux-free` | `zmf` | ZenMux Free (Web) | Web cookie | [link](https://zenmux.ai) | Login at zenmux.ai, then export all cookies using EditThisCookie or Cookie-Editor and paste the full Cookie header string here. Refresh every ~30 days. | — |
## API Key Providers (paid / paid-with-free-credits) (230)
## API Key Providers (paid / paid-with-free-credits) (231)
| ID | Alias | Name | Tags | Website | Notes |
|----|-------|------|------|---------|-------|
@@ -319,6 +319,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
| `sumopod` | `sumopod` | SumoPod | API key | [link](https://ai.sumopod.com) | Use your SumoPod API key (sk-...) in Authorization: Bearer <key>. Fully OpenAI-compatible. API base URL: https://ai.sumopod.com/v1. |
| `suno` | `suno` | Suno | API key | [link](https://suno.ai) | Paste session cookie from suno.ai (Clerk auth) |
| `synthetic` | `synthetic` | Synthetic | API key, aggregator | [link](https://synthetic.new) | — |
| `tabitoken` | `tabitoken` | TabiToken | API key, aggregator | [link](https://tabitoken.com) | — |
| `tencent` | `tencent` | Tencent Hunyuan | API key | [link](https://hunyuan.tencent.com) | Get API key at console.cloud.tencent.com |
| `thebai` | `thebai` | TheB.AI | API key, aggregator | [link](https://theb.ai) | Bearer API key for the TheB.AI OpenAI-compatible gateway. |
| `tinyfish` | `tf` | TinyFish Fetch | API key | [link](https://docs.tinyfish.ai/fetch-api) | X-API-Key from agent.tinyfish.ai/api-keys |

View File

@@ -1,6 +1,6 @@
# OmniRoute
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 343 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 346 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
## Overview
@@ -165,7 +165,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ └── manager.ts # MITM proxy manager
│ ├── shared/ # Shared utilities, components, and constants
│ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.)
│ │ ├── constants/ # Provider definitions (343), model lists, pricing, routing strategies, MCP scopes
│ │ ├── constants/ # Provider definitions (346), model lists, pricing, routing strategies, MCP scopes
│ │ ├── contracts/ # Shared API contracts
│ │ ├── hooks/ # React hooks
│ │ ├── middleware/ # Shared middleware utilities
@@ -277,7 +277,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
## Key Features (v3.8.50)
### Core Proxy
- **343 AI providers** with automatic format translation
- **346 AI providers** with automatic format translation
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
- **4-tier fallback**: Subscription → API Key → Cheap → Free
@@ -475,7 +475,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
## v3.8.x Highlights
- **343-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add
- **346-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add
- **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay`
- **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown
- **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules)

View File

@@ -918,9 +918,9 @@ export function parseImageModel(modelStr) {
}
}
// No provider prefix — try to find the model in every provider
// No provider prefix — try to find the model in every provider, excluding cookie-auth (web) bridges
for (const [providerId, config] of Object.entries(IMAGE_PROVIDERS)) {
if (config.routingAliases?.includes(modelStr) || config.models.some((m) => m.id === modelStr)) {
if (config.authHeader !== "cookie" && (config.routingAliases?.includes(modelStr) || config.models.some((m) => m.id === modelStr))) {
return { provider: providerId, model: modelStr };
}
}

View File

@@ -17,6 +17,8 @@ interface MusicProvider {
id: string;
baseUrl: string;
statusUrl?: string;
/** Regional deployment of the same contract, reachable via a base-URL override. */
regionalBaseUrl?: string;
authType: string;
authHeader: string;
format: string;
@@ -79,14 +81,21 @@ export const MUSIC_PROVIDERS: Record<string, MusicProvider> = {
minimax: {
id: "minimax",
baseUrl: "https://api.minimax.io/v1/music_generation",
statusUrl: "https://api.minimax.io/v1/query/music_generation",
// The music operation answers with the finished audio in the POST response —
// there is no task id and no query endpoint, hence no statusUrl. The regional
// deployment serves the same contract and is the only host that accepts the
// `aigc_watermark` request field.
regionalBaseUrl: "https://api.minimaxi.com/v1/music_generation",
authType: "apikey",
authHeader: "bearer",
format: "minimax-music",
models: [
{ id: "music-3.0", name: "Music 3.0" },
{ id: "music-2.6", name: "Music 2.6" },
{ id: "music-3.0-free", name: "Music 3.0 Free" },
{ id: "music-2.6-free", name: "Music 2.6 Free" },
{ id: "music-cover", name: "Music Cover" },
{ id: "music-cover-free", name: "Music Cover Free" },
],
},
comfyui: {

View File

@@ -19,6 +19,19 @@ export interface AlternateFormat {
authHeader?: string;
headers?: Record<string, string>;
urlSuffix?: string;
/**
* Monta a URL final quando o protocolo alternativo embute o modelo no path, e
* nao apenas um sufixo fixo. O caso concreto e o protocolo Gemini, cuja rota e
* `{base}/{model}:generateContent` (ou `:streamGenerateContent?alt=sse`) — algo
* que `chatPath`/`urlSuffix` nao expressam, porque ambos sao constantes.
*
* Mesma assinatura do `urlBuilder` de RegistryEntry (base ja sem "/" final,
* modelo e stream), de proposito: um gateway que fala Gemini como alternativa
* reaproveita `buildGeminiGenerateContentUrl` de shared.ts — o mesmo builder que
* o provedor Gemini nativo usa — em vez de reimplementar a rota.
* Quando ausente, a URL continua sendo `baseUrl + chatPath + urlSuffix`.
*/
urlBuilder?: (base: string, model: string, stream: boolean) => string;
label: string;
}

View File

@@ -263,6 +263,7 @@ import { freeinferenceProvider } from "./registry/freeinference/index.ts";
import { freeAiProvider } from "./registry/free-ai/index.ts";
import { voidAiProvider } from "./registry/void-ai/index.ts";
import { helixmindProvider } from "./registry/helixmind/index.ts";
import { tabitokenProvider } from "./registry/tabitoken/index.ts";
export const REGISTRY: Record<string, RegistryEntry> = {
aimlapi: aimlapiProvider,
@@ -530,4 +531,5 @@ export const REGISTRY: Record<string, RegistryEntry> = {
"free-ai": freeAiProvider,
"void-ai": voidAiProvider,
helixmind: helixmindProvider,
tabitoken: tabitokenProvider,
};

View File

@@ -1,5 +1,5 @@
import type { RegistryEntry } from "../../shared.ts";
import { resolvePublicCred } from "../../shared.ts";
import { buildGeminiGenerateContentUrl, resolvePublicCred } from "../../shared.ts";
export const geminiProvider: RegistryEntry = {
id: "gemini",
@@ -7,10 +7,7 @@ export const geminiProvider: RegistryEntry = {
format: "gemini",
executor: "default",
baseUrl: "https://generativelanguage.googleapis.com/v1beta/models",
urlBuilder: (base, model, stream) => {
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
return `${base}/${model}:${action}`;
},
urlBuilder: buildGeminiGenerateContentUrl,
authType: "apikey",
authHeader: "x-goog-api-key",
defaultContextLength: 1048576,

View File

@@ -1,11 +1,62 @@
import type { RegistryEntry } from "../../shared.ts";
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
import {
buildGeminiGenerateContentUrl,
buildOpenAiCompatibleRegistryEntry,
getAnthropicCompatHeaders,
} from "../../shared.ts";
/**
* HCNSec — NewAPI-based host (https://api.hcnsec.cn), announced by its own `/api/status` as
* 新疆幻城网安科技公益大模型安全网关. Catalogued as an API-key **regional** provider
* (`APIKEY_PROVIDERS_REGIONAL.hcnsec`); this entry only describes how to reach it.
*
* It shipped OpenAI-only. The three alternates below were added after probing the host live:
* every one of them reaches the NewAPI token layer (`{"error":{"type":"new_api_error"}}` on an
* invalid key) rather than a router 404, so each is a route this host actually serves —
* including the Gemini path in both its unary and `:streamGenerateContent?alt=sse` forms.
* The default format, base URL and auth scheme are deliberately untouched.
*
* `models: []` is unchanged and deliberate. Unlike TabiToken, this host gates every discovery
* endpoint behind auth (`/api/status` reports `pricing.requireAuth: true`; `/api/pricing`,
* `/api/models`, `/api/models/display` and `/api/user/models` all answer "Unauthorized, not
* logged in and no access token provided"). Rather than ship a guessed catalog, the model list
* is left to live discovery through `modelsUrl` with the operator's own key — the same
* arrangement `anyapi` and `helixmind` use.
*/
export const hcnsecProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
id: "hcnsec",
alias: "hcnsec",
baseUrl: "https://api.hcnsec.cn/v1/chat/completions",
modelsUrl: "https://api.hcnsec.cn/v1/models",
responsesBaseUrl: "https://api.hcnsec.cn/v1/responses",
models: [],
passthroughModels: true,
alternateFormats: [
{
// `Anthropic-Version` is scoped to this alternate (deepseek's arrangement) because
// it is only meaningful on `/v1/messages`, and because `default.ts` supplies that
// default solely for `anthropic-compatible-*` provider ids — not for a gateway that
// reaches the Claude protocol through an alternate.
format: "claude",
baseUrl: "https://api.hcnsec.cn/v1/messages",
authHeader: "x-api-key",
headers: getAnthropicCompatHeaders(),
label: "Anthropic-compatible",
},
{
format: "openai-responses",
baseUrl: "https://api.hcnsec.cn/v1/responses",
authHeader: "bearer",
label: "OpenAI Responses",
},
{
// The Gemini protocol carries the model in the path, so this alternate needs the
// same builder the native `gemini` provider uses instead of a constant chatPath.
format: "gemini",
baseUrl: "https://api.hcnsec.cn/v1beta/models",
authHeader: "x-goog-api-key",
urlBuilder: buildGeminiGenerateContentUrl,
label: "Gemini-compatible",
},
],
});

View File

@@ -0,0 +1,59 @@
import type { RegistryEntry } from "../../shared.ts";
import { getAnthropicCompatHeaders } from "../../shared.ts";
/**
* TabiToken — NewAPI-based Claude gateway (https://tabitoken.com).
*
* The catalog below is not hand-written: TabiToken leaves the NewAPI pricing endpoint
* public (`/api/status` reports `pricing.requireAuth: false`), so `GET /api/pricing`
* lists every model together with the protocols it accepts. All four entries report
* `supported_endpoint_types: ["anthropic","openai"]`, which is why only those two
* protocols are declared here — the host also routes `/v1/responses` and the Gemini
* `/v1beta` path, but no model on this gateway is reachable through them.
*
* Claude-first (`/v1/messages` + `x-api-key`) because the whole catalog is Claude and
* that avoids a translation hop for Claude-native clients; `passthroughModels` keeps
* models added upstream usable before this list catches up.
*
* No static fingerprint headers. TabiToken fronts Cloudflare, and the only User-Agent
* it rejects is the literal `curl/*` default — a browser UA is answered with
* "Access denied: abusive or non-compliant use is prohibited", while sending no UA
* (the fetch default) reaches the token layer normally. So, unlike agentrouter, this
* entry needs neither a static nor a dynamic wire image.
*
* `headers` carries only `Anthropic-Version`, and it has to live on the entry rather
* than come from the executor: `default.ts` defaults that header solely for provider
* ids prefixed `anthropic-compatible-` (buildHeaders, the `startsWith` branch), so a
* plain `format: "claude"` entry would POST `/v1/messages` without it. Six sibling
* third-party Claude entries (wafer, zai, xiaomi-mimo, xiaomi-mimo-token-plan,
* bailian-coding-plan, deepseek) set it for exactly this reason. Entry-level headers
* are merged for every format (base.ts::buildHeadersPreamble), so the OpenAI alternate
* below also sends it — a documented no-op on `/chat/completions` (see the same note in
* executors/github.ts).
*/
export const tabitokenProvider: RegistryEntry = {
id: "tabitoken",
alias: "tabitoken",
format: "claude",
executor: "default",
baseUrl: "https://tabitoken.com/v1/messages",
modelsUrl: "https://tabitoken.com/v1/models",
authType: "apikey",
authHeader: "x-api-key",
headers: getAnthropicCompatHeaders(),
alternateFormats: [
{
format: "openai",
baseUrl: "https://tabitoken.com/v1/chat/completions",
authHeader: "bearer",
label: "OpenAI-compatible",
},
],
models: [
{ id: "claude-opus-5", name: "Claude Opus 5" },
{ id: "claude-opus-5-thinking", name: "Claude Opus 5 (Thinking)" },
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
{ id: "claude-opus-4-8-thinking", name: "Claude Opus 4.8 (Thinking)" },
],
passthroughModels: true,
};

View File

@@ -758,3 +758,20 @@ export function buildAntigravityUrl(base: string, model: string, stream: boolean
const path = stream ? "/v1internal:streamGenerateContent?alt=sse" : "/v1internal:generateContent";
return `${base}${path}`;
}
/**
* Gemini protocol `generateContent` route: the model goes in the path, not the body.
*
* Shared because the format has two consumers: the native `gemini` provider
* (RegistryEntry.urlBuilder) and gateways that expose Gemini as an alternate
* protocol (AlternateFormat.urlBuilder, see alternateFormats.ts). One copy per
* consumer would leave the streaming `?alt=sse` suffix free to diverge.
*/
export function buildGeminiGenerateContentUrl(
base: string,
model: string,
stream: boolean
): string {
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
return `${base}/${model}:${action}`;
}

View File

@@ -303,6 +303,19 @@ export const SEARCH_CREDENTIAL_FALLBACKS: Record<string, string> = {
export const SEARCH_PROVIDER_ALIASES: Record<string, string> = {
"jina-ai": "jina-search",
jina: "jina-search",
brave: "brave-search",
serper: "serper-search",
perplexity: "perplexity-search",
exa: "exa-search",
tavily: "tavily-search",
"google-pse": "google-pse-search",
linkup: "linkup-search",
ollama: "ollama-search",
searchapi: "searchapi-search",
youcom: "youcom-search",
searxng: "searxng-search",
zai: "zai-search",
duckduckgo: "duckduckgo-free",
};
export function resolveSearchProviderId(providerId: string): string {

View File

@@ -191,6 +191,9 @@ export class DefaultExecutor extends BaseExecutor {
// Operator's manual override (#6147) keeps its own semantics and falls
// through to the provider-specific handling below.
const normalized = alternate.baseUrl.replace(/\/$/, "");
// A model-scoped alternate (the Gemini protocol: `{base}/{model}:generateContent`)
// builds its own URL — chatPath/urlSuffix are constants and cannot carry the model.
if (alternate.urlBuilder) return alternate.urlBuilder(normalized, model, stream);
return `${normalized}${alternate.chatPath || ""}${alternate.urlSuffix || ""}`;
}
}

View File

@@ -0,0 +1,358 @@
/**
* MiniMax music generation handler (format: "minimax-music").
*
* The provider entry has been in musicRegistry since the media registries were
* introduced, but handleMusicGeneration never grew a branch for its format — so
* every registered `minimax/*` music model fell through the dispatch chain to
* `Unsupported music format: minimax-music` (400) and the models were
* advertised by /v1/models while being impossible to call.
*
* The upstream contract is a single synchronous POST — unlike the vendor's
* task-based media endpoints there is no task id and no query endpoint, so a
* request is either finished (`data.status` 2, audio in `data.audio`) or still
* generating (`data.status` 1), which can only be reported back, never awaited.
* Failures are carried in the `base_resp` envelope (`status_code` 0 = success)
* even on HTTP 200.
*
* `output_format` selects how the audio comes back: `url` (a short-lived link,
* valid for 24h — callers must download it before it expires) or `hex` (the raw
* container inline, normalized here to base64 so the response matches the
* OpenAI-shaped payload the other music branches return).
*/
import { saveCallLog } from "@/lib/usageDb";
import { sanitizeErrorMessage } from "../../utils/error.ts";
type MinimaxMusicBody = Record<string, unknown>;
interface MinimaxMusicProviderConfig {
baseUrl: string;
/** Regional deployment of the same contract — see resolveEndpoint below. */
regionalBaseUrl?: string;
}
interface MinimaxMusicCredentials {
apiKey?: unknown;
accessToken?: unknown;
providerSpecificData?: { baseUrl?: unknown } | null;
}
interface MinimaxMusicLog {
info?: (scope: string, message: string) => void;
error?: (scope: string, message: string) => void;
}
interface MinimaxMusicArgs {
model: string;
provider: string;
providerConfig: MinimaxMusicProviderConfig;
body: MinimaxMusicBody;
credentials?: MinimaxMusicCredentials | null;
log?: MinimaxMusicLog | null;
}
/** Containers accepted by `audio_setting.format`. */
const AUDIO_FORMATS = new Set(["mp3", "wav", "pcm"]);
/** Accepted `output_format` values. */
const OUTPUT_FORMATS = new Set(["url", "hex"]);
/** Container assumed when the request does not pin `audio_setting.format`. */
const DEFAULT_AUDIO_FORMAT = "mp3";
/** `data.status`: 1 = still generating, 2 = finished. */
const STATUS_IN_PROGRESS = 1;
/** String request fields forwarded verbatim when the caller provides them. */
const STRING_REQUEST_FIELDS = [
"prompt",
"lyrics",
"audio_url",
"audio_base64",
"cover_feature_id",
] as const;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function numberValue(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function booleanValue(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
/** Fire-and-forget usage log for a MiniMax music-generation call. */
function logMinimaxMusicCall(params: {
status: number;
model: string;
provider: string;
duration: number;
error?: string;
requestBody?: unknown;
responseBody?: unknown;
}): void {
saveCallLog({
method: "POST",
path: "/v1/music/generations",
...params,
}).catch(() => {});
}
/**
* Endpoint for this call: the per-connection `providerSpecificData.baseUrl`
* override (the same storage every configurable-base-URL provider uses) wins
* over the registry default. That override is how a connection targets the
* regional deployment declared as `regionalBaseUrl`.
*/
function resolveEndpoint(
providerConfig: MinimaxMusicProviderConfig,
credentials?: MinimaxMusicCredentials | null
): string {
const psd = credentials?.providerSpecificData;
const override = isRecord(psd) ? stringValue(psd.baseUrl) : undefined;
return override || providerConfig.baseUrl;
}
/** True when `endpoint` is the regional deployment declared by the registry. */
function isRegionalEndpoint(endpoint: string, regionalBaseUrl?: string): boolean {
if (!regionalBaseUrl) return false;
try {
return new URL(endpoint).host === new URL(regionalBaseUrl).host;
} catch {
return false;
}
}
/** Forwards only the recognized `audio_setting` members, dropping unknown containers. */
function buildAudioSetting(body: MinimaxMusicBody): Record<string, unknown> | undefined {
const provided: Record<string, unknown> = isRecord(body.audio_setting) ? body.audio_setting : {};
const setting: Record<string, unknown> = {};
const sampleRate = numberValue(provided.sample_rate);
if (sampleRate !== undefined) setting.sample_rate = sampleRate;
const bitrate = numberValue(provided.bitrate);
if (bitrate !== undefined) setting.bitrate = bitrate;
const format = stringValue(provided.format)?.toLowerCase();
if (format && AUDIO_FORMATS.has(format)) setting.format = format;
return Object.keys(setting).length > 0 ? setting : undefined;
}
/** Container reported back to the caller — mirrors what was asked upstream. */
function resolveAudioFormat(body: MinimaxMusicBody): string {
const provided: Record<string, unknown> = isRecord(body.audio_setting) ? body.audio_setting : {};
const format = stringValue(provided.format)?.toLowerCase();
return format && AUDIO_FORMATS.has(format) ? format : DEFAULT_AUDIO_FORMAT;
}
function resolveOutputFormat(body: MinimaxMusicBody): string {
const requested = stringValue(body.output_format)?.toLowerCase();
return requested && OUTPUT_FORMATS.has(requested) ? requested : "url";
}
/**
* Upstream request body. `stream` is pinned false: this route answers with a
* single JSON payload, and streaming responses would also be restricted to the
* hex output format.
*/
function buildUpstreamBody(
model: string,
body: MinimaxMusicBody,
regional: boolean
): Record<string, unknown> {
const request: Record<string, unknown> = {
model,
stream: false,
output_format: resolveOutputFormat(body),
};
for (const field of STRING_REQUEST_FIELDS) {
const value = stringValue(body[field]);
if (value !== undefined) request[field] = value;
}
const audioSetting = buildAudioSetting(body);
if (audioSetting) request.audio_setting = audioSetting;
const lyricsOptimizer = booleanValue(body.lyrics_optimizer);
if (lyricsOptimizer !== undefined) request.lyrics_optimizer = lyricsOptimizer;
// `instrumental` is the spelling the other music branches already accept.
const isInstrumental = booleanValue(body.is_instrumental) ?? booleanValue(body.instrumental);
if (isInstrumental !== undefined) request.is_instrumental = isInstrumental;
// Only the regional endpoint accepts a watermark flag.
if (regional) {
const watermark = booleanValue(body.aigc_watermark);
if (watermark !== undefined) request.aigc_watermark = watermark;
}
return request;
}
async function readPayload(response: Response): Promise<Record<string, unknown>> {
const rawText = await response.text();
if (!rawText) return {};
try {
const parsed: unknown = JSON.parse(rawText);
return isRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
/** Hex payloads are normalized to base64; Buffer would silently drop bad nibbles. */
function hexAudioToBase64(audioHex: string): string {
if (audioHex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(audioHex)) {
throw new Error("MiniMax music generation returned invalid hex audio");
}
return Buffer.from(audioHex, "hex").toString("base64");
}
/** `base_resp.status_code` is non-zero on failures that still answer HTTP 200. */
function readEnvelopeError(payload: Record<string, unknown>): string | undefined {
const baseResp: Record<string, unknown> = isRecord(payload.base_resp) ? payload.base_resp : {};
const statusCode = numberValue(baseResp.status_code);
if (statusCode === undefined || statusCode === 0) return undefined;
return stringValue(baseResp.status_msg) || `upstream status code ${statusCode}`;
}
export async function handleMinimaxMusicGeneration({
model,
provider,
providerConfig,
body,
credentials,
log,
}: MinimaxMusicArgs) {
const startTime = Date.now();
const token = stringValue(credentials?.apiKey) || stringValue(credentials?.accessToken);
if (!token) {
return { success: false as const, status: 401, error: "MiniMax API key is required" };
}
const modelId = stringValue(model);
if (!modelId) {
return { success: false as const, status: 400, error: "MiniMax music model is required" };
}
const endpoint = resolveEndpoint(providerConfig, credentials);
const upstreamBody = buildUpstreamBody(
modelId,
body,
isRegionalEndpoint(endpoint, providerConfig.regionalBaseUrl)
);
const audioFormat = resolveAudioFormat(body);
const modelLabel = `${provider}/${modelId}`;
log?.info?.(
"MUSIC",
`${modelLabel} (minimax-music) | prompt: "${String(body.prompt ?? "").slice(0, 60)}..." | ` +
`output_format: ${upstreamBody.output_format} | audio_format: ${audioFormat}`
);
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(upstreamBody),
});
const payload = await readPayload(response);
if (!response.ok) {
const errorMessage =
readEnvelopeError(payload) || `MiniMax music generation failed (${response.status})`;
log?.error?.("MUSIC", `${provider} minimax-music error ${response.status}: ${errorMessage}`);
logMinimaxMusicCall({
status: response.status,
model: modelLabel,
provider,
duration: Date.now() - startTime,
error: errorMessage,
requestBody: upstreamBody,
});
return { success: false as const, status: response.status, error: errorMessage };
}
const envelopeError = readEnvelopeError(payload);
if (envelopeError) {
log?.error?.("MUSIC", `${provider} minimax-music rejected the request: ${envelopeError}`);
logMinimaxMusicCall({
status: 502,
model: modelLabel,
provider,
duration: Date.now() - startTime,
error: envelopeError,
requestBody: upstreamBody,
});
return { success: false as const, status: 502, error: envelopeError };
}
const data: Record<string, unknown> = isRecord(payload.data) ? payload.data : {};
// No task id and no query endpoint exist for this operation, so an
// unfinished generation cannot be polled — surface it instead of hanging.
if (numberValue(data.status) === STATUS_IN_PROGRESS) {
const pending = "MiniMax music generation is still in progress; retry the request";
logMinimaxMusicCall({
status: 502,
model: modelLabel,
provider,
duration: Date.now() - startTime,
error: pending,
});
return { success: false as const, status: 502, error: pending };
}
const audio = stringValue(data.audio);
if (!audio) {
const errorMessage = "MiniMax music generation returned no audio";
logMinimaxMusicCall({
status: 502,
model: modelLabel,
provider,
duration: Date.now() - startTime,
error: errorMessage,
});
return { success: false as const, status: 502, error: errorMessage };
}
const track =
upstreamBody.output_format === "hex"
? { b64_json: hexAudioToBase64(audio), format: audioFormat }
: { url: audio, format: audioFormat };
logMinimaxMusicCall({
status: 200,
model: modelLabel,
provider,
duration: Date.now() - startTime,
responseBody: { audio_count: 1 },
});
return {
success: true as const,
data: { created: Math.floor(Date.now() / 1000), data: [track] },
};
} catch (err: unknown) {
const errorMessage = sanitizeErrorMessage(err) || "Music provider error";
log?.error?.("MUSIC", `${provider} minimax-music error: ${errorMessage}`);
logMinimaxMusicCall({
status: 502,
model: modelLabel,
provider,
duration: Date.now() - startTime,
error: errorMessage,
});
return { success: false as const, status: 502, error: errorMessage };
}
}

View File

@@ -33,6 +33,7 @@ import {
} from "../utils/kieTask.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
import { handleFalMusicGeneration } from "./mediaGeneration/fal.ts";
import { handleMinimaxMusicGeneration } from "./mediaGeneration/minimaxMusic.ts";
function normalizeKieSunoModel(model: string): string {
const map: Record<string, string> = {
@@ -153,6 +154,17 @@ export async function handleMusicGeneration({ body, credentials, log }) {
return handleUdioMusicGeneration({ model, provider, providerConfig, body, credentials, log });
}
if (providerConfig.format === "minimax-music") {
return handleMinimaxMusicGeneration({
model,
provider,
providerConfig,
body,
credentials,
log,
});
}
return {
success: false,
status: 400,

View File

@@ -64,6 +64,7 @@ import { getHiddenModelsByProvider } from "@/models";
import { resolveModelLockoutSettings } from "../../src/lib/resilience/modelLockoutSettings";
import { fetchCodexQuota } from "./codexQuotaFetcher.ts";
import { evaluateQuotaCutoff, getQuotaFetcher, type QuotaInfo } from "./quotaPreflight.ts";
import { resolveProviderId } from "../../src/shared/constants/providers.ts";
import * as semaphore from "./rateLimitSemaphore.ts";
import { getCircuitBreaker } from "../../src/shared/utils/circuitBreaker";
import { parseModel } from "./model.ts";
@@ -491,7 +492,10 @@ export async function buildAutoCandidates(
let quotaRemaining = 100;
let quotaCutoffBlocked = false;
let quotaCutoffReason: string | undefined;
const fetcher = getQuotaFetcher(provider);
// #10877: `provider` here may be a legacy/user-facing alias spelling
// (target.provider/parseModel output); canonicalize before the fetcher
// registry lookup so aliased combo members still hit quota-aware scoring.
const fetcher = getQuotaFetcher(resolveProviderId(provider));
const connection = target.connectionId ? connectionById.get(target.connectionId) : undefined;
const authType = typeof connection?.authType === "string" ? connection.authType : null;
const sessionAvailability =
@@ -2275,7 +2279,10 @@ async function handleComboChatInner({
);
}
}
log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status });
log.warn("COMBO", `Model ${modelStr} failed, trying next`, {
status: result.status,
errorBody: redactConnectionLabel(errorText),
});
// #5976: per-model-quota providers (Gemini, GitHub, etc.) multiplex models
// behind one connection. A model-level 500 or 429 (RPM) must NOT cool down
@@ -3460,7 +3467,10 @@ async function handleRoundRobinCombo({
kind: classifyComboOutcome(result.status, errorText),
});
if (offset > 0) fallbackCount++;
log.warn("COMBO-RR", `${modelStr} failed, trying next model`, { status: result.status });
log.warn("COMBO-RR", `${modelStr} failed, trying next model`, {
status: result.status,
errorBody: redactConnectionLabel(errorText),
});
if (
resilienceSettings.providerCooldown.enabled &&

View File

@@ -14,6 +14,7 @@ import { isRecord } from "./comboData.ts";
import type { SlaRoutingPolicy } from "../autoCombo/routerStrategy.ts";
import { RESET_WINDOW_NAMES } from "./types.ts";
import type { ResolvedComboTarget } from "./types.ts";
import { resolveProviderId } from "../../../src/shared/constants/providers.ts";
const RESET_AWARE_SESSION_WINDOW_MS = 5 * 60 * 60 * 1000;
const RESET_AWARE_WEEKLY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
@@ -138,7 +139,11 @@ export function resolveSlaRoutingPolicy(
export function getResetAwareProvider(target: ResolvedComboTarget): string | null {
const provider = (target.providerId || target.provider || "").toLowerCase();
return provider || null;
// #10877: combo targets can carry a legacy/user-facing alias spelling
// (e.g. "ollamacloud", "cx") while quota fetchers register under the
// canonical provider id (e.g. "ollama-cloud", "codex"). Canonicalize here
// so getQuotaFetcher() lookups downstream (quotaStrategies.ts) find them.
return provider ? resolveProviderId(provider) : null;
}
function normalizeResetAt(value: unknown): string | null {

View File

@@ -8,7 +8,7 @@
*
* ## Fail-open paths
* 1. Optional-deps gate: if any of `@atjsh/llmlingua-2`, `@huggingface/transformers`,
* `@tensorflow/tfjs`, `js-tiktoken` does not resolve, return `text` immediately —
* `js-tiktoken` does not resolve, return `text` immediately —
* NO worker spawn. This is the default in CI / most installs (deps are OPTIONAL).
* 2. Per-call timeout: first call for a model gets `FIRST_CALL_TIMEOUT_MS` (one-time
* model load); warm calls get `LLMLINGUA_WORKER_TIMEOUT_MS`. On timeout → original
@@ -16,7 +16,7 @@
* 3. Worker error/exit → resolve all pending with their original text + respawn next.
*
* ## Serialization
* ONNX/tfjs are not reentrant — calls are queued FIFO and only one message is
* ONNX inference is not reentrant — calls are queued FIFO and only one message is
* in-flight at a time (the next is posted after the previous reply or its timeout).
*
* ## Idle eviction
@@ -45,7 +45,7 @@ const FIRST_CALL_TIMEOUT_MS = 60000;
/**
* Gate probe: `@atjsh/llmlingua-2` is the entry package that declares the others
* (`@huggingface/transformers`, `@tensorflow/tfjs`, `js-tiktoken`) as peers. We probe
* (`@huggingface/transformers`, `js-tiktoken`) as peers. We probe
* ONLY it (by manifest existence) because the peers are ESM-only and `require.resolve`
* throws for them even when installed; the worker still fail-opens if a peer is
* genuinely missing at `import()` time.

View File

@@ -656,6 +656,18 @@ export function applyRtkCompression(
};
});
// Mirror the sibling stacked engines (headroom, session-dedup, ccr, relevance,
// ionizer, readLifecycle): skip the expensive createCompressionStats() pass
// (full JSON.stringify + tokenizer over the whole body, twice) when nothing
// actually changed. Untouched messages keep their original reference above,
// so a reference-identity scan is enough to detect the no-op case (#10765).
const anyMessageChanged = compressedMessages.some(
(message, index) => message !== messages[index]
);
if (!anyMessageChanged) {
return { body, compressed: false, stats: null };
}
const compressedBody = { ...adapter.body, messages: compressedMessages };
const stats = createCompressionStats(
adapter.body,

View File

@@ -121,6 +121,29 @@ export function pushUniqueResponsesOutputItems(target: unknown[], items: readonl
}
}
/**
* #10156 — strip items matched by `isCommentaryItem` (the same predicate used
* to drop live commentary-phase SSE frames, #6199) from a `response.completed`
* output array before it is forwarded or buffered for backfill. Upstreams may
* echo an already-dropped commentary item back inside a non-empty terminal
* `output` array; without this, the live stream and the terminal snapshot
* silently disagree about what the client actually saw.
*/
export function filterResponsesCommentaryFromItems(
items: readonly unknown[],
isCommentaryItem: (item: unknown) => boolean
): { items: unknown[]; changed: boolean } {
let changed = false;
const filtered = items.filter((item) => {
if (isCommentaryItem(item)) {
changed = true;
return false;
}
return true;
});
return { items: filtered, changed };
}
export function backfillResponsesCompletedOutput(
parsed: unknown,
collectedItems: readonly unknown[]

View File

@@ -36,6 +36,7 @@ import {
import { STREAM_IDLE_TIMEOUT_MS, FETCH_BODY_TIMEOUT_MS, HTTP_STATUS } from "../config/constants.ts";
import {
OMIT_STREAMING_CHUNK_MARKER,
isResponsesCommentaryMessageItem,
sanitizeStreamingChunk,
} from "../handlers/responseSanitizer.ts";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
@@ -59,6 +60,7 @@ import {
} from "../services/sessionManager.ts";
import {
backfillResponsesCompletedOutput,
filterResponsesCommentaryFromItems,
normalizeResponsesCompletedUsage as normalizeUsage,
normalizeResponsesSseIds,
pushUniqueResponsesOutputItems,
@@ -1565,11 +1567,26 @@ export function createSSEStream(options: StreamOptions = {}) {
}
}
}
let responsesCommentaryStrippedFromCompleted = false;
if (
parsed.type === "response.completed" &&
Array.isArray(parsed.response?.output) &&
parsed.response.output.length > 0
) {
// #10156 — an upstream may echo a `phase:"commentary"` item back
// inside a non-empty terminal `output` array even though its live
// SSE frames were already dropped above. Keep both representations
// consistent by applying the same drop here.
if (shouldDropResponsesCommentary) {
const { items, changed } = filterResponsesCommentaryFromItems(
parsed.response.output,
isResponsesCommentaryMessageItem
);
if (changed) {
parsed.response.output = items;
responsesCommentaryStrippedFromCompleted = true;
}
}
pushUniqueResponsesOutputItems(
passthroughResponsesOutputItems,
parsed.response.output
@@ -1613,9 +1630,19 @@ export function createSSEStream(options: StreamOptions = {}) {
]) as typeof parsed;
}
const stripped = stripResponsesLifecycleEcho(parsed);
// Belt-and-suspenders for #10156: filter the backfill buffer itself
// before it can seed an empty `response.completed.response.output`,
// in case a future code path pushes a commentary item into it
// without going through the response.completed branch above.
const backfillCandidates = shouldDropResponsesCommentary
? filterResponsesCommentaryFromItems(
passthroughResponsesOutputItems,
isResponsesCommentaryMessageItem
).items
: passthroughResponsesOutputItems;
const backfilled = backfillResponsesCompletedOutput(
parsed,
passthroughResponsesOutputItems
backfillCandidates
);
const usageNormalized = normalizeUsage(parsed);
if (
@@ -1623,7 +1650,8 @@ export function createSSEStream(options: StreamOptions = {}) {
backfilled ||
textualToolCallBackfilled ||
responsesIdsNormalized ||
usageNormalized
usageNormalized ||
responsesCommentaryStrippedFromCompleted
) {
output = `data: ${JSON.stringify(parsed)}\n\n`;
injectedUsage = true;

367
package-lock.json generated
View File

@@ -155,8 +155,7 @@
"node": ">=22.22.2 <23 || >=24.0.0 <27"
},
"optionalDependencies": {
"@atjsh/llmlingua-2": "2.0.3",
"@tensorflow/tfjs": "4.22.0",
"@atjsh/llmlingua-2": "2.0.5",
"better-sqlite3": "^13.0.2",
"js-tiktoken": "^1.0.20",
"keytar": "^7.9.0",
@@ -556,17 +555,16 @@
}
},
"node_modules/@atjsh/llmlingua-2": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@atjsh/llmlingua-2/-/llmlingua-2-2.0.3.tgz",
"integrity": "sha512-UJJFMbzYldkZ4qX5CrSZtmytOnXf6aXhmr1sBhbpVMHdmQG+7GCnrx5rIwPSOmozXD9KiPv5nnV6pvzxdtHdYQ==",
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@atjsh/llmlingua-2/-/llmlingua-2-2.0.5.tgz",
"integrity": "sha512-cXdGUJgx0e2Sui5gYC8kapOhw1HAxwzh9IuYPdqyB+VlP6SL9imIfyB7I4GTCl/iG+BUxaOqSrLqWsWYvDZuVQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"es-toolkit": "^1.38.0"
},
"peerDependencies": {
"@huggingface/transformers": "*",
"@tensorflow/tfjs": "*",
"@huggingface/transformers": "^3.5.2 || ^4.0.0",
"js-tiktoken": "*"
}
},
@@ -12241,241 +12239,6 @@
"tailwindcss": "4.3.3"
}
},
"node_modules/@tensorflow/tfjs": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs/-/tfjs-4.22.0.tgz",
"integrity": "sha512-0TrIrXs6/b7FLhLVNmfh8Sah6JgjBPH4mZ8JGb7NU6WW+cx00qK5BcAZxw7NCzxj6N8MRAIfHq+oNbPUNG5VAg==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@tensorflow/tfjs-backend-cpu": "4.22.0",
"@tensorflow/tfjs-backend-webgl": "4.22.0",
"@tensorflow/tfjs-converter": "4.22.0",
"@tensorflow/tfjs-core": "4.22.0",
"@tensorflow/tfjs-data": "4.22.0",
"@tensorflow/tfjs-layers": "4.22.0",
"argparse": "^1.0.10",
"chalk": "^4.1.0",
"core-js": "3.29.1",
"regenerator-runtime": "^0.13.5",
"yargs": "^16.0.3"
},
"bin": {
"tfjs-custom-module": "dist/tools/custom_module/cli.js"
}
},
"node_modules/@tensorflow/tfjs-backend-cpu": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-cpu/-/tfjs-backend-cpu-4.22.0.tgz",
"integrity": "sha512-1u0FmuLGuRAi8D2c3cocHTASGXOmHc/4OvoVDENJayjYkS119fcTcQf4iHrtLthWyDIPy3JiPhRrZQC9EwnhLw==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@types/seedrandom": "^2.4.28",
"seedrandom": "^3.0.5"
},
"engines": {
"yarn": ">= 1.3.2"
},
"peerDependencies": {
"@tensorflow/tfjs-core": "4.22.0"
}
},
"node_modules/@tensorflow/tfjs-backend-webgl": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-webgl/-/tfjs-backend-webgl-4.22.0.tgz",
"integrity": "sha512-H535XtZWnWgNwSzv538czjVlbJebDl5QTMOth4RXr2p/kJ1qSIXE0vZvEtO+5EC9b00SvhplECny2yDewQb/Yg==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@tensorflow/tfjs-backend-cpu": "4.22.0",
"@types/offscreencanvas": "~2019.3.0",
"@types/seedrandom": "^2.4.28",
"seedrandom": "^3.0.5"
},
"engines": {
"yarn": ">= 1.3.2"
},
"peerDependencies": {
"@tensorflow/tfjs-core": "4.22.0"
}
},
"node_modules/@tensorflow/tfjs-converter": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-converter/-/tfjs-converter-4.22.0.tgz",
"integrity": "sha512-PT43MGlnzIo+YfbsjM79Lxk9lOq6uUwZuCc8rrp0hfpLjF6Jv8jS84u2jFb+WpUeuF4K33ZDNx8CjiYrGQ2trQ==",
"license": "Apache-2.0",
"optional": true,
"peerDependencies": {
"@tensorflow/tfjs-core": "4.22.0"
}
},
"node_modules/@tensorflow/tfjs-core": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-4.22.0.tgz",
"integrity": "sha512-LEkOyzbknKFoWUwfkr59vSB68DMJ4cjwwHgicXN0DUi3a0Vh1Er3JQqCI1Hl86GGZQvY8ezVrtDIvqR1ZFW55A==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@types/long": "^4.0.1",
"@types/offscreencanvas": "~2019.7.0",
"@types/seedrandom": "^2.4.28",
"@webgpu/types": "0.1.38",
"long": "4.0.0",
"node-fetch": "~2.6.1",
"seedrandom": "^3.0.5"
},
"engines": {
"yarn": ">= 1.3.2"
}
},
"node_modules/@tensorflow/tfjs-core/node_modules/@types/offscreencanvas": {
"version": "2019.7.3",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
"license": "MIT",
"optional": true
},
"node_modules/@tensorflow/tfjs-data": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-data/-/tfjs-data-4.22.0.tgz",
"integrity": "sha512-dYmF3LihQIGvtgJrt382hSRH4S0QuAp2w1hXJI2+kOaEqo5HnUPG0k5KA6va+S1yUhx7UBToUKCBHeLHFQRV4w==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@types/node-fetch": "^2.1.2",
"node-fetch": "~2.6.1",
"string_decoder": "^1.3.0"
},
"peerDependencies": {
"@tensorflow/tfjs-core": "4.22.0",
"seedrandom": "^3.0.5"
}
},
"node_modules/@tensorflow/tfjs-layers": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-layers/-/tfjs-layers-4.22.0.tgz",
"integrity": "sha512-lybPj4ZNj9iIAPUj7a8ZW1hg8KQGfqWLlCZDi9eM/oNKCCAgchiyzx8OrYoWmRrB+AM6VNEeIT+2gZKg5ReihA==",
"license": "Apache-2.0 AND MIT",
"optional": true,
"peerDependencies": {
"@tensorflow/tfjs-core": "4.22.0"
}
},
"node_modules/@tensorflow/tfjs/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"optional": true,
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/@tensorflow/tfjs/node_modules/cliui": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
"integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
"license": "ISC",
"optional": true,
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^7.0.0"
}
},
"node_modules/@tensorflow/tfjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT",
"optional": true
},
"node_modules/@tensorflow/tfjs/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/@tensorflow/tfjs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"optional": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@tensorflow/tfjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@tensorflow/tfjs/node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/@tensorflow/tfjs/node_modules/yargs": {
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
"integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
"license": "MIT",
"optional": true,
"dependencies": {
"cliui": "^7.0.2",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.0",
"y18n": "^5.0.5",
"yargs-parser": "^20.2.2"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@tensorflow/tfjs/node_modules/yargs-parser": {
"version": "20.2.9",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz",
"integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@testing-library/jest-dom": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz",
@@ -13022,13 +12785,6 @@
"@types/node": "*"
}
},
"node_modules/@types/long": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
"license": "MIT",
"optional": true
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -13059,24 +12815,6 @@
"undici-types": "~8.3.0"
}
},
"node_modules/@types/node-fetch": {
"version": "2.6.13",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
"license": "MIT",
"optional": true,
"dependencies": {
"@types/node": "*",
"form-data": "^4.0.4"
}
},
"node_modules/@types/offscreencanvas": {
"version": "2019.3.0",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.3.0.tgz",
"integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==",
"license": "MIT",
"optional": true
},
"node_modules/@types/parse-json": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
@@ -13153,13 +12891,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/seedrandom": {
"version": "2.4.34",
"resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.34.tgz",
"integrity": "sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A==",
"license": "MIT",
"optional": true
},
"node_modules/@types/tough-cookie": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.0.tgz",
@@ -13978,13 +13709,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@webgpu/types": {
"version": "0.1.38",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.38.tgz",
"integrity": "sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA==",
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/@xmldom/xmldom": {
"version": "0.9.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
@@ -17208,18 +16932,6 @@
"node": ">=6.6.0"
}
},
"node_modules/core-js": {
"version": "3.29.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.29.1.tgz",
"integrity": "sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -26774,13 +26486,6 @@
"node": ">=0.1.90"
}
},
"node_modules/long": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
"license": "Apache-2.0",
"optional": true
},
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -29266,52 +28971,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/node-fetch": {
"version": "2.6.13",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.13.tgz",
"integrity": "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==",
"license": "MIT",
"optional": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT",
"optional": true
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause",
"optional": true
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-forge": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
@@ -33071,13 +32730,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
@@ -33916,7 +33568,7 @@
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/selfsigned": {
@@ -34761,13 +34413,6 @@
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/sql.js": {
"version": "1.14.2",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.2.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "omniroute",
"version": "3.8.50",
"description": "Unified AI router with 343 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
"description": "Unified AI router with 346 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
"type": "module",
"bin": {
"omniroute": "bin/omniroute.mjs",
@@ -342,8 +342,7 @@
"onnxruntime-node": "~1.24.3"
},
"optionalDependencies": {
"@atjsh/llmlingua-2": "2.0.3",
"@tensorflow/tfjs": "4.22.0",
"@atjsh/llmlingua-2": "2.0.5",
"better-sqlite3": "^13.0.2",
"js-tiktoken": "^1.0.20",
"keytar": "^7.9.0",

View File

@@ -6,7 +6,10 @@
* deployment runs `server.js` from that directory directly (not the assembled
* `dist/` bundle). The standalone trace cannot see worker_threads entrypoints
* resolved at runtime, including the required call-log artifact worker and the
* optional LLMLingua-2 worker. It also omits LLMLingua's optional dependencies.
* optional LLMLingua-2 worker (`open-sse/services/compression/engines/llmlingua/onnxWorker.js`,
* dynamically spawned via worker_threads — untraceable by webpack). It also omits
* LLMLingua's optional SLM deps (`@atjsh/llmlingua-2`, `js-tiktoken`) — they are
* optionalDependencies and are only installed at the ROOT `node_modules`.
*
* The call-log worker is required, so a bundle failure must fail the build.
* LLMLingua remains fail-soft when its optional dependencies are absent.

View File

@@ -4,31 +4,32 @@
* OmniRoute — Co-locate the LLMLingua-2 optional dependency closure into the standalone bundle.
*
* The compression "ultra" SLM tier (PR #4257) runs `@atjsh/llmlingua-2` +
* `@huggingface/transformers` + `@tensorflow/tfjs` + `js-tiktoken` inside a worker thread
* `@huggingface/transformers` + `js-tiktoken` inside a worker thread
* (`open-sse/services/compression/engines/llmlingua/onnxWorker.js`, shipped under `dist/`). These
* are `optionalDependencies`: npm installs them into the ROOT `node_modules` on
* `--include=optional`, but the Next.js standalone trace bundles ONLY `@huggingface/transformers`
* (3.5.2, pinned) into `dist/node_modules` — it does NOT trace the optional, dynamically-imported
* (4.2.0, pinned) into `dist/node_modules` — it does NOT trace the optional, dynamically-imported
* SLM packages.
*
* ## Why this matters (the instance-split bug)
*
* The worker lives under `dist/`, so its `import("@huggingface/transformers")` resolves
* `dist/node_modules/@huggingface/transformers` (3.5.2) and the worker sets the model `cacheDir`
* `dist/node_modules/@huggingface/transformers` (4.2.0) and the worker sets the model `cacheDir`
* on THAT instance's `env`. But its `import("@atjsh/llmlingua-2")` walks past `dist/node_modules`
* (no `@atjsh` there) up to the ROOT `node_modules`, and llmlingua-2's own
* `import("@huggingface/transformers")` then resolves the ROOT transformers — a DIFFERENT instance.
* The `cacheDir`/`localModelPath` config the worker set never reaches the instance llmlingua-2
* actually uses, so the local model under `DATA_DIR/models/llmlingua` is never found and the SLM
* tier silently fails-open (no compression). Worse, if the root transformers is a 4.x line,
* llmlingua-2 throws on a tokenizer-API change (`decoder.decode` is undefined).
* tier silently fails-open (no compression). (Before `@atjsh/llmlingua-2@2.0.5` a root
* transformers on the 4.x line also made llmlingua-2 throw on a tokenizer-API change
* — `decoder.decode` is undefined; 2.0.5+ supports both v3 and v4.)
*
* ## The fix
*
* Co-locate the SLM optional dependency CLOSURE from the root `node_modules` into
* `dist/node_modules` (NO-CLOBBER, so the pinned `dist` transformers 3.5.2 / onnxruntime / sharp
* `dist/node_modules` (NO-CLOBBER, so the pinned `dist` transformers 4.2.0 / onnxruntime / sharp
* stay). Then the worker resolves `@atjsh/llmlingua-2` AND `@huggingface/transformers` from the
* SAME `dist/node_modules` — a single 3.5.2 instance — so the env config applies and the local
* SAME `dist/node_modules` — a single 4.2.0 instance — so the env config applies and the local
* model loads.
*
* `@huggingface/transformers` is intentionally NOT a closure seed: it is a PEER of
@@ -54,7 +55,7 @@ import { dirname, join, sep } from "node:path";
* Entry packages of the SLM optional stack (the closure roots). `@huggingface/transformers` is
* deliberately absent — it is the pinned instance already present in `dist/node_modules`.
*/
export const SEED_PACKAGES = ["@atjsh/llmlingua-2", "@tensorflow/tfjs", "js-tiktoken"];
export const SEED_PACKAGES = ["@atjsh/llmlingua-2", "js-tiktoken"];
/**
* Compute the transitive dependency closure of `seeds` by walking each package's `dependencies` +

View File

@@ -402,7 +402,7 @@ runBuildTool(
// The worker is spawned via worker_threads at a path the Next.js bundler cannot
// statically trace, so it must ship as a standalone .js (mirrors the MCP-server
// bundling above). Heavy deps (@atjsh/llmlingua-2 / @huggingface/transformers /
// @tensorflow/tfjs / js-tiktoken) stay EXTERNAL — they are optionalDependencies,
// js-tiktoken) stay EXTERNAL — they are optionalDependencies,
// dynamically imported at runtime, and the worker fail-opens if any is absent.
const llmWorkerSrc = join(
ROOT,

View File

@@ -50,7 +50,6 @@ export const OPTIONAL_PACKS = [
{ name: "@huggingface/transformers" },
{ name: "onnxruntime-node" },
{ name: "@atjsh/llmlingua-2" },
{ name: "@tensorflow/tfjs" },
{ name: "js-tiktoken" },
],
},
@@ -156,7 +155,7 @@ export async function dirChecksum(dir) {
hash.update(String(size));
hash.update("\0");
try {
// Stream to keep memory bounded on multi-hundred-MB packages (tfjs).
// Stream to keep memory bounded on multi-hundred-MB packages (onnxruntime-node).
for await (const chunk of createReadStream(absolute)) hash.update(chunk);
} catch {
hash.update("<unreadable>");

View File

@@ -11,6 +11,13 @@ import { getModelPricing } from "@/lib/playground/types";
import type { ConfigState } from "../StudioConfigPane";
import type { StreamMetrics } from "@/shared/schemas/playground";
import { buildReasoningRequestFields } from "../reasoningControlUtils";
import {
buildNonChatRequestBody,
formatNonChatResponse,
isChatCompletionsEndpoint,
lastUserContent,
resolveChatTabRequestPath,
} from "./chatTabEndpointRequest";
interface Message {
role: "system" | "user" | "assistant";
@@ -127,11 +134,19 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
try {
const fetchHeaders: Record<string, string> = { "Content-Type": "application/json" };
const chatEndpoint = isChatCompletionsEndpoint(configState.endpoint);
const requestBody = chatEndpoint
? buildRequestBody(chatMessages)
: buildNonChatRequestBody(
configState.endpoint,
lastUserContent(chatMessages),
configState.model
);
const res = await fetch("/api/v1/chat/completions", {
const res = await fetch(resolveChatTabRequestPath(configState.endpoint), {
method: "POST",
headers: fetchHeaders,
body: JSON.stringify(buildRequestBody(chatMessages)),
body: JSON.stringify(requestBody),
signal: controller.signal,
});
@@ -150,6 +165,20 @@ export default function ChatTab({ configState, onMetricsUpdate }: ChatTabProps)
return;
}
if (!chatEndpoint) {
const rawText = await res.text();
setMessages((prev) => {
const next = [...prev];
const idx = appendIndex !== undefined ? appendIndex : next.length - 1;
next[idx] = { ...next[idx], content: formatNonChatResponse(rawText) };
return next;
});
setResponseDuration(Date.now() - startTime);
setLoading(false);
streamMetrics.reset();
return;
}
let firstChunk = true;
const reader = res.body?.getReader();
const decoder = new TextDecoder();

View File

@@ -0,0 +1,59 @@
// src/app/(dashboard)/dashboard/playground/components/tabs/chatTabEndpointRequest.ts
//
// #10592 — ChatTab.tsx hardcoded every "Send" click to POST /api/v1/chat/completions,
// ignoring configState.endpoint entirely. Selecting a search-only provider (exa-search,
// tavily-search, serper-search) in the Endpoint selector still sent a chat.completions
// request, which has no notion of search-provider credentials and 404s.
//
// This module gives ChatTab a small, testable seam for routing non-chat endpoints
// (currently "search" and "web.fetch") to their real path with a query-shaped body,
// instead of the chat.completions messages/SSE shape.
import { endpointToPath, type PlaygroundEndpoint } from "@/lib/playground/codeExport";
/** Chat-shaped endpoints keep the existing messages[] + SSE-delta request/response flow. */
export function isChatCompletionsEndpoint(endpoint: PlaygroundEndpoint | undefined): boolean {
return !endpoint || endpoint === "chat.completions";
}
/** Resolves the fetch path (mounted under `/api`) for the selected Playground endpoint. */
export function resolveChatTabRequestPath(endpoint: PlaygroundEndpoint | undefined): string {
return `/api${endpointToPath(endpoint ?? "chat.completions")}`;
}
/**
* Builds the request body for a non-chat endpoint from the user's free-text query.
* "search" and "web.fetch" both take a single string field instead of a messages array.
*/
export function buildNonChatRequestBody(
endpoint: PlaygroundEndpoint | undefined,
query: string,
model: string
): Record<string, unknown> {
if (endpoint === "web.fetch") {
return { url: query };
}
const body: Record<string, unknown> = { query };
if (model) body.model = model;
return body;
}
/** Renders a non-chat endpoint's raw response text as a chat-bubble-friendly string. */
export function formatNonChatResponse(rawText: string): string {
try {
const parsed = JSON.parse(rawText) as unknown;
return "```json\n" + JSON.stringify(parsed, null, 2) + "\n```";
} catch {
return rawText;
}
}
/** Finds the most recent user-authored message content to use as a non-chat query. */
export function lastUserContent(
chatMessages: Array<{ role: string; content: string }>
): string {
for (let i = chatMessages.length - 1; i >= 0; i--) {
if (chatMessages[i].role === "user") return chatMessages[i].content;
}
return "";
}

View File

@@ -19,7 +19,11 @@ import * as log from "@/sse/utils/logger";
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import { v1SearchSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import {
formatValidationMessage,
isValidationFailure,
validateBody,
} from "@/shared/validation/helpers";
import { recordCost } from "@/domain/costRules";
import {
computeCacheKey,
@@ -120,7 +124,7 @@ async function postHandler(request: Request, context: unknown) {
const validation = validateBody(v1SearchSchema, rawBody);
if (isValidationFailure(validation)) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message);
return errorResponse(HTTP_STATUS.BAD_REQUEST, formatValidationMessage(validation.error));
}
const body = validation.data;

View File

@@ -44,6 +44,13 @@ import { getAntigravityQuotaFamily } from "@omniroute/open-sse/services/antigrav
interface QuotaInfo {
remainingPercentage: number;
resetAt: string | null;
// #10095 — upstream explicitly told us it did NOT report this window's
// fraction (e.g. a fresh Antigravity account or a newly-launched
// -tiered model id Google hasn't wired quota telemetry for yet).
// `undefined`/`true` means the value is a real, upstream-reported
// percentage; `false` means "unknown", so callers must not treat the
// defaulted-to-0 `remainingPercentage` as genuine exhaustion.
fractionReported?: boolean;
}
interface QuotaCacheEntry {
@@ -113,7 +120,10 @@ const MAX_CONCURRENT_REFRESHES = 5;
function isExhausted(quotas: Record<string, QuotaInfo>): boolean {
const entries = Object.values(quotas);
if (entries.length === 0) return false;
return entries.every((q) => q.remainingPercentage <= 0);
// #10095 — a window whose fraction was never reported by upstream must
// never single-handedly flip the whole connection to exhausted; treat it
// as available (mirrors the guard in genericQuotaFetcher.ts).
return entries.every((q) => q.fractionReported !== false && q.remainingPercentage <= 0);
}
/**
@@ -237,6 +247,9 @@ function normalizeQuotas(rawQuotas: Record<string, any>): Record<string, QuotaIn
safePercentage(q.remainingPercentage) ??
(q.total > 0 ? Math.round(((q.total - (q.used || 0)) / q.total) * 100) : 0),
resetAt: q.resetAt || null,
// #10095 — thread through the "did upstream actually report this
// window's fraction" signal (see UsageQuota in usage/quota.ts).
fractionReported: q.fractionReported === false ? false : undefined,
};
}
}
@@ -641,11 +654,14 @@ export function getQuotaWindowStatus(
usedPercentage,
resetAt,
// If reset time has already passed, avoid stale cached percentages blocking selection.
reachedThreshold: windowExpired
? false
: remainingPercentage <= 0
? true
: usedPercentage >= thresholdPercent,
// #10095 — a window whose fraction upstream never reported is "unknown",
// not "0% remaining"; never let it reach the exhaustion threshold.
reachedThreshold:
windowExpired || window.fractionReported === false
? false
: remainingPercentage <= 0
? true
: usedPercentage >= thresholdPercent,
};
}

View File

@@ -26,7 +26,11 @@ import {
isBcryptHash,
verifyManagementPassword,
} from "@/lib/auth/managementPassword";
import { webSessionCredentialKey, parseProviderSpecificData } from "./webSessionDedup";
import {
webSessionCredentialKey,
parseProviderSpecificData,
isMatchingOauthIdentity,
} from "./webSessionDedup";
import { pickCodexConnectionForUser } from "@/lib/oauth/utils/codexConnectionSelection";
import { reconcileCodexUsageHistory } from "./providers/usageIdentityReconciliation";
@@ -435,30 +439,25 @@ export async function createProviderConnection(data: JsonRecord) {
}
} else {
// For other providers (or Codex without workspaceId), match on email —
// disambiguated by providerSpecificData.username when present on both
// sides. Two different IdPs can share the same email address (e.g. a
// Google account and a HuggingFace account); matching on email alone
// would silently overwrite the other account's connection on the
// second login. Only fall back to the bare email-only match when
// neither side carries a username (legacy rows created before this
// disambiguation existed).
// disambiguated by providerSpecificData.username and/or
// providerSpecificData.profileArn when present on both sides. Two
// different IdPs (or two distinct Kiro/AWS profiles authenticated via
// the same email-carrying IdP) can share the same email address;
// matching on email alone would silently overwrite the other
// account's connection on the second login. Only fall back to the
// bare email-only match when neither side carries a username/profileArn
// (legacy rows created before this disambiguation existed).
const incomingUsername = toStringOrNull(providerSpecificData.username);
const incomingProfileArn = toStringOrNull(providerSpecificData.profileArn);
const emailMatches = db
.prepare(
"SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND email = ?"
)
.all(data.provider, data.email) as JsonRecord[];
existing =
emailMatches.find((row) => {
const existingUsername = toStringOrNull(
parseProviderSpecificData(row.provider_specific_data)?.username
);
if (incomingUsername && existingUsername) {
return incomingUsername === existingUsername;
}
if (incomingUsername || existingUsername) return false;
return true;
}) || null;
emailMatches.find((row) =>
isMatchingOauthIdentity(row, incomingUsername, incomingProfileArn)
) || null;
}
} else if (data.authType === "apikey") {
// Name-based upsert (existing behavior): same provider + same name → update.

View File

@@ -55,3 +55,43 @@ export function parseProviderSpecificData(raw: unknown): Record<string, unknown>
}
return null;
}
/** Trimmed non-empty string, else null — local to avoid a cross-module import for one coercion. */
function nonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
/**
* Two-sided disambiguator match: `true` when both sides agree, `false` when
* both carry a value and it differs, `undefined` when the field can't decide
* (at most one side carries it) — the caller then defers to other fields.
*/
function fieldMatch(incoming: string | null, existing: string | null): boolean | undefined {
if (incoming && existing) return incoming === existing;
if (incoming || existing) return false;
return undefined;
}
/**
* Decide whether `row` (an existing `provider_connections` record) is the
* same OAuth identity as an incoming connection carrying `incomingUsername`
* and `incomingProfileArn` (#10815).
*
* Two independent disambiguators, either of which can prove "different
* account": `providerSpecificData.username` (Raycast-style IdP dedup) and
* `providerSpecificData.profileArn` (Kiro/AWS profile dedup — Kiro never
* sets `username`). A field only rules a match IN/OUT when both the
* incoming and existing record carry it; when neither carries either field
* the legacy bare-email match still applies unchanged.
*/
export function isMatchingOauthIdentity(
row: { provider_specific_data?: unknown },
incomingUsername: string | null,
incomingProfileArn: string | null
): boolean {
const existingPsd = parseProviderSpecificData(row.provider_specific_data);
const usernameMatch = fieldMatch(incomingUsername, nonEmptyString(existingPsd?.username));
const profileArnMatch = fieldMatch(incomingProfileArn, nonEmptyString(existingPsd?.profileArn));
if (usernameMatch === false || profileArnMatch === false) return false;
return true;
}

View File

@@ -36,6 +36,7 @@ export const PROVIDER_ENDPOINTS = {
"free-ai": "https://api.free.ai/v1/chat/",
"void-ai": "https://api.voidai.app/v1/chat/completions",
helixmind: "https://helixmind.online/v1/chat/completions",
tabitoken: "https://tabitoken.com/v1/messages",
glm: "https://api.z.ai/api/anthropic/v1/messages",
glmt: "https://api.z.ai/api/anthropic/v1/messages",
"bailian-coding-plan":

View File

@@ -146,6 +146,7 @@ export const AGGREGATOR_PROVIDER_IDS = new Set([
"free-ai",
"void-ai",
"helixmind",
"tabitoken",
]);
export const ENTERPRISE_CLOUD_PROVIDER_IDS = new Set([

View File

@@ -1264,4 +1264,19 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
apiHint:
"Create a helix- key and use https://helixmind.online/v1. OpenAI requests use Bearer authentication; the Anthropic-compatible messages endpoint accepts x-api-key.",
},
// TabiToken (https://tabitoken.com) — NewAPI-based Claude gateway. Its public pricing
// endpoint lists a Claude-only catalog (Opus 5 / 4.8, each with a -thinking variant),
// every model accepting the Anthropic and OpenAI protocols.
tabitoken: {
id: "tabitoken",
alias: "tabitoken",
name: "TabiToken",
icon: "hub",
color: "#F97316",
textIcon: "TT",
passthroughModels: true,
website: "https://tabitoken.com",
apiHint:
"Create an sk- key at https://tabitoken.com and use https://tabitoken.com. The Anthropic-compatible /v1/messages endpoint (default) takes x-api-key; /v1/chat/completions takes Bearer.",
},
};

View File

@@ -56,6 +56,19 @@ export function isValidationFailure<TData>(
return validation.success === false;
}
/**
* Build a human-readable 400 message from a validation failure, naming the
* first offending field instead of the generic "Invalid request" (#10849).
* Intended for routes that reply with a single message string (e.g.
* `errorResponse()`) rather than the full `{ message, details }` envelope
* returned by `validatedJsonBody()`.
*/
export function formatValidationMessage(error: ValidationErrorPayload): string {
const [first] = error.details;
if (!first) return error.message;
return first.field ? `${first.field}: ${first.message}` : first.message;
}
/**
* Result of attempting to parse and validate a JSON body against a Zod schema.
*

View File

@@ -568,27 +568,16 @@ export const v1SearchSchema = z
.trim()
.min(1, "Query is required")
.max(500, "Query must be 500 characters or fewer"),
provider: z
.enum([
"serper-search",
"brave-search",
"perplexity-search",
"exa-search",
"tavily-search",
"firecrawl",
"google-pse-search",
"linkup-search",
"ollama-search",
"searchapi-search",
"youcom-search",
"searxng-search",
"zai-search",
"jina-search",
"jina-ai",
"jina",
"duckduckgo-free",
])
.optional(),
// Not a z.enum: the runtime catalog (SEARCH_PROVIDERS + SEARCH_PROVIDER_ALIASES in
// open-sse/config/searchRegistry.ts) is the source of truth via resolveSearchProvider(),
// which already returns a named "Unknown search provider: <id>" error for bad ids (see
// src/app/api/v1/search/route.ts). A hard-coded enum here would 400 before that check
// ever runs, hiding the informative message behind a generic Zod failure (#10849).
// Known catalog ids as of this writing: serper-search, brave-search, perplexity-search,
// exa-search, tavily-search, firecrawl, google-pse-search, linkup-search, ollama-search,
// searchapi-search, youcom-search, searxng-search, zai-search, jina-search, jina-ai,
// jina, duckduckgo-free (plus short aliases resolved by SEARCH_PROVIDER_ALIASES).
provider: z.string().min(1).optional(),
max_results: z.coerce.number().int().min(1).max(100).default(5),
search_type: z.enum(["web", "news"]).default("web"),
offset: z.coerce.number().int().min(0).default(0),

View File

@@ -5368,6 +5368,32 @@
"stream": "https://t3.chat/api/chat"
}
},
"tabitoken": {
"format": "claude",
"headers": {
"apiKey": {
"Accept": "text/event-stream",
"Anthropic-Version": "2023-06-01",
"Content-Type": "application/json",
"x-api-key": "<CRED>"
},
"nonStream": {
"Anthropic-Version": "2023-06-01",
"Content-Type": "application/json",
"x-api-key": "<CRED>"
},
"oauth": {
"Accept": "text/event-stream",
"Anthropic-Version": "2023-06-01",
"Content-Type": "application/json",
"x-api-key": "<CRED>"
}
},
"url": {
"nonStream": "https://tabitoken.com/v1/messages",
"stream": "https://tabitoken.com/v1/messages"
}
},
"tencent": {
"format": "openai",
"headers": {

View File

@@ -29,10 +29,9 @@ function mkPkg(
/**
* Build a root tree mirroring the real SLM optional shape:
* @atjsh/llmlingua-2 → dep es-toolkit, PEER @huggingface/transformers (+ tfjs, js-tiktoken)
* @tensorflow/tfjs → dep @tensorflow/tfjs-core → dep long
* @atjsh/llmlingua-2 → dep es-toolkit, PEER @huggingface/transformers (+ js-tiktoken)
* js-tiktoken → dep base64-js
* @huggingface/transformers present at root as a (stale) 4.2.0
* @huggingface/transformers present at root as a (hypothetical future) 5.0.0
*
* Each mock package gets a resolvable entrypoint so that isPackageIntact (which
* checks entrypoint integrity via require.resolve) can validate the co-located
@@ -49,26 +48,12 @@ function buildRoot(rootDir: string): void {
dependencies: { "es-toolkit": "^1.38.0" },
peerDependencies: {
"@huggingface/transformers": "*",
"@tensorflow/tfjs": "*",
"js-tiktoken": "*",
},
},
{ "dist/index.js": "export const llmlingua = true;\n" }
);
mkPkg(rootNm, "es-toolkit", { main: "index.js" }, { "index.js": "export const esToolkit = true;\n" });
mkPkg(
rootNm,
"@tensorflow/tfjs",
{ main: "index.js", dependencies: { "@tensorflow/tfjs-core": "4.22.0" } },
{ "index.js": "export const tfjs = true;\n" }
);
mkPkg(
rootNm,
"@tensorflow/tfjs-core",
{ main: "index.js", dependencies: { long: "^5.0.0" } },
{ "index.js": "export const tfjsCore = true;\n" }
);
mkPkg(rootNm, "long", { main: "index.js" }, { "index.js": "export const long = true;\n" });
mkPkg(
rootNm,
"js-tiktoken",
@@ -76,8 +61,8 @@ function buildRoot(rootDir: string): void {
{ "index.js": "export const tiktoken = true;\n" }
);
mkPkg(rootNm, "base64-js", { main: "index.js" }, { "index.js": "export const base64 = true;\n" });
// Root transformers is the STALE 4.x line — the bug we must not propagate into dist.
mkPkg(rootNm, "@huggingface/transformers", { version: "4.2.0" });
// Root transformers is a hypothetical FUTURE line — the version we must not propagate into dist.
mkPkg(rootNm, "@huggingface/transformers", { version: "5.0.0" });
}
test("computeDependencyClosure walks deps transitively and skips peers (transformers)", () => {
@@ -88,11 +73,8 @@ test("computeDependencyClosure walks deps transitively and skips peers (transfor
for (const expected of [
"@atjsh/llmlingua-2",
"@tensorflow/tfjs",
"js-tiktoken",
"es-toolkit",
"@tensorflow/tfjs-core",
"long",
"base64-js",
]) {
assert.ok(closure.includes(expected), `closure should include ${expected}`);
@@ -111,23 +93,20 @@ test("colocateLlmlinguaOptionals copies the closure into dist and never clobbers
const root = mkdtempSync(join(tmpdir(), "omniroute-colocate-copy-"));
try {
buildRoot(root);
// dist already ships the PINNED transformers (3.5.2) — must survive untouched.
// dist already ships the PINNED transformers (4.2.0) — must survive untouched.
const distNm = join(root, "dist", "node_modules");
mkPkg(distNm, "@huggingface/transformers", { version: "3.5.2" });
mkPkg(distNm, "@huggingface/transformers", { version: "4.2.0" });
const result = colocateLlmlinguaOptionals({ rootDir: root });
assert.equal(result.skipped, false);
if (result.skipped === false) {
assert.ok(result.copied >= 6, `expected >=6 packages copied, got ${result.copied}`);
assert.ok(result.copied >= 4, `expected >=4 packages copied, got ${result.copied}`);
}
// Full closure landed in dist/node_modules.
for (const name of [
"@atjsh/llmlingua-2",
"es-toolkit",
"@tensorflow/tfjs",
"@tensorflow/tfjs-core",
"long",
"js-tiktoken",
"base64-js",
]) {
@@ -136,11 +115,11 @@ test("colocateLlmlinguaOptionals copies the closure into dist and never clobbers
// The package payload came along (not just the manifest).
assert.ok(existsSync(join(distNm, "@atjsh", "llmlingua-2", "dist", "index.js")));
// CRITICAL: dist's pinned transformers is preserved — root's 4.2.0 must NOT win.
// CRITICAL: dist's pinned transformers is preserved — root's 5.0.0 must NOT win.
const distTransformers = JSON.parse(
readFileSync(join(distNm, "@huggingface", "transformers", "package.json"), "utf8")
);
assert.equal(distTransformers.version, "3.5.2", "dist transformers must remain 3.5.2");
assert.equal(distTransformers.version, "4.2.0", "dist transformers must remain 4.2.0");
} finally {
rmSync(root, { recursive: true, force: true });
}
@@ -150,7 +129,7 @@ test("colocateLlmlinguaOptionals is idempotent (second run is a no-op)", () => {
const root = mkdtempSync(join(tmpdir(), "omniroute-colocate-idem-"));
try {
buildRoot(root);
mkPkg(join(root, "dist", "node_modules"), "@huggingface/transformers", { version: "3.5.2" });
mkPkg(join(root, "dist", "node_modules"), "@huggingface/transformers", { version: "4.2.0" });
const first = colocateLlmlinguaOptionals({ rootDir: root });
assert.equal(first.skipped, false);
@@ -169,7 +148,7 @@ test("colocateLlmlinguaOptionals skips when SLM optionals are not installed", ()
const root = mkdtempSync(join(tmpdir(), "omniroute-colocate-noopt-"));
try {
// dist bundle exists, but the optional seeds were never installed at root.
mkPkg(join(root, "dist", "node_modules"), "@huggingface/transformers", { version: "3.5.2" });
mkPkg(join(root, "dist", "node_modules"), "@huggingface/transformers", { version: "4.2.0" });
mkdirSync(join(root, "node_modules"), { recursive: true });
const result = colocateLlmlinguaOptionals({ rootDir: root });
@@ -208,7 +187,7 @@ test("colocateLlmlinguaOptionals fills a Next-traced stub (package.json only, no
try {
buildRoot(root);
const distNm = join(root, "dist", "node_modules");
mkPkg(distNm, "@huggingface/transformers", { version: "3.5.2" });
mkPkg(distNm, "@huggingface/transformers", { version: "4.2.0" });
// Simulate the Next-traced stub: directory exists, package.json only.
const stubDir = join(distNm, "@atjsh", "llmlingua-2");
@@ -233,5 +212,5 @@ test("colocateLlmlinguaOptionals fills a Next-traced stub (package.json only, no
test("SEED_PACKAGES excludes transformers (it is a dist-pinned peer, not a seed)", () => {
assert.ok(!SEED_PACKAGES.includes("@huggingface/transformers"));
assert.deepEqual(SEED_PACKAGES, ["@atjsh/llmlingua-2", "@tensorflow/tfjs", "js-tiktoken"]);
assert.deepEqual(SEED_PACKAGES, ["@atjsh/llmlingua-2", "js-tiktoken"]);
});

View File

@@ -0,0 +1,91 @@
/**
* #10597 — When a combo target fails with a non-2xx status, the per-target
* "Model X failed, trying next" COMBO log line only carries `{ status }` —
* the upstream error BODY (e.g. Anthropic's "prompt is too long" or a
* tool_use/tool_result pairing 400) is captured in `errorText` but never
* logged, so operators cannot distinguish failure causes from server logs
* without reproducing the request.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-10597-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-10597-test-secret";
const { handleComboChat } = await import("../../open-sse/services/combo.ts");
const DISTINCTIVE_ERROR_TEXT =
"messages.450: `tool_use` ids were found without `tool_result` blocks immediately after";
type WarnCall = { tag: string; msg: string; meta: unknown };
const warnCalls: WarnCall[] = [];
const log = {
info: () => {},
debug: () => {},
error: () => {},
warn: (tag: string, msg: string, meta?: unknown) => {
warnCalls.push({ tag, msg, meta });
},
};
function failing400() {
return new Response(
JSON.stringify({
type: "error",
error: { type: "invalid_request_error", message: DISTINCTIVE_ERROR_TEXT },
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
function healthy200(model: string) {
return new Response(
JSON.stringify({
id: "ok",
object: "chat.completion",
model,
choices: [{ index: 0, message: { role: "assistant", content: "hello from " + model }, finish_reason: "stop" }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
function makeCombo(models: string[]) {
return { name: "test-combo-10597", strategy: "priority", models: models.map((m) => ({ model: m })) };
}
test("#10597 COMBO failure log must surface the upstream error body, not just the status code", async () => {
const modelsCalled: string[] = [];
const handleSingleModel = async (_body: unknown, modelStr: string) => {
modelsCalled.push(modelStr);
if (modelsCalled.length === 1) return failing400();
return healthy200(modelStr);
};
const result = await handleComboChat({
body: { model: "test", messages: [{ role: "user", content: "hi" }] },
combo: makeCombo(["claude/claude-opus-4-8", "openai/gpt-4o-mini"]),
handleSingleModel,
log,
settings: {},
allCombos: [],
});
assert.equal(result.status, 200);
assert.equal(modelsCalled.length, 2);
const failureLog = warnCalls.find(
(c) => typeof c.msg === "string" && c.msg.includes("claude/claude-opus-4-8") && c.msg.includes("failed")
);
assert.ok(failureLog, "expected a COMBO warn log for the failing leg");
const serialized = JSON.stringify(failureLog);
assert.ok(
serialized.includes("tool_use") || serialized.includes(DISTINCTIVE_ERROR_TEXT),
`expected the upstream error body to appear in the COMBO failure log, but got: ${serialized}`
);
});

View File

@@ -1,8 +1,8 @@
/**
* Tests for the real LLMLingua worker-thread backend (`worker.ts` + `onnxWorker.ts`).
*
* The four optional deps (`@atjsh/llmlingua-2`, `@huggingface/transformers`,
* `@tensorflow/tfjs`, `js-tiktoken`) are NOT installed in this worktree, so the
* The three optional deps (`@atjsh/llmlingua-2`, `@huggingface/transformers`,
* `js-tiktoken`) are NOT installed in this worktree, so the
* default path MUST fail-open WITHOUT spawning a worker:
*
* 1. Deps absent → fail-open, no spawn (ALWAYS runs here): the backend returns the
@@ -23,12 +23,11 @@ import {
const require = createRequire(import.meta.url);
/** Whether all four optional deps resolve in this environment. */
/** Whether all three optional deps resolve in this environment. */
function depsResolve(): boolean {
try {
require.resolve("@atjsh/llmlingua-2");
require.resolve("@huggingface/transformers");
require.resolve("@tensorflow/tfjs");
require.resolve("js-tiktoken");
return true;
} catch {

View File

@@ -70,7 +70,8 @@ describe("RTK compression engine", () => {
assert.equal(rtkEngine.validateConfig({ intensity: "invalid" }).valid, false);
assert.equal(rtkEngine.validateConfig({ rawOutputRetention: "always" }).valid, true);
const body = { messages: [{ role: "tool", content: "same\nsame\nsame\nsame" }] };
const repeated = Array.from({ length: 20 }, () => "same").join("\n");
const body = { messages: [{ role: "tool", content: repeated }] };
assert.equal(
rtkEngine.apply(body, { config: { rtkConfig: { enabled: true } } }).stats?.engine,
"rtk"

View File

@@ -16,7 +16,6 @@ import { assembleStandalone } from "../../scripts/build/assembleStandalone.mjs";
const REQUIRED_RUNTIME_PACKAGES = [
"@atjsh/llmlingua-2",
"@huggingface/transformers",
"@tensorflow/tfjs",
"js-tiktoken",
];
@@ -47,7 +46,7 @@ function mkPkg(
function buildLlmlinguaRoot(
rootDir: string,
transformersVersion = "3.5.2"
transformersVersion = "4.2.0"
): void {
const rootNm = join(rootDir, "node_modules");
@@ -61,7 +60,6 @@ function buildLlmlinguaRoot(
},
peerDependencies: {
"@huggingface/transformers": "*",
"@tensorflow/tfjs": "*",
"js-tiktoken": "*",
},
},
@@ -72,18 +70,6 @@ function buildLlmlinguaRoot(
mkPkg(rootNm, "es-toolkit");
mkPkg(rootNm, "@tensorflow/tfjs", {
dependencies: {
"@tensorflow/tfjs-core": "4.22.0",
},
});
mkPkg(rootNm, "@tensorflow/tfjs-core", {
dependencies: {
long: "^5.0.0",
},
});
mkPkg(rootNm, "long");
mkPkg(rootNm, "js-tiktoken", {
dependencies: {
"base64-js": "^1.5.1",
@@ -138,8 +124,6 @@ test("#9166 standalone assembly includes the complete LLMLingua runtime closure"
for (const packageName of [
...REQUIRED_RUNTIME_PACKAGES,
"es-toolkit",
"@tensorflow/tfjs-core",
"long",
"base64-js",
"onnxruntime-node",
]) {
@@ -175,14 +159,14 @@ test("#9166 standalone assembly never overwrites an already pinned transformers
);
try {
buildLlmlinguaRoot(root, "4.2.0");
buildLlmlinguaRoot(root, "5.0.0");
const { distDir, standaloneDir } = createStandalone(root);
mkPkg(
join(standaloneDir, "node_modules"),
"@huggingface/transformers",
{
version: "3.5.2",
version: "4.2.0",
}
);
@@ -208,7 +192,7 @@ test("#9166 standalone assembly never overwrites an already pinned transformers
assert.equal(
targetManifest.version,
"3.5.2",
"4.2.0",
"standalone's pinned transformers version must not be overwritten"
);
@@ -286,9 +270,6 @@ test("#9166 co-location is not skipped when every closure dir exists but one is
// llmlingua-2 one is the partial NFT-trace shell without its main.
for (const packageName of [
"es-toolkit",
"@tensorflow/tfjs",
"@tensorflow/tfjs-core",
"long",
"js-tiktoken",
"base64-js",
"@huggingface/transformers",

View File

@@ -12,8 +12,13 @@
import test from "node:test";
import assert from "node:assert/strict";
const { SEARCH_PROVIDERS, SEARCH_CREDENTIAL_FALLBACKS, getSearchProvider, selectProvider } =
await import("../../open-sse/config/searchRegistry.ts");
const {
SEARCH_PROVIDERS,
SEARCH_CREDENTIAL_FALLBACKS,
getSearchProvider,
selectProvider,
resolveSearchProvider,
} = await import("../../open-sse/config/searchRegistry.ts");
const { handleSearch } = await import("../../open-sse/handlers/search.ts");
const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts");
@@ -54,8 +59,18 @@ test("v1SearchSchema accepts firecrawl for search (unified id)", () => {
search_type: "news",
});
assert.equal(news.success, true);
// #10849: v1SearchSchema.provider is a free-form string, not a hard-coded enum, so
// the runtime catalog (resolveSearchProvider()) is the source of truth for whether an
// id is valid — the legacy "firecrawl-search" id is still rejected, just downstream of
// the schema (route.ts replies "Unknown search provider: firecrawl-search") instead of
// by an opaque schema-level 400.
const legacy = v1SearchSchema.safeParse({ query: "q", provider: "firecrawl-search" });
assert.equal(legacy.success, false, "legacy firecrawl-search id is not accepted");
assert.equal(legacy.success, true, "provider is a free-form string at the schema layer");
assert.equal(
resolveSearchProvider("firecrawl-search"),
null,
"legacy firecrawl-search id does not resolve to a registered provider"
);
});
test("handleSearch firecrawl hits /v2/search with sources web and normalizes data.web", async () => {

View File

@@ -0,0 +1,115 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-kiro-10815-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
test.after(async () => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("createProviderConnection keeps two Kiro oauth connections with the same email but different profileArn separate (#10815)", async () => {
const first = await providersDb.createProviderConnection({
provider: "kiro",
authType: "oauth",
email: "user@example.com",
accessToken: "token-account-1",
refreshToken: "refresh-account-1",
providerSpecificData: {
authMethod: "imported",
provider: "Google",
profileArn: "arn:aws:codewhisperer:us-east-1:111111111111:profile/AAAA",
},
});
const second = await providersDb.createProviderConnection({
provider: "kiro",
authType: "oauth",
email: "user@example.com",
accessToken: "token-account-2",
refreshToken: "refresh-account-2",
providerSpecificData: {
authMethod: "imported",
provider: "Google",
profileArn: "arn:aws:codewhisperer:us-east-1:222222222222:profile/BBBB",
},
});
const kiroConnections = await providersDb.getProviderConnections({ provider: "kiro" });
assert.notEqual(
second.id,
first.id,
"second Kiro connection should be a new row, not an update of the first"
);
assert.equal(
kiroConnections.length,
2,
`expected 2 Kiro connections after adding a second account, got ${kiroConnections.length}`
);
});
test("createProviderConnection re-auth of the SAME Kiro profileArn still updates in place (#10815)", async () => {
const first = await providersDb.createProviderConnection({
provider: "kiro",
authType: "oauth",
email: "same-profile@example.com",
accessToken: "token-a",
refreshToken: "refresh-a",
providerSpecificData: {
authMethod: "imported",
provider: "Google",
profileArn: "arn:aws:codewhisperer:us-east-1:333333333333:profile/CCCC",
},
});
const reauth = await providersDb.createProviderConnection({
provider: "kiro",
authType: "oauth",
email: "same-profile@example.com",
accessToken: "token-a-refreshed",
refreshToken: "refresh-a-refreshed",
providerSpecificData: {
authMethod: "imported",
provider: "Google",
profileArn: "arn:aws:codewhisperer:us-east-1:333333333333:profile/CCCC",
},
});
assert.equal(
reauth.id,
first.id,
"re-auth of the same profileArn should update the existing row"
);
});
test("createProviderConnection keeps legacy email-only OAuth dedup for rows without profileArn/username (#10815)", async () => {
const first = await providersDb.createProviderConnection({
provider: "google",
authType: "oauth",
email: "legacy@example.com",
accessToken: "legacy-token-1",
refreshToken: "legacy-refresh-1",
});
const second = await providersDb.createProviderConnection({
provider: "google",
authType: "oauth",
email: "legacy@example.com",
accessToken: "legacy-token-2",
refreshToken: "legacy-refresh-2",
});
assert.equal(
second.id,
first.id,
"legacy rows without profileArn/username should still dedup by bare email match"
);
});

View File

@@ -0,0 +1,267 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-minimax-music-"));
const { handleMusicGeneration } = await import("../../open-sse/handlers/musicGeneration.ts");
const { MUSIC_PROVIDERS } = await import("../../open-sse/config/musicRegistry.ts");
const GLOBAL_ENDPOINT = MUSIC_PROVIDERS.minimax.baseUrl;
const REGIONAL_ENDPOINT = MUSIC_PROVIDERS.minimax.regionalBaseUrl as string;
interface Captured {
url: string;
authorization: string;
contentType: string;
body: Record<string, unknown>;
}
/** Installs a fetch stub answering every call with `payload`, capturing the request. */
function stubFetch(payload: unknown, status = 200) {
const captured: Captured[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request, options: RequestInit = {}) => {
const headers = new Headers(options.headers ?? {});
captured.push({
url: String(url),
authorization: headers.get("authorization") ?? "",
contentType: headers.get("content-type") ?? "",
body: JSON.parse(String(options.body ?? "{}")) as Record<string, unknown>,
});
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json" },
});
}) as typeof globalThis.fetch;
return {
captured,
restore() {
globalThis.fetch = originalFetch;
},
};
}
test("minimax is registered with the music models it serves and no query endpoint", () => {
const provider = MUSIC_PROVIDERS.minimax;
const modelIds = provider.models.map((model) => model.id);
assert.equal(provider.format, "minimax-music");
assert.equal(provider.authHeader, "bearer");
assert.equal(provider.statusUrl, undefined);
assert.ok(REGIONAL_ENDPOINT, "a regional endpoint must be declared");
assert.notEqual(new URL(REGIONAL_ENDPOINT).host, new URL(GLOBAL_ENDPOINT).host);
assert.deepEqual(modelIds, [
"music-3.0",
"music-2.6",
"music-3.0-free",
"music-2.6-free",
"music-cover",
"music-cover-free",
]);
});
test("handleMusicGeneration dispatches minimax-music and normalizes the audio URL", async () => {
const stub = stubFetch({
data: { status: 2, audio: "https://example.com/minimax-music.mp3" },
base_resp: { status_code: 0, status_msg: "success" },
});
try {
const result = await handleMusicGeneration({
body: {
model: "minimax/music-3.0",
prompt: "warm lo-fi guitar loop",
lyrics: "##first line\nsecond line##",
is_instrumental: false,
lyrics_optimizer: true,
audio_setting: { format: "wav", sample_rate: 44100, bitrate: 256000, bogus: "drop-me" },
aigc_watermark: true,
},
credentials: { apiKey: "minimax-key" },
log: null,
});
assert.equal(stub.captured.length, 1);
const request = stub.captured[0];
assert.equal(request.url, GLOBAL_ENDPOINT);
assert.equal(request.authorization, "Bearer minimax-key");
assert.equal(request.contentType, "application/json");
assert.equal(request.body.model, "music-3.0");
assert.equal(request.body.prompt, "warm lo-fi guitar loop");
assert.equal(request.body.lyrics, "##first line\nsecond line##");
assert.equal(request.body.stream, false);
assert.equal(request.body.output_format, "url");
assert.equal(request.body.is_instrumental, false);
assert.equal(request.body.lyrics_optimizer, true);
assert.deepEqual(request.body.audio_setting, {
sample_rate: 44100,
bitrate: 256000,
format: "wav",
});
// The watermark field only exists on the regional endpoint.
assert.ok(!("aigc_watermark" in request.body));
assert.equal(result.success, true);
assert.deepEqual(result.data.data, [
{ url: "https://example.com/minimax-music.mp3", format: "wav" },
]);
} finally {
stub.restore();
}
});
test("minimax-music forwards cover inputs and honors the hex output format", async () => {
const stub = stubFetch({
data: { status: 2, audio: "48656c6c6f" },
base_resp: { status_code: 0 },
});
try {
const result = await handleMusicGeneration({
body: {
model: "minimax/music-cover",
prompt: "cover this take",
output_format: "HEX",
audio_url: "https://example.com/reference.mp3",
cover_feature_id: "feature-1",
},
credentials: { accessToken: "minimax-token" },
log: null,
});
const request = stub.captured[0];
assert.equal(request.body.model, "music-cover");
assert.equal(request.body.output_format, "hex");
assert.equal(request.body.audio_url, "https://example.com/reference.mp3");
assert.equal(request.body.cover_feature_id, "feature-1");
assert.equal(result.success, true);
assert.deepEqual(result.data.data, [
{ b64_json: Buffer.from("Hello").toString("base64"), format: "mp3" },
]);
} finally {
stub.restore();
}
});
test("minimax-music targets the regional endpoint via the connection base URL", async () => {
const stub = stubFetch({
data: { status: 2, audio: "https://example.com/regional.mp3" },
base_resp: { status_code: 0 },
});
try {
const result = await handleMusicGeneration({
body: { model: "minimax/music-2.6", prompt: "guzheng ballad", aigc_watermark: true },
credentials: {
apiKey: "minimax-key",
providerSpecificData: { baseUrl: REGIONAL_ENDPOINT },
},
log: null,
});
const request = stub.captured[0];
assert.equal(request.url, REGIONAL_ENDPOINT);
assert.equal(request.body.aigc_watermark, true);
assert.equal(result.success, true);
} finally {
stub.restore();
}
});
test("minimax-music surfaces base_resp failures returned with HTTP 200", async () => {
const stub = stubFetch({ base_resp: { status_code: 1004, status_msg: "invalid api key" } });
try {
const logged: string[] = [];
const result = await handleMusicGeneration({
body: { model: "minimax/music-3.0", prompt: "x" },
credentials: { apiKey: "minimax-key" },
log: { info: () => {}, error: (_scope: string, message: string) => logged.push(message) },
});
assert.equal(result.success, false);
assert.equal(result.status, 502);
assert.equal(result.error, "invalid api key");
assert.equal(logged.length, 1);
} finally {
stub.restore();
}
});
test("minimax-music reports an unfinished generation instead of polling", async () => {
const stub = stubFetch({ data: { status: 1 }, base_resp: { status_code: 0 } });
try {
const result = await handleMusicGeneration({
body: { model: "minimax/music-3.0-free", prompt: "x" },
credentials: { apiKey: "minimax-key" },
log: null,
});
assert.equal(result.success, false);
assert.equal(result.status, 502);
assert.match(result.error, /still in progress/);
} finally {
stub.restore();
}
});
test("minimax-music rejects a completed response that carries no audio", async () => {
const stub = stubFetch({ data: { status: 2 }, base_resp: { status_code: 0 } });
try {
const result = await handleMusicGeneration({
body: { model: "minimax/music-3.0", prompt: "x" },
credentials: { apiKey: "minimax-key" },
log: null,
});
assert.equal(result.success, false);
assert.equal(result.status, 502);
assert.match(result.error, /returned no audio/);
} finally {
stub.restore();
}
});
test("minimax-music propagates upstream HTTP failures", async () => {
const stub = stubFetch({ base_resp: { status_code: 2013, status_msg: "invalid params" } }, 400);
try {
const result = await handleMusicGeneration({
body: { model: "minimax/music-3.0", prompt: "x" },
credentials: { apiKey: "minimax-key" },
log: null,
});
assert.equal(result.success, false);
assert.equal(result.status, 400);
assert.equal(result.error, "invalid params");
} finally {
stub.restore();
}
});
test("minimax-music refuses to call upstream without a credential", async () => {
const stub = stubFetch({});
try {
const result = await handleMusicGeneration({
body: { model: "minimax/music-3.0", prompt: "x" },
credentials: null,
log: null,
});
assert.equal(result.success, false);
assert.equal(result.status, 401);
assert.equal(stub.captured.length, 0);
} finally {
stub.restore();
}
});

View File

@@ -0,0 +1,299 @@
// Coverage for the two NewAPI-based gateways touched alongside the AlternateFormat.urlBuilder
// hook: tabitoken (new — Claude-first, 2 protocols) and hcnsec (already shipped as an
// OpenAI-only regional entry, now declaring the 3 further protocols it actually serves).
//
// hcnsec is the first registry entry to expose the **Gemini** protocol as an alternate, and the
// Gemini route embeds the model in the path (`{base}/{model}:generateContent`) instead of a
// constant suffix. `chatPath`/`urlSuffix` cannot express that, so AlternateFormat grew an
// optional `urlBuilder` and shared.ts grew `buildGeminiGenerateContentUrl` — the same builder the
// native `gemini` provider now uses. The identity assertion at the bottom of this file is what
// keeps those two consumers from drifting apart.
//
// hcnsec's pre-existing guarantees (openai format, bearer auth, no static model seed) keep their
// own guard in tests/unit/hcnsec-provider.test.ts; the shape test here re-asserts them only to
// prove the alternates were added *without* moving the defaults.
//
// Everything is asserted through the real DefaultExecutor (not a reimplementation of the
// precedence rules) because both providers exist in the static registry — the limitation the
// older Task 3/4 tests in alternate-formats.test.ts had to work around no longer applies.
import { test } from "node:test";
import assert from "node:assert/strict";
import { DefaultExecutor } from "../../open-sse/executors/default.ts";
import { getTargetFormat } from "../../open-sse/services/provider.ts";
import { getRegistryEntry } from "../../open-sse/config/providerRegistry.ts";
import { buildGeminiGenerateContentUrl } from "../../open-sse/config/providers/shared.ts";
import { geminiProvider } from "../../open-sse/config/providers/registry/gemini/index.ts";
import { getAlternateFormats } from "../../src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts";
import { AI_PROVIDERS, AGGREGATOR_PROVIDER_IDS } from "../../src/shared/constants/providers.ts";
import { APIKEY_PROVIDERS_GATEWAYS } from "../../src/shared/constants/providers/apikey/gateways.ts";
import { APIKEY_PROVIDERS_REGIONAL } from "../../src/shared/constants/providers/apikey/regional.ts";
import { PROVIDER_ENDPOINTS } from "../../src/shared/constants/config.ts";
const KEY = { apiKey: "sk-test" } as never;
const withFormat = (targetFormat: string) =>
({ apiKey: "sk-test", providerSpecificData: { targetFormat } }) as never;
// ── tabitoken ─────────────────────────────────────────────────────────────────
test("tabitoken registry entry is Claude-first with an OpenAI alternate", () => {
const entry = getRegistryEntry("tabitoken");
assert.equal(entry?.format, "claude");
assert.equal(entry?.executor, "default");
assert.equal(entry?.authType, "apikey");
assert.equal(entry?.authHeader, "x-api-key");
assert.equal(entry?.baseUrl, "https://tabitoken.com/v1/messages");
assert.equal(entry?.modelsUrl, "https://tabitoken.com/v1/models");
assert.equal(entry?.passthroughModels, true);
// The generic claude-format path in default.ts::buildHeaders only defaults
// anthropic-version for `anthropic-compatible-*` ids, so the entry carries it.
assert.equal(entry?.headers?.["Anthropic-Version"], "2023-06-01");
// Only the two protocols tabitoken's own /api/pricing reports per model
// (supported_endpoint_types: ["anthropic","openai"]).
assert.deepEqual(
(entry?.alternateFormats || []).map((a) => a.format),
["openai"]
);
});
test("tabitoken catalog matches the four Claude models its public pricing endpoint lists", () => {
const entry = getRegistryEntry("tabitoken");
assert.deepEqual(
(entry?.models || []).map((m) => m.id),
["claude-opus-5", "claude-opus-5-thinking", "claude-opus-4-8", "claude-opus-4-8-thinking"]
);
for (const model of entry?.models || []) {
assert.equal(typeof model.name, "string");
assert.ok(model.name.length > 0, `${model.id} must carry a display name`);
}
});
test("tabitoken defaults to /v1/messages + x-api-key and switches to Bearer on the OpenAI alternate", () => {
const executor = new DefaultExecutor("tabitoken");
assert.equal(getTargetFormat("tabitoken", null), "claude");
assert.equal(
executor.buildUrl("claude-opus-5", true, 0, KEY),
"https://tabitoken.com/v1/messages"
);
const claudeHeaders = executor.buildHeaders(KEY, true) as Record<string, string>;
assert.equal(claudeHeaders["x-api-key"], "sk-test");
assert.equal(claudeHeaders["Authorization"], undefined);
assert.equal(claudeHeaders["Anthropic-Version"], "2023-06-01");
assert.equal(getTargetFormat("tabitoken", { targetFormat: "openai" }), "openai");
const openaiCreds = withFormat("openai");
assert.equal(
executor.buildUrl("claude-opus-5", true, 0, openaiCreds),
"https://tabitoken.com/v1/chat/completions"
);
const openaiHeaders = executor.buildHeaders(openaiCreds, true) as Record<string, string>;
assert.equal(openaiHeaders["Authorization"], "Bearer sk-test");
assert.equal(openaiHeaders["x-api-key"], undefined);
});
test("tabitoken ignores a targetFormat it does not declare", () => {
const executor = new DefaultExecutor("tabitoken");
// Unknown alternate → resolveAlternateFormat returns null → default format/route/auth.
assert.equal(getTargetFormat("tabitoken", { targetFormat: "gemini" }), "claude");
const creds = withFormat("gemini");
assert.equal(
executor.buildUrl("claude-opus-5", true, 0, creds),
"https://tabitoken.com/v1/messages"
);
const headers = executor.buildHeaders(creds, true) as Record<string, string>;
assert.equal(headers["x-api-key"], "sk-test");
assert.equal(headers["x-goog-api-key"], undefined);
});
// ── hcnsec ────────────────────────────────────────────────────────────────────
test("hcnsec keeps its OpenAI-first defaults and adds the three further protocols it serves", () => {
const entry = getRegistryEntry("hcnsec");
// Unchanged by this extension — the guard that adding alternates moved no default.
assert.equal(entry?.format, "openai");
assert.equal(entry?.executor, "default");
assert.equal(entry?.authHeader, "bearer");
assert.equal(entry?.baseUrl, "https://api.hcnsec.cn/v1/chat/completions");
assert.equal(entry?.modelsUrl, "https://api.hcnsec.cn/v1/models");
assert.deepEqual(entry?.models, []);
assert.equal(entry?.passthroughModels, true);
// Added: the Responses route plus one alternate per further protocol.
assert.equal(entry?.responsesBaseUrl, "https://api.hcnsec.cn/v1/responses");
assert.deepEqual(
(entry?.alternateFormats || []).map((a) => a.format),
["claude", "openai-responses", "gemini"]
);
});
test("hcnsec routes each protocol to its own endpoint with the matching auth scheme", () => {
const executor = new DefaultExecutor("hcnsec");
assert.equal(getTargetFormat("hcnsec", null), "openai");
assert.equal(
executor.buildUrl("gpt-5", true, 0, KEY),
"https://api.hcnsec.cn/v1/chat/completions"
);
assert.equal(
(executor.buildHeaders(KEY, true) as Record<string, string>)["Authorization"],
"Bearer sk-test"
);
const claudeCreds = withFormat("claude");
assert.equal(getTargetFormat("hcnsec", { targetFormat: "claude" }), "claude");
assert.equal(
executor.buildUrl("claude-opus-5", true, 0, claudeCreds),
"https://api.hcnsec.cn/v1/messages"
);
const claudeHeaders = executor.buildHeaders(claudeCreds, true) as Record<string, string>;
assert.equal(claudeHeaders["x-api-key"], "sk-test");
assert.equal(claudeHeaders["Authorization"], undefined);
assert.equal(claudeHeaders["Anthropic-Version"], "2023-06-01");
const responsesCreds = withFormat("openai-responses");
assert.equal(getTargetFormat("hcnsec", { targetFormat: "openai-responses" }), "openai-responses");
assert.equal(
executor.buildUrl("gpt-5", true, 0, responsesCreds),
"https://api.hcnsec.cn/v1/responses"
);
assert.equal(
(executor.buildHeaders(responsesCreds, true) as Record<string, string>)["Authorization"],
"Bearer sk-test"
);
});
test("hcnsec's Gemini alternate builds the model-scoped generateContent route in both forms", () => {
const executor = new DefaultExecutor("hcnsec");
const creds = withFormat("gemini");
assert.equal(getTargetFormat("hcnsec", { targetFormat: "gemini" }), "gemini");
// Unary: the model lands in the path, which is exactly what chatPath/urlSuffix
// (both constants) could not express before urlBuilder existed.
assert.equal(
executor.buildUrl("gemini-3.7-flash", false, 0, creds),
"https://api.hcnsec.cn/v1beta/models/gemini-3.7-flash:generateContent"
);
// Streaming keeps the `?alt=sse` suffix the Gemini protocol requires.
assert.equal(
executor.buildUrl("gemini-3.7-flash", true, 0, creds),
"https://api.hcnsec.cn/v1beta/models/gemini-3.7-flash:streamGenerateContent?alt=sse"
);
const headers = executor.buildHeaders(creds, true) as Record<string, string>;
assert.equal(headers["x-goog-api-key"], "sk-test");
assert.equal(headers["Authorization"], undefined);
assert.equal(headers["x-api-key"], undefined);
});
test("hcnsec ignores a targetFormat it does not declare", () => {
const executor = new DefaultExecutor("hcnsec");
assert.equal(getTargetFormat("hcnsec", { targetFormat: "codex" }), "openai");
const creds = withFormat("codex");
assert.equal(
executor.buildUrl("gpt-5", true, 0, creds),
"https://api.hcnsec.cn/v1/chat/completions"
);
assert.equal(
(executor.buildHeaders(creds, true) as Record<string, string>)["Authorization"],
"Bearer sk-test"
);
});
// ── shared Gemini URL builder ─────────────────────────────────────────────────
test("the Gemini route builder is shared with the native gemini provider, not duplicated", () => {
// Identity, not equality: if someone re-inlines an arrow function on either side the
// `?alt=sse` suffix is free to drift between the two consumers. This is the guard.
assert.equal(geminiProvider.urlBuilder, buildGeminiGenerateContentUrl);
const geminiAlternate = (getRegistryEntry("hcnsec")?.alternateFormats || []).find(
(a) => a.format === "gemini"
);
assert.equal(geminiAlternate?.urlBuilder, buildGeminiGenerateContentUrl);
assert.equal(
buildGeminiGenerateContentUrl("https://example.com/v1beta/models", "m", false),
"https://example.com/v1beta/models/m:generateContent"
);
assert.equal(
buildGeminiGenerateContentUrl("https://example.com/v1beta/models", "m", true),
"https://example.com/v1beta/models/m:streamGenerateContent?alt=sse"
);
});
test("the native gemini provider still resolves its own generateContent route", () => {
const executor = new DefaultExecutor("gemini");
assert.equal(
executor.buildUrl("gemini-3.7-flash", false, 0, KEY),
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.7-flash:generateContent"
);
assert.equal(
executor.buildUrl("gemini-3.7-flash", true, 0, KEY),
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.7-flash:streamGenerateContent?alt=sse"
);
});
// ── dashboard / catalog registration ──────────────────────────────────────────
test("both hosts surface in the dashboard alternate-protocol picker", () => {
assert.deepEqual(
getAlternateFormats("tabitoken").map((a) => a.format),
["openai"]
);
assert.deepEqual(
getAlternateFormats("hcnsec").map((a) => a.format),
["claude", "openai-responses", "gemini"]
);
// Every alternate needs a label — it is the string the picker renders.
for (const id of ["tabitoken", "hcnsec"]) {
for (const alternate of getAlternateFormats(id)) {
assert.ok(alternate.label, `${id}/${alternate.format} must declare a label`);
}
}
});
test("tabitoken is catalogued as an aggregator gateway with a display endpoint", () => {
const provider = (AI_PROVIDERS as Record<string, Record<string, unknown>>).tabitoken;
assert.ok(provider, "tabitoken must be present in AI_PROVIDERS");
assert.equal(provider.id, "tabitoken");
assert.equal(provider.alias, "tabitoken");
assert.equal(provider.passthroughModels, true);
assert.equal(typeof provider.name, "string");
assert.equal(typeof provider.website, "string");
assert.equal(typeof provider.apiHint, "string");
assert.ok(AGGREGATOR_PROVIDER_IDS.has("tabitoken"), "tabitoken must be an aggregator");
// The display endpoint must name the protocol the gateway defaults to.
assert.equal(
(PROVIDER_ENDPOINTS as Record<string, string>).tabitoken,
"https://tabitoken.com/v1/messages"
);
});
test("extending hcnsec's protocols leaves its existing catalog classification alone", () => {
// hcnsec shipped before this change as an API-key **regional** provider (its own guard:
// tests/unit/hcnsec-provider.test.ts). Declaring three more protocols on the registry entry
// is an engine-level capability change — it must not silently reclassify the catalog entry
// as a gateway/aggregator, which would move it in the dashboard and in the generated
// provider reference. This test is the guard against that drift.
const provider = (AI_PROVIDERS as Record<string, Record<string, unknown>>).hcnsec;
assert.ok(provider, "hcnsec must remain present in AI_PROVIDERS");
assert.equal(provider.id, "hcnsec");
assert.equal(provider.alias, "hcnsec");
assert.equal(provider.passthroughModels, true);
assert.equal(provider.name, "Huancheng Public API");
assert.equal(provider.website, "https://api.hcnsec.cn");
assert.equal(typeof provider.authHint, "string");
assert.equal(
AGGREGATOR_PROVIDER_IDS.has("hcnsec"),
false,
"hcnsec stays a regional provider — the protocol extension must not reclassify it"
);
const regional = APIKEY_PROVIDERS_REGIONAL as Record<string, unknown>;
assert.ok(regional.hcnsec, "hcnsec must stay in the regional family file");
const gateways = APIKEY_PROVIDERS_GATEWAYS as Record<string, unknown>;
assert.equal(
gateways.hcnsec,
undefined,
"hcnsec must not be duplicated into gateways — the families are a strict partition"
);
});

View File

@@ -0,0 +1,32 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { applyRtkCompression } from "../../open-sse/services/compression/engines/rtk/index.ts";
// Issue #10765: enabling RTK causes ~100% CPU even when the engine finds nothing to
// compress ("It also occurs when the engine does not modify the request and reports
// no token savings."). Root cause: applyRtkCompression() unconditionally calls
// createCompressionStats() at the end of the function — which does a full
// JSON.stringify() (+ tiktoken tokenize for Codex bodies) of the ENTIRE request body,
// TWICE (original + compressed) — even when zero messages were touched.
//
// Every sibling stacked engine (headroom, session-dedup, ccr, relevance, ionizer,
// readLifecycle) returns `stats: null` early when nothing changed, skipping this
// expensive computation entirely. RTK is the outlier: it always pays the cost.
test("RTK no-op run should skip the expensive stats computation (like sibling engines)", () => {
const body = {
model: "codex/gpt-5",
provider: "codex",
messages: [
{ role: "user", content: "hello, this is a simple message with nothing to compress" },
],
};
const result = applyRtkCompression(body, { config: { enabled: true } });
assert.equal(result.compressed, false, "RTK made no changes");
assert.equal(
result.stats,
null,
"RTK should return stats: null on a no-op run, like every sibling stacked engine"
);
});

View File

@@ -23,7 +23,7 @@
// gateways family to 228 measured on the tip; Puter retired (#10210) and chatanywhere restored
// (base-reds round 3, #9985) are both included in that measurement; Cursor API (specialty-media,
// #10729) brings it to 229; Token Kiosk (gateways, #10722) — merged in the same
// merge-train batch — independently bumped the gateways family too, landing at 230.
// merge-train batch — independently bumped the gateways family too, landing at 231.
import { test } from "node:test";
import assert from "node:assert/strict";
@@ -52,12 +52,12 @@ test("barrel still exports every catalog + key helpers", () => {
}
});
test("APIKEY_PROVIDERS merges the 6 family files into 230 entries (no loss / no dup)", async () => {
test("APIKEY_PROVIDERS merges the 6 family files into 231 entries (no loss / no dup)", async () => {
const keys = Object.keys((P as Record<string, object>).APIKEY_PROVIDERS);
assert.equal(keys.length, 230);
assert.equal(new Set(keys).size, 230, "duplicate keys after spread-merge");
assert.equal(keys.length, 231);
assert.equal(new Set(keys).size, 231, "duplicate keys after spread-merge");
// the merged object's entry-count equals the sum of the 6 semantic family files; families are a
// strict partition (every provider in exactly one), so the sum must be exactly 230.
// strict partition (every provider in exactly one), so the sum must be exactly 231.
const families: [string, string][] = [
["gateways", "APIKEY_PROVIDERS_GATEWAYS"],
["frontier-labs", "APIKEY_PROVIDERS_FRONTIER"],
@@ -77,7 +77,7 @@ test("APIKEY_PROVIDERS merges the 6 family files into 230 entries (no loss / no
seen.add(k);
}
}
assert.equal(famTotal, 230, "families must partition all 230 providers");
assert.equal(famTotal, 231, "families must partition all 231 providers");
});
test("AI_PROVIDERS Proxy aggregates all sections; lookups resolve", () => {

View File

@@ -0,0 +1,107 @@
// #10095 — Antigravity multi-account "all exhausted" false positive.
//
// src/domain/quotaCache.ts is the FIRST, unconditional gate every chat request
// passes through (src/sse/services/auth.ts::getProviderCredentialsWithQuotaPreflight).
// When Google's Cloud Code API doesn't report `remainingFraction` for a model
// (fresh accounts, newly-launched -tiered model ids), open-sse/services/usage/
// antigravity.ts writes `fractionReported:false` but defaults
// `remainingPercentage` to 0 — quotaCache.ts previously read only the numeric
// percentage and treated that as genuine 0%-remaining exhaustion, so every
// freshly-connected Antigravity account looked simultaneously (and falsely)
// dead, and getProviderCredentials returned "All antigravity accounts have
// exhausted their quota" before ever trying one.
import { test, describe, before, after } from "node:test";
import assert from "node:assert/strict";
describe("#10095 — quotaCache respects Antigravity fractionReported:false", () => {
before(async () => {
const { __clearForTests } = await import("../../src/domain/quotaCache.ts");
__clearForTests();
});
after(async () => {
const { __clearForTests } = await import("../../src/domain/quotaCache.ts");
__clearForTests();
});
test("unreported quota window (fractionReported:false) is NOT treated as exhausted", async () => {
const { setQuotaCache, isQuotaExhaustedForRequest } = await import(
"../../src/domain/quotaCache.ts"
);
const connectionId = "10095-fresh-account";
// Exact shape open-sse/services/usage/antigravity.ts:660-694 writes when
// Google's API omits remainingFraction for this model.
setQuotaCache(connectionId, "antigravity", {
"gemini-3.7-flash-tiered": {
used: 0,
total: 1000,
resetAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
remainingPercentage: 0,
unlimited: false,
fractionReported: false,
quotaSource: "fetchAvailableModels",
},
});
const exhausted = isQuotaExhaustedForRequest(
connectionId,
"antigravity",
"agy/gemini-3.7-flash-tiered"
);
assert.equal(exhausted, false, "must NOT treat an unreported quota window as exhausted");
});
test("companion: a REAL 0% window (fractionReported:true) still reports exhausted", async () => {
const { setQuotaCache, isQuotaExhaustedForRequest } = await import(
"../../src/domain/quotaCache.ts"
);
const connectionId = "10095-genuinely-exhausted-account";
setQuotaCache(connectionId, "antigravity", {
"gemini-3.7-flash-tiered": {
used: 1000,
total: 1000,
resetAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
remainingPercentage: 0,
unlimited: false,
fractionReported: true,
quotaSource: "retrieveUserQuota",
},
});
const exhausted = isQuotaExhaustedForRequest(
connectionId,
"antigravity",
"agy/gemini-3.7-flash-tiered"
);
assert.equal(
exhausted,
true,
"the fix must not blanket-disable exhaustion detection — a genuinely reported 0% window still exhausts"
);
});
test("issue follow-up model id shape (agy/gemini-3.7-flash-tiered) still resolves its family", async () => {
const { setQuotaCache, isQuotaExhaustedForRequest } = await import(
"../../src/domain/quotaCache.ts"
);
const connectionId = "10095-agy-tiered-family";
setQuotaCache(connectionId, "agy", {
"agy/gemini-3.7-flash-tiered": {
used: 500,
total: 1000,
resetAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
remainingPercentage: 50,
unlimited: false,
fractionReported: true,
quotaSource: "retrieveUserQuota",
},
});
const exhausted = isQuotaExhaustedForRequest(
connectionId,
"agy",
"agy/gemini-3.7-flash-tiered"
);
assert.equal(
exhausted,
false,
"non-regression: family resolution for the agy/gemini-3.7-flash-tiered id must keep working"
);
});
});

View File

@@ -0,0 +1,66 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { getResetAwareProvider } from "../../open-sse/services/combo/quotaScoring.ts";
import { registerQuotaFetcher, getQuotaFetcher } from "../../open-sse/services/quotaPreflight.ts";
import { resolveProviderId } from "../../src/shared/constants/providers.ts";
import type { ResolvedComboTarget } from "../../open-sse/services/combo/types.ts";
function buildTarget(provider: string): ResolvedComboTarget {
return {
kind: "model",
stepId: "s1",
executionKey: "e1",
modelStr: `${provider}/some-model`,
provider,
providerId: provider,
connectionId: "conn-1",
weight: 1,
label: null,
} as ResolvedComboTarget;
}
test("#10877: getResetAwareProvider() canonicalizes an alias-spelled provider so the fetcher registered under the canonical id is found", () => {
registerQuotaFetcher("ollama-cloud", async () => ({ ok: true }) as never);
const target = buildTarget("ollamacloud");
const lookedUpProvider = getResetAwareProvider(target);
assert.equal(
lookedUpProvider,
resolveProviderId("ollamacloud"),
"getResetAwareProvider() should return the canonical provider id, not the raw alias"
);
const fetcher = getQuotaFetcher(lookedUpProvider!);
assert.notEqual(
fetcher,
undefined,
"a fetcher registered under the canonical provider id must be found for an alias-spelled combo target"
);
});
test("#10877: getResetAwareProvider() is a no-op (same cache key) for already-canonical provider ids", () => {
registerQuotaFetcher("codex", async () => ({ ok: true }) as never);
const target = buildTarget("codex");
const lookedUpProvider = getResetAwareProvider(target);
assert.equal(lookedUpProvider, "codex");
assert.notEqual(getQuotaFetcher(lookedUpProvider!), undefined);
});
test("#10877: getResetAwareProvider() returns null when neither providerId nor provider is set", () => {
const target = {
kind: "model",
stepId: "s1",
executionKey: "e1",
modelStr: "unknown/model",
provider: "",
providerId: "",
connectionId: "conn-1",
weight: 1,
label: null,
} as ResolvedComboTarget;
assert.equal(getResetAwareProvider(target), null);
});

View File

@@ -364,3 +364,63 @@ test("Claude to Responses translation includes canonical Codex usage", async ()
assert.equal(completed.response.usage.output_tokens, 6);
assert.equal(completed.response.usage.total_tokens, 94);
});
// #10156 — the live-frame drop above works correctly, but real upstreams (as in
// the issue's repro) echo the ALREADY-DROPPED commentary item back inside the
// terminal `response.completed.response.output` array. Because that array is
// non-empty, `backfillResponsesCompletedOutput` never touches it, so the
// terminal snapshot silently disagreed with the events already delivered to
// the client. This must stay filtered too.
test("response.completed strips a commentary item the upstream echoes back non-empty (#10156)", async () => {
const output = await readTransformed(
[
...buildResponsesStream().slice(0, -1),
sse({
type: "response.completed",
response: {
id: "resp_10156",
output: [
{
id: "msg_commentary",
type: "message",
role: "assistant",
phase: "commentary",
content: [{ type: "output_text", text: COMMENTARY_TEXT }],
},
{
id: "msg_final",
type: "message",
role: "assistant",
phase: "final",
content: [{ type: "output_text", text: FINAL_TEXT }],
},
],
},
}),
],
{ ...PASSTHROUGH_RESPONSES_OPTIONS, dropResponsesCommentary: true }
);
assert.ok(
!output.includes(COMMENTARY_TEXT),
"commentary text must never reach the client, live or in the terminal snapshot"
);
assert.ok(
!output.includes("msg_commentary"),
"the commentary item id must not appear anywhere in the forwarded stream"
);
const completedLine = output
.split(/\r?\n/)
.find((line) => line.startsWith("data:") && line.includes('"response.completed"'));
assert.ok(completedLine, "the terminal Responses event must be forwarded");
const completed = JSON.parse(completedLine.slice(5).trim());
assert.ok(
!completed.response.output.some((item: { phase?: string }) => item.phase === "commentary"),
"BUG #10156: response.completed.response.output must not retain the commentary item once its live SSE frames were suppressed — live stream and terminal snapshot must stay consistent"
);
assert.ok(
completed.response.output.some((item: { id?: string }) => item.id === "msg_final"),
"the final answer item must still be present in the terminal snapshot"
);
});

View File

@@ -0,0 +1,69 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-search-10849-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const searchRoute = await import("../../src/app/api/v1/search/route.ts");
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
function makeRequest(body: unknown) {
return new Request("http://localhost/v1/search", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
type ErrorBody = { error?: { message: string } };
test("#10849: unknown provider id returns 'Unknown search provider: <id>', not opaque 'Invalid request'", async () => {
const response = await searchRoute.POST(makeRequest({ query: "test", provider: "grok" }), {});
const body = (await response.json()) as ErrorBody;
assert.equal(response.status, 400);
assert.match(
body.error?.message ?? "",
/Unknown search provider: grok/,
`expected a named-provider message, got: ${body.error?.message}`
);
});
test("#10849: short alias 'brave' resolves like existing 'jina' aliases (not an opaque 400)", async () => {
const response = await searchRoute.POST(makeRequest({ query: "test", provider: "brave" }), {});
const body = (await response.json()) as ErrorBody;
assert.notEqual(
body.error?.message,
"Invalid request",
`expected a named provider error, got opaque: ${JSON.stringify(body.error)}`
);
});
test("#10849: a genuinely bad field surfaces a non-generic, field-named 400 message", async () => {
const response = await searchRoute.POST(
makeRequest({ query: "test", search_type: "bogus" }),
{}
);
const body = (await response.json()) as ErrorBody;
assert.equal(response.status, 400);
assert.notEqual(
body.error?.message,
"Invalid request",
`expected a field-named message, got opaque: ${JSON.stringify(body.error)}`
);
assert.match(
body.error?.message ?? "",
/search_type/,
`expected the message to name the offending field, got: ${body.error?.message}`
);
});

View File

@@ -381,11 +381,17 @@ test("v1SearchSchema rejects query over 500 chars", async () => {
assert.ok(!result.success);
});
test("v1SearchSchema rejects invalid provider", async () => {
test("v1SearchSchema accepts any non-empty provider string; the catalog rejects unknown ids (#10849)", async () => {
const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts");
const { resolveSearchProvider } = await import("../../open-sse/config/searchRegistry.ts");
// provider is a free-form string at the schema layer — resolveSearchProvider() (backing
// POST /v1/search) is the runtime source of truth, and returns null for unknown ids so
// the route can reply with a named "Unknown search provider: <id>" error instead of an
// opaque schema-level 400.
const result = v1SearchSchema.safeParse({ query: "test", provider: "google" });
assert.ok(!result.success);
assert.ok(result.success);
assert.equal(resolveSearchProvider("google"), null);
});
test("v1SearchSchema accepts tavily provider", async () => {

View File

@@ -0,0 +1,98 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/playground/types", () => ({ getModelPricing: () => null }));
vi.mock("@/lib/playground/streamMetrics", () => ({
computeMetrics: () => ({ ttftMs: 100, totalMs: 500, tokensIn: 10, tokensOut: 20, tps: 40, costUsd: 0.001 }),
}));
vi.mock("remark-gfm", () => ({ default: () => {} }));
vi.mock("react-markdown", () => ({
default: ({ children }: { children: React.ReactNode }) => <div data-testid="markdown-content">{children}</div>,
}));
if (typeof Element.prototype.scrollIntoView === "undefined") {
Object.defineProperty(Element.prototype, "scrollIntoView", { value: () => {}, writable: true, configurable: true });
}
function setInputValue(el: HTMLTextAreaElement | HTMLInputElement, value: string): void {
const nativeSetter =
el instanceof HTMLTextAreaElement
? Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set
: Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
nativeSetter?.call(el, value);
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}
const { DEFAULT_PARAMS } = await import("../../../src/app/(dashboard)/dashboard/playground/components/ParamSliders");
const { default: ChatTab } = await import("../../../src/app/(dashboard)/dashboard/playground/components/tabs/ChatTab");
function makeSearchProviderConfig() {
return {
endpoint: "search" as const,
baseUrl: "http://localhost:20128",
model: "exa-search/web",
provider: "exa-search",
systemPrompt: "",
params: { ...DEFAULT_PARAMS },
};
}
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function renderChatTab(config: ReturnType<typeof makeSearchProviderConfig>): HTMLDivElement {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(<ChatTab configState={config} />);
});
containers.push({ root, el });
return el;
}
async function waitFor(fn: () => boolean, timeout = 3000): Promise<void> {
const start = Date.now();
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("waitFor timed out");
await new Promise((r) => setTimeout(r, 20));
}
}
describe("ChatTab — search-provider endpoint routing (#10592)", () => {
beforeEach(() => {
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
for (const { root, el } of containers.splice(0)) {
act(() => root.unmount());
el.remove();
}
document.body.innerHTML = "";
vi.restoreAllMocks();
});
it("routes to /api/v1/search (not /api/v1/chat/completions) when configState.endpoint is 'search'", async () => {
let capturedUrl: string | null = null;
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
capturedUrl = String(url);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
controller.close();
},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } }
);
});
const el = renderChatTab(makeSearchProviderConfig());
const textarea = el.querySelector("textarea") as HTMLTextAreaElement;
act(() => {
setInputValue(textarea, "latest news India");
});
const sendBtn = Array.from(el.querySelectorAll("button")).find((b) =>
b.textContent?.includes("Send")
) as HTMLButtonElement | undefined;
await act(async () => {
sendBtn?.click();
});
await waitFor(() => capturedUrl !== null);
expect(capturedUrl).toBe("/api/v1/search");
fetchSpy.mockRestore();
});
});

View File

@@ -0,0 +1,34 @@
import test from "node:test";
import assert from "node:assert/strict";
import { IMAGE_PROVIDERS, parseImageModel } from "../../open-sse/config/imageRegistry.ts";
test("#10848 bare id that only exists on a cookie-auth web bridge should not silently resolve to it", () => {
const chatgptWeb = IMAGE_PROVIDERS["chatgpt-web"];
assert.equal(chatgptWeb.authHeader, "cookie");
const otherProvidersWithSameId = Object.entries(IMAGE_PROVIDERS).filter(
([providerId, config]) =>
providerId !== "chatgpt-web" && config.models.some((m) => m.id === "gpt-5.5")
);
assert.deepEqual(
otherProvidersWithSameId,
[],
"expected only chatgpt-web (cookie) to register gpt-5.5"
);
const resolved = parseImageModel("gpt-5.5");
assert.notDeepEqual(
resolved,
{ provider: "chatgpt-web", model: "gpt-5.5" },
"bare 'gpt-5.5' must not silently bind to the cookie-auth chatgpt-web bridge"
);
assert.deepEqual(parseImageModel("chatgpt-web/gpt-5.5"), {
provider: "chatgpt-web",
model: "gpt-5.5",
});
assert.deepEqual(parseImageModel("cgpt-web/gpt-5.5"), {
provider: "chatgpt-web",
model: "gpt-5.5",
});
});