mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 06:32:16 +03:00
Compare commits
23 Commits
fix/10788-
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8d9f845da | ||
|
|
118840131d | ||
|
|
3fed9e837a | ||
|
|
1f04e73a1c | ||
|
|
f66c986dbd | ||
|
|
3669df2ca5 | ||
|
|
590cbbe7b1 | ||
|
|
c7e264e1a6 | ||
|
|
699be22e1e | ||
|
|
7756aef970 | ||
|
|
050c7c0021 | ||
|
|
c535df9076 | ||
|
|
5d9998eb31 | ||
|
|
19741775ee | ||
|
|
b668d91364 | ||
|
|
4ec080dc19 | ||
|
|
0458c5ac4c | ||
|
|
9603ec1bf1 | ||
|
|
6d043674c2 | ||
|
|
bed4d24049 | ||
|
|
018badc3b3 | ||
|
|
87719f2381 | ||
|
|
eb6f319712 |
14
.github/dependabot.yml
vendored
14
.github/dependabot.yml
vendored
@@ -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"
|
||||
|
||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
|
||||
@@ -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
|
||||
|
||||
14
README.md
14
README.md
@@ -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 15–95% 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 15–95% 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 & 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 15–95%, ~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 15–95%, ~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'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's docs."/>
|
||||
|
||||
<sub>📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & 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">
|
||||
|
||||
|
||||
2
changelog.d/features/10668-newapi-gateway-protocols.md
Normal file
2
changelog.d/features/10668-newapi-gateway-protocols.md
Normal 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
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
1
changelog.d/fixes/10536-llmlingua-2-2.0.5-drop-tfjs.md
Normal file
1
changelog.d/fixes/10536-llmlingua-2-2.0.5-drop-tfjs.md
Normal 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))
|
||||
@@ -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)
|
||||
1
changelog.d/fixes/10597-combo-log-error-body.md
Normal file
1
changelog.d/fixes/10597-combo-log-error-body.md
Normal 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))
|
||||
1
changelog.d/fixes/10765-rtk-unconditional-stats-cpu.md
Normal file
1
changelog.d/fixes/10765-rtk-unconditional-stats-cpu.md
Normal 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)
|
||||
1
changelog.d/fixes/10815-kiro-oauth-profilearn-dedup.md
Normal file
1
changelog.d/fixes/10815-kiro-oauth-profilearn-dedup.md
Normal 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)
|
||||
1
changelog.d/fixes/10848-image-scan-cookie-bridge.md
Normal file
1
changelog.d/fixes/10848-image-scan-cookie-bridge.md
Normal 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)
|
||||
1
changelog.d/fixes/10849-search-provider-opaque-400.md
Normal file
1
changelog.d/fixes/10849-search-provider-opaque-400.md
Normal 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)
|
||||
@@ -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)
|
||||
1
changelog.d/fixes/minimax-music-generation-dispatch.md
Normal file
1
changelog.d/fixes/minimax-music-generation-dispatch.md
Normal 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).
|
||||
@@ -20,7 +20,6 @@
|
||||
"@stryker-mutator/tap-runner",
|
||||
"@swc/helpers",
|
||||
"@tailwindcss/postcss",
|
||||
"@tensorflow/tfjs",
|
||||
"@testing-library/jest-dom",
|
||||
"@testing-library/react",
|
||||
"@toon-format/toon",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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ą runtime’y 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 runtime’y transformers.js + onnxruntime-node oraz model TinyBERT (~57 MB) pobierany
|
||||
przy pierwszym użyciu (nie przez npm).
|
||||
|
||||
Per środowisko:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 權杖路徑,迴環介面)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
8
llm.txt
8
llm.txt
@@ -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)
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
59
open-sse/config/providers/registry/tabitoken/index.ts
Normal file
59
open-sse/config/providers/registry/tabitoken/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 || ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
358
open-sse/handlers/mediaGeneration/minimaxMusic.ts
Normal file
358
open-sse/handlers/mediaGeneration/minimaxMusic.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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
367
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` +
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
91
tests/unit/combo-10597-error-body-logging.test.ts
Normal file
91
tests/unit/combo-10597-error-body-logging.test.ts
Normal 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}`
|
||||
);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
115
tests/unit/kiro-second-oauth-connection-10815.test.ts
Normal file
115
tests/unit/kiro-second-oauth-connection-10815.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
267
tests/unit/minimax-music-generation.test.ts
Normal file
267
tests/unit/minimax-music-generation.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
299
tests/unit/newapi-gateway-providers.test.ts
Normal file
299
tests/unit/newapi-gateway-providers.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
32
tests/unit/probe-10765-rtk-noop-stats.test.ts
Normal file
32
tests/unit/probe-10765-rtk-noop-stats.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
66
tests/unit/quota-scoring-alias-lookup-10877.test.ts
Normal file
66
tests/unit/quota-scoring-alias-lookup-10877.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
69
tests/unit/search-provider-opaque-400-10849.test.ts
Normal file
69
tests/unit/search-provider-opaque-400-10849.test.ts
Normal 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}`
|
||||
);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
34
tests/unit/unprefixed-scan-web-cookie-10848.test.ts
Normal file
34
tests/unit/unprefixed-scan-web-cookie-10848.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user