mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-16 20:22:21 +03:00
Merge remote-tracking branch 'origin/release/v3.8.44' into feat/auto-combo-request-controls-6022
# Conflicts: # open-sse/services/combo.ts
This commit is contained in:
@@ -1466,6 +1466,13 @@ APP_LOG_TO_FILE=true
|
||||
# CLIPROXYAPI_PORT=5544
|
||||
# CLIPROXYAPI_CONFIG_DIR=~/.cli-proxy-api
|
||||
|
||||
# ── Mux embedded service ──
|
||||
# Override the port where the embedded Mux (coder/mux) agent-orchestration
|
||||
# daemon listens. Always bound to 127.0.0.1 — never configurable to 0.0.0.0.
|
||||
# Rarely needed — defaults to 8322.
|
||||
# Used by: src/lib/services/bootstrap.ts, src/app/api/services/mux/_lib.ts
|
||||
# MUX_SERVICE_PORT=8322
|
||||
|
||||
# ── Local hostnames (Docker networking) ──
|
||||
# Comma-separated additional hostnames treated as "local" for provider routing.
|
||||
# Used by: open-sse/config/providerRegistry.ts — allows Docker service names.
|
||||
|
||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -19,12 +19,23 @@
|
||||
- **feat(usage):** add on-demand period-scoped usage-data reset (Settings → System Storage) with a purge API and time-window selector.
|
||||
- **feat(claude-code):** add an opt-in auto-permission classifier compat mode (off/auto/always) for Claude Code, toggleable from the CLI Code settings.
|
||||
- **feat(providers):** add optional client-identity header profiles for compatible nodes — preset User-Agent/fingerprint headers (e.g. matching a known CLI) merged into the existing customHeaders field.
|
||||
- **feat(xai):** surface Grok usage on the quota dashboard via local usage-history aggregation. (thanks @DevEstacion)
|
||||
- **feat(services):** add **Mux** (`coder/mux`) as a managed embedded service — install/start/stop/restart/logs lifecycle + dashboard tab, loopback-only API, `127.0.0.1`-bound with the auth token passed via env (never argv). Ported from upstream 9router#1802. (thanks @Ansh7473)
|
||||
- **feat(services):** promote **Bifrost** (`@maximhq/bifrost`) to a supervised embedded service ([#5670](https://github.com/diegosouzapw/OmniRoute/issues/5670)) — full install/start/stop/restart/update/status/auto-start lifecycle + dashboard tab, loopback-only API (hard rule #17). When a supervised Bifrost is running and `BIFROST_BASE_URL` is unset, the relay route auto-selects it as the routing backend (`getBifrostRoutingConfig()`); an explicit `BIFROST_BASE_URL` always takes precedence.
|
||||
- **feat(autoCombo):** add **per-request Auto-Combo controls** via two headers ([#6024](https://github.com/diegosouzapw/OmniRoute/issues/6024) / [#6025](https://github.com/diegosouzapw/OmniRoute/issues/6025) / [#6023](https://github.com/diegosouzapw/OmniRoute/issues/6023)) — `X-OmniRoute-Mode` steers an `auto` combo's scoring for a single request (friendly presets `fast`/`balanced`/`quality`/`cheap`/`reliable`/`offline` **or** a raw mode-pack name; `balanced` forces the default weights), and `X-OmniRoute-Budget` sets a hard per-request USD cost ceiling. Both override the combo's stored config only for the request that carries them; unknown/garbage values are ignored so the saved config is preserved. The resolvers are pure (`open-sse/services/autoCombo/requestControls.ts`) and feed the engine's existing `config.modePack` / `config.budgetCap` inputs — no engine changes. Regression guard: `tests/unit/auto-combo-request-controls-6024.test.ts` (5). (thanks @chirag127)
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **dashboard ("Update now" → Internal Server Error):** clicking **Update now** on the dashboard home could crash the page with a blank "Internal Server Error" screen (`Minified React error #31`). The handler POSTs the loopback-only `/api/system/version` auto-update endpoint and, on a non-OK JSON response (e.g. a `403` when the dashboard is reached through a reverse proxy / non-loopback origin), passed the raw error envelope object `{ error: { code, message, correlation_id } }` straight to `notify.error()`, which rendered the object as a React child and threw #31. The update-error path now funnels the body through `extractApiErrorMessage()` (the same safe extractor added in #5340), so a readable string always reaches the toast. Regression guard: `tests/unit/ui/home-update-error-render-5991.test.ts`. ([#5991](https://github.com/diegosouzapw/OmniRoute/issues/5991))
|
||||
|
||||
- **kiro (system prompt leaked as raw user text):** when Claude Code routed through the Kiro/CodeWhisperer backend, the `system` message was normalized to a `user` turn with no wrapper, so the entire system prompt (environment info, tool definitions, memory instructions, etc.) appeared as if the user had typed it — polluting the model context. System-origin content is now wrapped in `<system-reminder>` tags before being merged into the Kiro user message, so the model can distinguish it from real user input. Real user turns are untouched. Regression guard: `tests/unit/kiro-system-reminder-2306.test.ts`. (thanks @VitzS7)
|
||||
|
||||
- **antigravity/gemini tool calls (`400 Unknown name "multipleOf"`):** requests routed to antigravity/gemini models with tools that declare a `multipleOf` numeric constraint failed with a hard upstream `400` (`Invalid JSON payload received. Unknown name "multipleOf"`). `multipleOf` is not part of the Gemini/antigravity OpenAPI 3.0 schema subset and was not being stripped from `function_declarations`. It is now removed at every schema level (top-level, nested, and array `items`), alongside the other unsupported constraints; `minimum`/`maximum` remain untouched. Regression guard: `tests/unit/gemini-multipleof-2309.test.ts`. (thanks @abil0321)
|
||||
|
||||
- **cline (empty/false-502 non-streaming responses):** the Cline gateway can wrap OpenAI-compatible chat completions in a `{ success, data: { choices, usage, … } }` envelope. The non-streaming path checked the top-level body for empty content before unwrapping, so a valid Cline response was treated as malformed. The body is now unwrapped via `unwrapClineNonStreamingEnvelope()` right after the provider-envelope unwrap and before the empty-content check, closing the remaining gap from #5956/#5924. Regression guard: `tests/unit/cline-response-envelope.test.ts`. ([#5956](https://github.com/diegosouzapw/OmniRoute/issues/5956) — thanks @KooshaPari)
|
||||
|
||||
- **combo (per-model-quota providers exhausted on a single model 500):** for per-model-quota providers (gemini, github, passthrough, compatible) that multiplex many models behind one connection, a model-level `500` (e.g. Gemini "Internal error encountered") wrongly marked the whole connection exhausted, so sibling models on the same connection were skipped and the combo could 502 instead of falling back. `markConnectionLevelExhaustion` now leaves the connection eligible on a `500` for per-model-quota providers (other connection-level statuses — 408/502/503/504/524 — still exhaust correctly), and the retry loop early-returns when a model is already in lockout. Regression guard: `tests/unit/combo/combo-target-exhaustion.test.ts`. ([#5976](https://github.com/diegosouzapw/OmniRoute/pull/5976) — thanks @hartmark)
|
||||
|
||||
### 📝 Maintenance
|
||||
|
||||
- **test (deflake `setup-claude`):** `tests/unit/cli/setup-claude.test.ts` failed ~50% of runs with `Unable to deserialize cloned data due to invalid or unsupported version` at file teardown (all subtests passed), randomly reddening `Unit Tests fast-path (2/2)` / `Fast Quality Gates` across the PR→release queue. Root cause: `node --test` streams each file's report to the parent as V8-serialized frames on fd 1 (stdout), and the CLI helper under test (`syncClaudeProfilesFromModels`) prints progress via `console.log` — that stdout output interleaved with the serialized frames and corrupted the stream. The test now silences the stdout-writing `console` methods for the file's duration (no assertion inspects stdout), making it deterministic (15/15 green locally). ([#5959](https://github.com/diegosouzapw/OmniRoute/issues/5959))
|
||||
@@ -103,6 +114,8 @@
|
||||
|
||||
- **providers (CLI profile auto-sync):** opt-in CLI profile auto-sync toggles, including Claude Code auto-sync, so generated CLI profiles can track provider changes automatically. ([#5755](https://github.com/diegosouzapw/OmniRoute/pull/5755) — thanks @diegosouzapw)
|
||||
|
||||
- **feat(minimax):** surface MiniMax M3 `<think>` reasoning as `reasoning_content` on OpenAI-format provider tiers. (thanks @zmf963)
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **fix(opencode):** stop fabricating `User-Agent: opencode/local` and `x-opencode-client: cli` headers when the client sends none — the executor-dedup refactor ([#5720](https://github.com/diegosouzapw/OmniRoute/pull/5720)) accidentally re-introduced header fabrication, violating the forward-only contract (inventing opencode-internal values risks upstream rejection). Restored to forward-only: those headers are emitted only when a real client source is present. Regression guard: `tests/unit/opencode-executor.test.ts`. (thanks @diegosouzapw)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.",
|
||||
"_rebaseline_2026_07_03_review_prs_release_green": "Release-green unblock (2026-07-03, /review-prs): the quality.yml fast-gates job was base-red for EVERY PR->release from growth inherited via already-merged PRs on the release tip — no offending PR branch left to fix in-place. Prod frozen raised: ApiManagerPageClient.tsx 3017->3058, OAuthModal.tsx 969->989, cliRuntime.ts 1090->1100, webProvidersA.ts 805->809. Test frozen raised: deepseek-web.test.ts 1081->1092. Real sizes (check-file-size.mjs reported). These stay frozen (cannot grow further); structural shrink tracked under decomposition roadmap #3501; the release captain's rebaseline-at-release supersedes this note. Bundled with the #5695 quick-start test regex fix (multi-line <Link> tolerance) in the same release-green PR.",
|
||||
"_rebaseline_2026_07_02_5798_release_green": "Release-green unblock #5798 / PR #5896 (2026-07-02): the quality.yml fast-gates job was base-red for EVERY PR->release (whole queue failing), from growth inherited via already-merged PRs — no offending PR branch left to fix. Prod frozen raised: AddApiKeyModal.tsx 869->905, providerPageHelpers.ts 996->1021, RequestLoggerV2.tsx 1316->1553, src/sse/services/auth.ts 2403->2405, antigravity.ts 1806->1813, base.ts 1502->1536 (1533 inherited + 3 lines from this PR's own typecheck:core fix in resolveBaseUrl), advancedTools.ts 1118->1120, accountFallback.ts 1783->1790, openai-to-kiro.ts 842->853, openai-responses.ts 1035->1092, stream.ts 2710->2727; new-above-cap frozen: webProvidersA.ts 805, tokenHealthCheck.ts 830. Test frozen raised: cc-compatible-provider 1179->1217, translator-openai-to-kiro 999->1088, web-cookie-providers-new 827->845; new-above-cap: response-sanitizer.test.ts 906. These files remain frozen (cannot grow further); the release captain's rebaseline-at-release supersedes this note.",
|
||||
"_rebaseline_2026_06_30_5552_flat_rate_cost": "Issue #5552 own growth: src/app/api/usage/analytics/route.ts 941->942 (+1 = the `flatRateAsZero: true` cost option at the existing computeUsageRowCost chokepoint, so subscription/cookie-web providers show $0 instead of an inflated per-token estimate in analytics). The flat-rate classifier (isFlatRateProvider + the provider-id set) lives in a new leaf src/lib/usage/flatRateProviders.ts (61 LOC, <cap) and the guard in computeCostFromPricing (src/lib/usage/costCalculator.ts, not baselined). Irreducible 1-line wiring at the analytics chokepoint; covered by tests/unit/flat-rate-cost-5552.test.ts.",
|
||||
"_rebaseline_2026_06_30_5483_force_responses_upstream": "PR #5637 (@KooshaPari, #5483) own growth: open-sse/executors/base.ts 1500->1502 (+2 = import + the single `requestCredentials = withForcedResponsesUpstream(...)` const threaded through buildUrl/buildHeaders/applyConfiguredUserAgent/ccRequestDefaults/transformRequest at the existing fetch-loop chokepoint), open-sse/executors/default.ts 876->877 (+1 = the `_omnirouteForceResponsesUpstream` short-circuit in the buildUrl `/responses` vs `/chat/completions` decision), tests/unit/executor-default-base.test.ts 1477->1523 (+46 = the new regression test that asserts a Responses-shaped MCP request routes to /responses for openai-compatible providers). The detection helpers (shouldForceResponsesUpstream/withForcedResponsesUpstream/isRecord, ~50 LOC) were EXTRACTED out of base.ts into a new leaf open-sse/executors/forceResponsesUpstream.ts (60 LOC, <cap) so the frozen god-file only takes the irreducible 2-line wiring. The contributor PR's incidental prettier reformats were reverted to keep the diff scoped to the fix. Covered by tests/unit/executor-default-base.test.ts.",
|
||||
@@ -184,7 +185,7 @@
|
||||
"open-sse/utils/stream.ts": 2727,
|
||||
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1385,
|
||||
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1028,
|
||||
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3017,
|
||||
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3058,
|
||||
"src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": 1105,
|
||||
"src/app/(dashboard)/dashboard/cache/page.tsx": 845,
|
||||
"src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": 900,
|
||||
@@ -242,14 +243,14 @@
|
||||
"src/lib/usage/providerLimits.ts": 955,
|
||||
"src/lib/usage/usageHistory.ts": 988,
|
||||
"_rebaseline_2026_06_27_5193_5203_antigravity_oauthmodal": "Antigravity remote-login own growth: OAuthModal.tsx 960->969 (gate units). #5193 (+~4: remote paste instruction shown for all remote incl. Google + its rationale comment) and #5203 (+~5: handleManualSubmit credential-blob branch + button guard; submit logic extracted to oauthBlobSubmit.ts to minimize). Frozen set to the SUM so either merge order passes. Cohesive at the existing manual-submit chokepoint.",
|
||||
"src/shared/components/OAuthModal.tsx": 969,
|
||||
"src/shared/components/OAuthModal.tsx": 989,
|
||||
"src/shared/components/RequestLoggerV2.tsx": 1553,
|
||||
"src/shared/components/analytics/charts.tsx": 1558,
|
||||
"src/shared/constants/cliTools.ts": 875,
|
||||
"src/shared/constants/pricing.ts": 1662,
|
||||
"src/shared/constants/providers.ts": 3276,
|
||||
"src/shared/constants/sidebarVisibility.ts": 1198,
|
||||
"src/shared/services/cliRuntime.ts": 1090,
|
||||
"src/shared/services/cliRuntime.ts": 1100,
|
||||
"src/shared/validation/schemas.ts": 2523,
|
||||
"_rebaseline_2026_06_28_5275_correlation_id_extract": "Extraction of the safe CorrelationId subset of #5275 (hartmark) — request correlation id stored in call_logs (migration 109) and returned via the X-Correlation-Id response header, WITHOUT the combo/resilience or build/lazy-loading changes (those stay in #5275). Own growth: callLogs.ts 975->985 (correlation_id column on CallLogSummaryRow + read/map), usageHistory.ts 983->988 (correlationId metadata normalize), chat.ts 1575->1632 (withCorrelationId response wiring + combo-failure log carrying correlationId), chatHelpers.ts new 811 (withCorrelationId helper + reqId threading; was 791<cap pre-feature). Cohesive request/logging chokepoint wiring; structural shrink of chat.ts tracked in #3501.",
|
||||
"src/sse/handlers/chat.ts": 1647,
|
||||
@@ -263,7 +264,7 @@
|
||||
"tests/unit/provider-validation-specialty.test.ts": 2843,
|
||||
"open-sse/executors/huggingchat.ts": 813,
|
||||
"_rebaseline_2026_07_01_v3843_release_5609": "Rebaseline v3.8.43 (PR #5609 release reconciliation). DRIFT dos 109 commits do ciclo: 8 god-files existentes cresceram (ApiManagerPageClient 2983->3017, combos/page 4594->4608, AddApiKeyModal 868->869, providerPageHelpers 974->996, chat.ts 1635->1647, auth.ts 2401->2403, batchProcessor 828->915, combo.ts 3368->3387) + 2 novos acima do cap (huggingchat.ts 813, tests web-cookie-providers-new 827) + 4 test files cresceram. Modularizacao deferida (blast-radius mid-release); congelado no estado atual p/ o proximo ciclo ratchetar daqui.",
|
||||
"src/lib/providers/validation/webProvidersA.ts": 805,
|
||||
"src/lib/providers/validation/webProvidersA.ts": 809,
|
||||
"src/lib/tokenHealthCheck.ts": 830
|
||||
},
|
||||
"testCap": 800,
|
||||
@@ -283,7 +284,7 @@
|
||||
"tests/unit/db-core-init.test.ts": 877,
|
||||
"tests/unit/db-migration-runner.test.ts": 1491,
|
||||
"tests/unit/db-settings-crud.test.ts": 941,
|
||||
"tests/unit/deepseek-web.test.ts": 1081,
|
||||
"tests/unit/deepseek-web.test.ts": 1092,
|
||||
"tests/unit/executor-antigravity.test.ts": 942,
|
||||
"tests/unit/executor-codex.test.ts": 1347,
|
||||
"tests/unit/executor-default-base.test.ts": 1523,
|
||||
@@ -310,7 +311,7 @@
|
||||
"tests/unit/translator-helper-branches.test.ts": 870,
|
||||
"tests/unit/translator-openai-responses-req.test.ts": 1172,
|
||||
"tests/unit/translator-openai-to-gemini.test.ts": 1579,
|
||||
"tests/unit/translator-openai-to-kiro.test.ts": 1088,
|
||||
"tests/unit/translator-openai-to-kiro.test.ts": 1093,
|
||||
"tests/unit/translator-resp-gemini-to-openai.test.ts": 1234,
|
||||
"tests/unit/usage-service-hardening.test.ts": 1633,
|
||||
"tests/unit/vscode-token-routes.test.ts": 1212,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: "Embedded Services"
|
||||
description: "Reference for 9Router and CLIProxyAPI"
|
||||
description: "Reference for 9Router, CLIProxyAPI, Mux, and Bifrost"
|
||||
---
|
||||
|
||||
# Embedded Services
|
||||
|
||||
> **Version:** v3.8.4
|
||||
> **Last updated:** 2026-06-28
|
||||
> **Audience:** Engineers adding, maintaining, or debugging embedded services (9Router, CLIProxyAPI).
|
||||
> **Version:** v3.8.44
|
||||
> **Last updated:** 2026-07-03
|
||||
> **Audience:** Engineers adding, maintaining, or debugging embedded services (9Router, CLIProxyAPI, Mux, Bifrost).
|
||||
|
||||
Embedded services are locally-installed process sidecar tools that OmniRoute installs, supervises, and
|
||||
exposes as first-class routing targets. Unlike external providers (which are reached over the internet
|
||||
@@ -32,18 +32,20 @@ via API keys), embedded services run on the same machine as OmniRoute and commun
|
||||
|
||||
### Why embedded services?
|
||||
|
||||
Two services are embedded as of v3.8.4:
|
||||
Four services are embedded as of v3.8.44:
|
||||
|
||||
| Service | npm package | Default port | Purpose |
|
||||
| --------------- | ---------------------------------------------- | :----------: | ---------------------------------------------------------------------------------------------------- |
|
||||
| **9Router** | `9router` | 20130 | AI router that OmniRoute can use as a sub-provider. Models exposed as `9router/{sub}/{model}` |
|
||||
| **CLIProxyAPI** | `@anthropic/cli-proxy` (via `cliproxy` binary) | auto | Local proxy adapter for Anthropic CLI auth flows. Provides fallback routing when OAuth tokens expire |
|
||||
| Service | npm package | Default port | Purpose |
|
||||
| --------------- | ----------------------------------------------- | :----------: | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| **9Router** | `9router` | 20130 | AI router that OmniRoute can use as a sub-provider. Models exposed as `9router/{sub}/{model}` |
|
||||
| **CLIProxyAPI** | `@anthropic/cli-proxy` (via `cliproxy` binary) | auto | Local proxy adapter for Anthropic CLI auth flows. Provides fallback routing when OAuth tokens expire |
|
||||
| **Mux** | `mux` (headless `mux server`) | 8322 | Local agent-orchestration daemon (coder/mux). Lifecycle-managed only — not a routing target (no LLM proxying). |
|
||||
| **Bifrost** | `@maximhq/bifrost` | 8080 | Go AI-gateway relay backend. When running, auto-selected by the relay route (`/v1/relay/`) |
|
||||
|
||||
Both follow the same supervisory model:
|
||||
All four follow the same supervisory model:
|
||||
|
||||
- OmniRoute installs them under `DATA_DIR/services/{name}/` (isolated from OmniRoute's own `package.json`)
|
||||
- OmniRoute spawns and monitors them as child processes
|
||||
- OmniRoute injects an ephemeral API key into the child's environment and rotates it without downtime
|
||||
- OmniRoute injects an ephemeral API key into the child's environment and rotates it without downtime (where applicable)
|
||||
- All management routes (`/api/services/*`) are **LOCAL_ONLY** — accessible only from loopback (hard rule #17)
|
||||
|
||||
### Key decisions (from design plan)
|
||||
@@ -54,7 +56,7 @@ Both follow the same supervisory model:
|
||||
| Installation mechanism | `npm install {package}` via `execFile` (no shell interpolation) |
|
||||
| Consumption mode | Provider registered as `9router/{sub}/{model}` in routing engine |
|
||||
| API key management | OmniRoute generates, encrypts at-rest (AES-256-GCM), and injects via env |
|
||||
| Dashboard location | `/dashboard/providers/services` (two tabs) |
|
||||
| Dashboard location | `/dashboard/providers/services` (three tabs) |
|
||||
| Auto-start | Toggle per service, default OFF |
|
||||
|
||||
---
|
||||
@@ -64,12 +66,13 @@ Both follow the same supervisory model:
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ Layer 1 — UI │
|
||||
│ /dashboard/providers/services (tabs: CLIProxyAPI | 9Router) │
|
||||
│ /dashboard/providers/services (tabs: CLIProxyAPI | 9Router | Mux)│
|
||||
│ Logs live (SSE), Start/Stop/Restart/Update, Settings, Install │
|
||||
│ │
|
||||
│ src/app/(dashboard)/dashboard/providers/services/ │
|
||||
│ ├── page.tsx Shell + tab routing by ?tab= │
|
||||
│ ├── tabs/ CliproxyServiceTab, NinerouterServiceTab│
|
||||
│ ├── tabs/ CliproxyServiceTab, NinerouterServiceTab,│
|
||||
│ │ MuxServiceTab │
|
||||
│ └── components/ ServiceStatusCard, ServiceLifecycleButtons,│
|
||||
│ ServiceLogsPanel, ApiKeyCard, ... │
|
||||
└──────────────────────┬─────────────────────────────────────────────┘
|
||||
@@ -81,6 +84,8 @@ Both follow the same supervisory model:
|
||||
│ rotate-key|status|auto-start|logs} │
|
||||
│ /api/services/cliproxy/{install|start|stop|restart|update| │
|
||||
│ status|auto-start|logs} │
|
||||
│ /api/services/mux/{install|start|stop|restart|update| │
|
||||
│ status|auto-start|logs} │
|
||||
│ /dashboard/providers/services/9router/embed/[...path] │
|
||||
│ (reverse HTTP + WebSocket proxy → 9Router upstream) │
|
||||
│ │
|
||||
@@ -106,7 +111,8 @@ Both follow the same supervisory model:
|
||||
│ modelSync.ts Periodic GET /v1/models → service_models table │
|
||||
│ ringBuffer.ts Circular log buffer (5 MB per service) │
|
||||
│ healthCheck.ts Polling HTTP health probe │
|
||||
│ installers/ ninerouter.ts, cliproxy.ts (installer adapters)│
|
||||
│ installers/ ninerouter.ts, cliproxy.ts, mux.ts │
|
||||
│ (installer adapters) │
|
||||
└──────────────────────┬─────────────────────────────────────────────┘
|
||||
│ OpenAI-compatible HTTP (loopback)
|
||||
┌──────────────────────▼─────────────────────────────────────────────┐
|
||||
@@ -123,6 +129,10 @@ Both follow the same supervisory model:
|
||||
│ open-sse/config/providerRegistry.ts │
|
||||
│ Models stored as "9router/{sub}/{model}" (prefixed). │
|
||||
│ Synced every 5 min by modelSync.ts. │
|
||||
│ │
|
||||
│ Mux is lifecycle-managed ONLY (Layers 1-3) — it is an agent- │
|
||||
│ orchestration daemon, not an LLM proxy, so it has no Layer 4 │
|
||||
│ executor/provider entry and is never a routing target. │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -139,6 +149,7 @@ Both follow the same supervisory model:
|
||||
| `src/lib/services/healthCheck.ts` | HTTP health probe (configurable interval) |
|
||||
| `src/lib/services/installers/ninerouter.ts` | npm install/update/uninstall for 9Router |
|
||||
| `src/lib/services/installers/cliproxy.ts` | npm install/update/uninstall for CLIProxyAPI |
|
||||
| `src/lib/services/installers/mux.ts` | npm install/update/uninstall for Mux |
|
||||
| `src/app/api/services/9router/_lib.ts` | `getOrInitSupervisor()` helper |
|
||||
| `src/app/api/services/[name]/logs/route.ts` | Shared SSE logs endpoint |
|
||||
| `open-sse/executors/ninerouter.ts` | Provider executor (Layer 4) |
|
||||
@@ -434,12 +445,56 @@ config) and `status` includes fewer fields.
|
||||
| `GET` | `/api/services/cliproxy/status` | Live + DB status (no `apiKeyMasked`) |
|
||||
| `POST` | `/api/services/cliproxy/auto-start` | Toggle auto-start |
|
||||
|
||||
The shared `GET /api/services/{name}/logs` endpoint (see §4.1) works for both
|
||||
services using the `[name]` dynamic segment.
|
||||
The shared `GET /api/services/{name}/logs` endpoint (see §4.1) works for all
|
||||
four services using the `[name]` dynamic segment.
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Reverse proxy (9Router dashboard embed)
|
||||
### 4.3 Mux endpoints (7 routes)
|
||||
|
||||
Mux has the same endpoint shape as CLIProxyAPI — no `rotate-key` route in the API
|
||||
surface (the bearer token is generated the same way as 9Router's via
|
||||
`getOrCreateApiKey("mux")` and injected via the `MUX_SERVER_AUTH_TOKEN` env var, but
|
||||
there is no dedicated rotation endpoint yet). Mux is lifecycle-managed only: unlike
|
||||
9Router, it has no Layer 4 executor and is never registered as a routing provider.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | -------------------------------- | ------------------------------------- |
|
||||
| `POST` | `/api/services/mux/install` | Install Mux from npm (`npm i mux`) |
|
||||
| `POST` | `/api/services/mux/start` | Start Mux (`mux server`) |
|
||||
| `POST` | `/api/services/mux/stop` | Stop Mux |
|
||||
| `POST` | `/api/services/mux/restart` | Restart Mux |
|
||||
| `POST` | `/api/services/mux/update` | Update to newer npm version |
|
||||
| `GET` | `/api/services/mux/status` | Live + DB status |
|
||||
| `POST` | `/api/services/mux/auto-start` | Toggle auto-start |
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Bifrost endpoints (7 routes)
|
||||
|
||||
Bifrost is a Go AI-gateway relay backend (`@maximhq/bifrost`). It uses the same
|
||||
endpoint shape as CLIProxyAPI (no `rotate-key` — Bifrost manages its own provider
|
||||
keys in `config.json` under its `-app-dir`).
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---------------------------------- | ------------------------------------------------------ |
|
||||
| `POST` | `/api/services/bifrost/install` | Install Bifrost from npm (`@maximhq/bifrost`) |
|
||||
| `POST` | `/api/services/bifrost/start` | Start Bifrost on port 8080 (default) |
|
||||
| `POST` | `/api/services/bifrost/stop` | Stop Bifrost |
|
||||
| `POST` | `/api/services/bifrost/restart` | Restart Bifrost |
|
||||
| `POST` | `/api/services/bifrost/update` | Update to newer version |
|
||||
| `GET` | `/api/services/bifrost/status` | Live + DB status |
|
||||
| `POST` | `/api/services/bifrost/auto-start` | Toggle auto-start |
|
||||
| `GET` | `/api/services/bifrost/logs` | SSE log tail (via shared `[name]/logs` dynamic route) |
|
||||
|
||||
**Routing wiring:** When `BIFROST_BASE_URL` is unset and the supervised Bifrost
|
||||
instance is running, `getBifrostRoutingConfig()` (in `routingBackend.ts`) automatically
|
||||
uses `http://127.0.0.1:{port}` as the relay base URL. Explicit `BIFROST_BASE_URL` env
|
||||
always takes precedence.
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Reverse proxy (9Router dashboard embed)
|
||||
|
||||
The dashboard embeds the 9Router web UI inside an iframe via an internal reverse
|
||||
proxy at:
|
||||
@@ -486,14 +541,20 @@ matrix.
|
||||
|
||||
### API key injection
|
||||
|
||||
9Router requires an API key for its own HTTP endpoints. OmniRoute:
|
||||
9Router and Mux require an API key/bearer token for their own HTTP endpoints.
|
||||
OmniRoute:
|
||||
|
||||
1. Generates a key via `crypto.randomBytes(32).toString("base64url")` with a
|
||||
service-specific prefix (`nr_` for 9Router).
|
||||
service-specific prefix (`nr_` for 9Router, `mx_` for Mux).
|
||||
2. Encrypts it at-rest using AES-256-GCM (same cipher used for provider credentials).
|
||||
3. Decrypts and injects it as `NINEROUTER_API_KEY` environment variable at spawn time.
|
||||
3. Decrypts and injects it as an environment variable at spawn time —
|
||||
`NINEROUTER_API_KEY` for 9Router, `MUX_SERVER_AUTH_TOKEN` for Mux (never a CLI
|
||||
flag, so the token never appears in `ps`/process listings).
|
||||
4. Never returns the plaintext key in any HTTP response.
|
||||
|
||||
CLIProxyAPI does not require an injected key (it authenticates via the host's
|
||||
existing CLI config).
|
||||
|
||||
### SSRF defense
|
||||
|
||||
The reverse HTTP proxy (`/dashboard/.../embed/[...path]`) is hardcoded to forward
|
||||
|
||||
@@ -3444,6 +3444,276 @@ paths:
|
||||
"400":
|
||||
description: Invalid request body
|
||||
|
||||
/api/services/mux/install:
|
||||
post:
|
||||
tags: [Embedded Services]
|
||||
summary: Install Mux from npm
|
||||
description: >-
|
||||
Installs the `mux` npm package (coder/mux — local agent-orchestration
|
||||
daemon) under DATA_DIR/services/mux/. **LOCAL_ONLY** — loopback only.
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
default: latest
|
||||
responses:
|
||||
"200":
|
||||
description: Install succeeded
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
installedVersion:
|
||||
type: string
|
||||
"400":
|
||||
description: Invalid request body
|
||||
"500":
|
||||
description: npm install failed
|
||||
|
||||
/api/services/mux/start:
|
||||
post:
|
||||
tags: [Embedded Services]
|
||||
summary: Start Mux
|
||||
description: >-
|
||||
Spawns `mux server --host 127.0.0.1 --port <port>`. Idempotent if
|
||||
already running. **LOCAL_ONLY** — loopback only.
|
||||
responses:
|
||||
"200":
|
||||
description: Service started
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServiceStatus"
|
||||
"409":
|
||||
description: Mux is not installed
|
||||
"503":
|
||||
description: Start failed
|
||||
|
||||
/api/services/mux/stop:
|
||||
post:
|
||||
tags: [Embedded Services]
|
||||
summary: Stop Mux
|
||||
description: >-
|
||||
Gracefully stops Mux. Idempotent.
|
||||
**LOCAL_ONLY** — loopback only.
|
||||
responses:
|
||||
"200":
|
||||
description: Service stopped
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServiceStatus"
|
||||
|
||||
/api/services/mux/restart:
|
||||
post:
|
||||
tags: [Embedded Services]
|
||||
summary: Restart Mux
|
||||
description: >-
|
||||
stop() then start() under the operation lock.
|
||||
**LOCAL_ONLY** — loopback only.
|
||||
responses:
|
||||
"200":
|
||||
description: Service restarted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServiceStatus"
|
||||
|
||||
/api/services/mux/update:
|
||||
post:
|
||||
tags: [Embedded Services]
|
||||
summary: Update Mux to a newer npm version
|
||||
description: >-
|
||||
Stops, installs newer version, restarts.
|
||||
**LOCAL_ONLY** — loopback only.
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
default: latest
|
||||
responses:
|
||||
"200":
|
||||
description: Update succeeded
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
installedVersion:
|
||||
type: string
|
||||
"500":
|
||||
description: Update failed
|
||||
|
||||
/api/services/mux/status:
|
||||
get:
|
||||
tags: [Embedded Services]
|
||||
summary: Get Mux status
|
||||
description: >-
|
||||
Returns live supervisor state and DB metadata.
|
||||
**LOCAL_ONLY** — loopback only.
|
||||
responses:
|
||||
"200":
|
||||
description: Status response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServiceStatus"
|
||||
|
||||
/api/services/mux/auto-start:
|
||||
post:
|
||||
tags: [Embedded Services]
|
||||
summary: Toggle Mux auto-start
|
||||
description: >-
|
||||
When enabled, Mux starts automatically on the next OmniRoute boot.
|
||||
**LOCAL_ONLY** — loopback only.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [enabled]
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: Auto-start flag updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
autoStart:
|
||||
type: boolean
|
||||
"400":
|
||||
description: Invalid request body
|
||||
|
||||
/api/services/bifrost/install:
|
||||
post:
|
||||
tags: [Embedded Services]
|
||||
summary: Install Bifrost
|
||||
description: >-
|
||||
Installs the `@maximhq/bifrost` npm package under DATA_DIR/services/bifrost/.
|
||||
The package downloads the Go binary on first run. Accepts an optional `version`
|
||||
field (semver or `latest`). **LOCAL_ONLY** — loopback only.
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
default: latest
|
||||
responses:
|
||||
"200":
|
||||
description: Installation result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
installedVersion:
|
||||
type: string
|
||||
installPath:
|
||||
type: string
|
||||
durationMs:
|
||||
type: number
|
||||
|
||||
/api/services/bifrost/start:
|
||||
post:
|
||||
tags: [Embedded Services]
|
||||
summary: Start Bifrost
|
||||
description: Starts the supervised Bifrost process. **LOCAL_ONLY** — loopback only.
|
||||
responses:
|
||||
"200":
|
||||
description: Service status after start
|
||||
"409":
|
||||
description: Bifrost is not installed
|
||||
|
||||
/api/services/bifrost/stop:
|
||||
post:
|
||||
tags: [Embedded Services]
|
||||
summary: Stop Bifrost
|
||||
description: Stops the supervised Bifrost process. **LOCAL_ONLY** — loopback only.
|
||||
responses:
|
||||
"200":
|
||||
description: Service status after stop
|
||||
|
||||
/api/services/bifrost/restart:
|
||||
post:
|
||||
tags: [Embedded Services]
|
||||
summary: Restart Bifrost
|
||||
description: Restarts the supervised Bifrost process. **LOCAL_ONLY** — loopback only.
|
||||
responses:
|
||||
"200":
|
||||
description: Service status after restart
|
||||
"409":
|
||||
description: Bifrost is not installed
|
||||
|
||||
/api/services/bifrost/update:
|
||||
post:
|
||||
tags: [Embedded Services]
|
||||
summary: Update Bifrost
|
||||
description: >-
|
||||
Updates Bifrost to the latest npm version. Stops the running process,
|
||||
installs the new version, and restarts if it was previously running.
|
||||
**LOCAL_ONLY** — loopback only.
|
||||
responses:
|
||||
"200":
|
||||
description: Update result
|
||||
|
||||
/api/services/bifrost/status:
|
||||
get:
|
||||
tags: [Embedded Services]
|
||||
summary: Get Bifrost status
|
||||
description: Returns live and DB status for the supervised Bifrost service. **LOCAL_ONLY** — loopback only.
|
||||
responses:
|
||||
"200":
|
||||
description: Bifrost service status
|
||||
|
||||
/api/services/bifrost/auto-start:
|
||||
post:
|
||||
tags: [Embedded Services]
|
||||
summary: Toggle Bifrost auto-start
|
||||
description: >-
|
||||
When enabled, Bifrost starts automatically on the next OmniRoute boot.
|
||||
**LOCAL_ONLY** — loopback only.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [enabled]
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
responses:
|
||||
"204":
|
||||
description: Auto-start flag updated
|
||||
"400":
|
||||
description: Invalid request body
|
||||
|
||||
/api/services/{name}/logs:
|
||||
get:
|
||||
tags: [Embedded Services]
|
||||
@@ -5264,6 +5534,214 @@ paths:
|
||||
$ref: "#/components/responses/InternalError"
|
||||
"503":
|
||||
description: Generator module not available
|
||||
/api/v1/ocr:
|
||||
post:
|
||||
tags:
|
||||
- Images
|
||||
summary: Document OCR
|
||||
description: >-
|
||||
Mistral OCR–compatible document OCR endpoint. Accepts a JSON body
|
||||
referencing a document/image and returns extracted text. Success
|
||||
responses carry the `X-OmniRoute-*` cost-telemetry headers.
|
||||
security:
|
||||
- BearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
model:
|
||||
type: string
|
||||
document:
|
||||
type: object
|
||||
responses:
|
||||
"200":
|
||||
description: OCR result with extracted text.
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/audio/translations:
|
||||
post:
|
||||
tags:
|
||||
- Audio
|
||||
summary: Translate audio to English
|
||||
description: >-
|
||||
OpenAI Whisper–compatible audio translation (multipart/form-data).
|
||||
Unlike `/api/v1/audio/transcriptions`, output is always English
|
||||
regardless of the source language. Success responses carry the
|
||||
`X-OmniRoute-*` cost-telemetry headers.
|
||||
security:
|
||||
- BearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- file
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
model:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: English translation of the audio.
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/providers/suggested-models:
|
||||
get:
|
||||
tags:
|
||||
- Providers
|
||||
summary: Suggested media models
|
||||
description: >-
|
||||
Read-only server-side proxy to the public HuggingFace Hub models search
|
||||
API, used by the dashboard to suggest models for a media provider kind
|
||||
without exposing an HF token client-side. Never accepts or returns
|
||||
credentials.
|
||||
parameters:
|
||||
- name: type
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: Media kind to search for (e.g. `image`, `audio`, `video`).
|
||||
responses:
|
||||
"200":
|
||||
description: List of suggested HuggingFace Hub models.
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/provider-plugin-manifest:
|
||||
get:
|
||||
tags:
|
||||
- Providers
|
||||
summary: Provider plugin manifest
|
||||
description: Returns the manifest describing installed provider plugins.
|
||||
responses:
|
||||
"200":
|
||||
description: Provider plugin manifest.
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/keys/{id}/devices:
|
||||
get:
|
||||
tags:
|
||||
- API Keys
|
||||
summary: List devices for an API key
|
||||
description: >-
|
||||
Lists the distinct devices (masked IP + User-Agent fingerprints)
|
||||
tracked for an API key by the in-memory device tracker. IPs are masked
|
||||
before storage; the route never sees the raw client IP.
|
||||
x-internal: true
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Distinct devices seen for the API key.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
/api/settings/purge-usage-history:
|
||||
post:
|
||||
tags:
|
||||
- Settings
|
||||
summary: Purge usage history
|
||||
description: Dashboard-only. Purges stored usage-history records.
|
||||
x-internal: true
|
||||
responses:
|
||||
"200":
|
||||
description: Usage history purged.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
/api/oauth/codex/import-token:
|
||||
post:
|
||||
tags:
|
||||
- OAuth
|
||||
summary: Import a Codex connection from a bare access token
|
||||
description: >-
|
||||
Dashboard-only. Creates a Codex (ChatGPT/OpenAI) connection from a raw
|
||||
access token with no refresh token (authType `access_token`).
|
||||
x-internal: true
|
||||
responses:
|
||||
"200":
|
||||
description: Connection imported.
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
/api/cli-tools/crush-settings:
|
||||
get:
|
||||
tags:
|
||||
- CLI Tools
|
||||
summary: Read Crush CLI OmniRoute config
|
||||
description: Local-only. Reads the OmniRoute provider block in Crush's config.
|
||||
x-internal: true
|
||||
responses:
|
||||
"200":
|
||||
description: Current Crush config state.
|
||||
post:
|
||||
tags:
|
||||
- CLI Tools
|
||||
summary: Write Crush CLI OmniRoute config
|
||||
description: Local-only. Registers OmniRoute as an `openai-compat` provider in Crush's config.
|
||||
x-internal: true
|
||||
responses:
|
||||
"200":
|
||||
description: Crush config updated.
|
||||
delete:
|
||||
tags:
|
||||
- CLI Tools
|
||||
summary: Remove OmniRoute from Crush CLI config
|
||||
description: Local-only. Removes the OmniRoute provider block from Crush's config.
|
||||
x-internal: true
|
||||
responses:
|
||||
"200":
|
||||
description: Crush config entry removed.
|
||||
/api/cli-tools/codewhale-settings:
|
||||
get:
|
||||
tags:
|
||||
- CLI Tools
|
||||
summary: Read CodeWhale CLI OmniRoute config
|
||||
description: >-
|
||||
Local-only. Reads the OmniRoute config block from
|
||||
`~/.codewhale/config.toml` (with `~/.deepseek/config.toml` legacy
|
||||
fallback).
|
||||
x-internal: true
|
||||
responses:
|
||||
"200":
|
||||
description: Current CodeWhale config state.
|
||||
post:
|
||||
tags:
|
||||
- CLI Tools
|
||||
summary: Write CodeWhale CLI OmniRoute config
|
||||
description: Local-only. Writes the OmniRoute config block in CodeWhale TOML format.
|
||||
x-internal: true
|
||||
responses:
|
||||
"200":
|
||||
description: CodeWhale config updated.
|
||||
delete:
|
||||
tags:
|
||||
- CLI Tools
|
||||
summary: Remove OmniRoute from CodeWhale CLI config
|
||||
description: Local-only. Removes the OmniRoute config block from CodeWhale's config.
|
||||
x-internal: true
|
||||
responses:
|
||||
"200":
|
||||
description: CodeWhale config entry removed.
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
|
||||
@@ -814,6 +814,7 @@ Automatic model pricing data synchronization from external sources.
|
||||
| `CLIPROXYAPI_HOST` | `127.0.0.1` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge host (legacy integration). |
|
||||
| `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge port. |
|
||||
| `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI config directory. |
|
||||
| `MUX_SERVICE_PORT` | `8322` | `src/lib/services/bootstrap.ts` | Override the port where the embedded Mux (coder/mux) agent-orchestration daemon listens (always 127.0.0.1). |
|
||||
| `LOCAL_HOSTNAMES` | _(empty)_ | `open-sse/config/providerRegistry.ts` | Comma-separated additional hostnames treated as "local" (Docker service names, etc.). |
|
||||
|
||||
`ENABLE_CC_COMPATIBLE_PROVIDER` is only for third-party relays that accept Claude Code clients
|
||||
|
||||
@@ -14,8 +14,14 @@ export const kimi_webProvider: RegistryEntry = {
|
||||
authType: "apikey",
|
||||
authHeader: "cookie",
|
||||
models: [
|
||||
{ id: "kimi-default", name: "Kimi Default" },
|
||||
{ id: "kimi-k2.6", name: "Kimi K2.6 (Thinking)" },
|
||||
{ id: "kimi-128k", name: "Kimi 128K (Long Context)" },
|
||||
// Model ids are the `key` field from www.kimi.com's
|
||||
// `/apiv2/kimi.gateway.config.v1.ConfigService/GetAvailableModels` response.
|
||||
// Agent / Agent-Swarm variants (`k2d6-agent`, `k2d6-agent-ultra`) are
|
||||
// intentionally NOT exposed — they need a different scenario
|
||||
// (`SCENARIO_OK_COMPUTER`) plus `kimiPlusId` / `agentMode` fields, which
|
||||
// the executor does not yet shape. Use `kimi-coding` (api.kimi.com) for
|
||||
// agentic flows.
|
||||
{ id: "k2d6", name: "K2.6 Instant" },
|
||||
{ id: "k2d6-thinking", name: "K2.6 Thinking", supportsReasoning: true },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -36,7 +36,24 @@ const CHAT_URL = `${BASE_URL}/apiv2/kimi.gateway.chat.v1.ChatService/Chat`;
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
|
||||
|
||||
const DEFAULT_SCENARIO = "SCENARIO_K2D5";
|
||||
/**
|
||||
* Map a Kimi model id (the `key` field from `GetAvailableModels`) to the
|
||||
* request shape the upstream expects. Today only the chat-tier `k2d6` family
|
||||
* is supported — the agent variants (`k2d6-agent`, `k2d6-agent-ultra`) need
|
||||
* a different scenario (`SCENARIO_OK_COMPUTER`) plus `kimiPlusId` /
|
||||
* `agentMode` fields that this executor does not shape; users who need
|
||||
* agentic Kimi should use the `kimi-coding` (api.kimi.com) provider.
|
||||
*/
|
||||
export interface KimiModelConfig {
|
||||
scenario: string;
|
||||
thinking: boolean;
|
||||
}
|
||||
|
||||
export function resolveModelConfig(modelId: string): KimiModelConfig {
|
||||
if (modelId === "k2d6-thinking") return { scenario: "SCENARIO_K2D5", thinking: true };
|
||||
// `k2d6` (Instant) and any unknown id fall back to the default chat scenario.
|
||||
return { scenario: "SCENARIO_K2D5", thinking: false };
|
||||
}
|
||||
|
||||
/** Wrap a JSON message in the 5-byte Connect streaming envelope (flags + length). */
|
||||
export function frameConnectMessage(json: string): Uint8Array {
|
||||
@@ -206,14 +223,14 @@ export class KimiWebExecutor extends BaseExecutor {
|
||||
return headers;
|
||||
}
|
||||
|
||||
private buildRequestBody(prompt: string, wantThinking: boolean): string {
|
||||
private buildRequestBody(prompt: string, wantThinking: boolean, scenario: string): string {
|
||||
return JSON.stringify({
|
||||
scenario: DEFAULT_SCENARIO,
|
||||
scenario,
|
||||
tools: [{ type: "TOOL_TYPE_SEARCH", search: {} }, { type: "TOOL_TYPE_CRON_JOB" }],
|
||||
message: {
|
||||
role: "user",
|
||||
blocks: [{ message_id: "", text: { content: prompt } }],
|
||||
scenario: DEFAULT_SCENARIO,
|
||||
scenario,
|
||||
},
|
||||
options: { thinking: wantThinking, enable_plugin: true },
|
||||
});
|
||||
@@ -236,14 +253,13 @@ export class KimiWebExecutor extends BaseExecutor {
|
||||
|
||||
const messages = (bodyObj.messages as Array<{ role: string; content: unknown }>) || [];
|
||||
const modelId = (bodyObj.model as string) || "kimi-default";
|
||||
// Decide thinking intent. A user sending `reasoning_effort: "none"` is
|
||||
// explicit — honour it even when the model id suggests a thinking variant.
|
||||
// Otherwise thinking models (kimi-k2.6 etc.) default to thinking on.
|
||||
const modelWantsThinking = /k2\.6|k2-6|think/i.test(modelId);
|
||||
const wantThinking = bodyObj.reasoning_effort === "none" ? false : modelWantsThinking;
|
||||
// Resolve scenario + default thinking flag from the model id (catalog truth),
|
||||
// then honour an explicit `reasoning_effort: "none"` override from the caller.
|
||||
const modelConfig = resolveModelConfig(modelId);
|
||||
const wantThinking = bodyObj.reasoning_effort === "none" ? false : modelConfig.thinking;
|
||||
|
||||
const prompt = foldMessages(messages);
|
||||
const reqBody = this.buildRequestBody(prompt, wantThinking);
|
||||
const reqBody = this.buildRequestBody(prompt, wantThinking, modelConfig.scenario);
|
||||
const reqHeaders = this.buildKimiHeaders(jwt);
|
||||
|
||||
// Connect framing wraps the JSON body in a 5-byte envelope. Without it the
|
||||
|
||||
@@ -58,7 +58,9 @@ const MODEL_ALIASES: Record<string, string> = {
|
||||
"qwen3-plus": "qwen3.7-plus",
|
||||
"qwen3-max": "qwen3.7-max",
|
||||
"qwen3-flash": "qwen3.6-plus",
|
||||
"qwen3-coder-plus": "qwen3.7-max",
|
||||
// Note: `qwen3-coder-plus` is a real upstream model id (Qwen3-Coder) and
|
||||
// must NOT be aliased — the previous `"qwen3-coder-plus": "qwen3.7-max"`
|
||||
// entry silently rewrote valid coder requests to the wrong model.
|
||||
"qwen3-coder-flash": "qwen3.6-plus",
|
||||
qwen: "qwen3.7-max",
|
||||
qwen3: "qwen3.7-max",
|
||||
|
||||
@@ -252,6 +252,7 @@ import { isCompactResponsesEndpoint } from "../executors/codex.ts";
|
||||
import { buildCodexQuotaPersistence } from "./chatCore/codexQuota.ts";
|
||||
import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts";
|
||||
import { translateNonStreamingResponse } from "./responseTranslator.ts";
|
||||
import { unwrapClineNonStreamingEnvelope } from "./chatCore/clineResponseEnvelope.ts";
|
||||
import { extractUsageFromResponse } from "./usageExtractor.ts";
|
||||
import {
|
||||
sanitizeOpenAIResponse,
|
||||
@@ -3612,6 +3613,7 @@ export async function handleChatCore({
|
||||
}
|
||||
responseBody = unwrapped;
|
||||
}
|
||||
responseBody = unwrapClineNonStreamingEnvelope(provider, responseBody);
|
||||
|
||||
// Check for empty content response (fake success) - trigger fallback
|
||||
if (isEmptyContentResponse(responseBody)) {
|
||||
|
||||
25
open-sse/handlers/chatCore/clineResponseEnvelope.ts
Normal file
25
open-sse/handlers/chatCore/clineResponseEnvelope.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasOpenAIChoices(value: unknown): boolean {
|
||||
return isRecord(value) && Array.isArray(value.choices);
|
||||
}
|
||||
|
||||
export function unwrapClineNonStreamingEnvelope(provider: string, responseBody: unknown): unknown {
|
||||
if (provider !== "cline" || !isRecord(responseBody)) {
|
||||
return responseBody;
|
||||
}
|
||||
|
||||
const data = responseBody.data;
|
||||
if (!hasOpenAIChoices(data)) {
|
||||
return responseBody;
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
usage: isRecord(data) && data.usage !== undefined ? data.usage : responseBody.usage,
|
||||
};
|
||||
}
|
||||
@@ -129,7 +129,13 @@ export function isTextualReasoningTagNativeRoute(providerId: string, modelId: st
|
||||
return (
|
||||
/deepseek[-_/]?r1\b/.test(routeId) ||
|
||||
/r1[-_/]?distill\b/.test(routeId) ||
|
||||
/(?:^|[/:_-])qwq(?:[/._:-]|$)/.test(routeId)
|
||||
/(?:^|[/:_-])qwq(?:[/._:-]|$)/.test(routeId) ||
|
||||
// 9router#2231: MiniMax M3 leaks raw <think>...</think> into `content` on its
|
||||
// OpenAI-format provider tiers (trae, huggingchat, bazaarlink, ollama-cloud,
|
||||
// opencode, cline, opencode-zen, codebuddy-cn). The direct minimax/minimax-cn
|
||||
// tiers stay on Anthropic's Messages format (targetFormat: "claude") and
|
||||
// already surface reasoning natively, so they are excluded here.
|
||||
(providerId !== "minimax" && providerId !== "minimax-cn" && /minimax[-_]?m3\b/.test(routeId))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,12 +19,7 @@ import {
|
||||
} from "./accountFallback.ts";
|
||||
import { errorResponse, unavailableResponse } from "../utils/error.ts";
|
||||
import { buildTargetTimeoutRunner } from "./combo/targetTimeoutRunner.ts";
|
||||
import {
|
||||
recordComboIntent,
|
||||
recordComboRequest,
|
||||
recordComboShadowRequest,
|
||||
getComboMetrics,
|
||||
} from "./comboMetrics.ts";
|
||||
import { recordComboRequest, recordComboShadowRequest, getComboMetrics } from "./comboMetrics.ts";
|
||||
import {
|
||||
resolveComboConfig,
|
||||
getDefaultComboConfig,
|
||||
@@ -56,35 +51,18 @@ import { phaseComboSetup } from "./combo/comboSetup.ts";
|
||||
import { checkCredentialGate, logCredentialSkip } from "./credentialGate.ts";
|
||||
import { emit } from "../../src/lib/events/eventBus";
|
||||
import { notifyWebhookEvent } from "../../src/lib/webhookDispatcher";
|
||||
import { classifyWithConfig } from "./intentClassifier.ts";
|
||||
import { selectProvider as selectAutoProvider } from "./autoCombo/engine.ts";
|
||||
import {
|
||||
resolveRequestModePack,
|
||||
parseRequestBudgetCap,
|
||||
} from "./autoCombo/requestControls.ts";
|
||||
import { selectWithStrategy } from "./autoCombo/routerStrategy.ts";
|
||||
import { parseAutoPrefix } from "./autoCombo/autoPrefix.ts";
|
||||
import { resolveAutoStrategyOrder } from "./combo/resolveAutoStrategy.ts";
|
||||
import { applyStrategyOrdering } from "./combo/applyStrategyOrdering.ts";
|
||||
import { handlePipelineCombo, buildPipelineResponse } from "./autoCombo/pipelineRouter.ts";
|
||||
import {
|
||||
DEFAULT_WEIGHTS,
|
||||
type ProviderCandidate,
|
||||
type ScoringWeights,
|
||||
} from "./autoCombo/scoring.ts";
|
||||
import { supportsToolCalling } from "./modelCapabilities.ts";
|
||||
import { type ProviderCandidate } from "./autoCombo/scoring.ts";
|
||||
import { estimateTokens } from "./contextManager.ts";
|
||||
import { getSessionConnection } from "./sessionManager.ts";
|
||||
import { applySessionStickiness, recordStickyBinding } from "./combo/sessionStickiness.ts";
|
||||
import { selectQuotaShareTarget } from "./combo/quotaShareStrategy.ts";
|
||||
import {
|
||||
resolveMaxConcurrentByConnection,
|
||||
makeConnectionConcurrencyResolver,
|
||||
lookupPositiveCap,
|
||||
} from "./combo/concurrencyCaps.ts";
|
||||
import { makeConnectionConcurrencyResolver, lookupPositiveCap } from "./combo/concurrencyCaps.ts";
|
||||
import { acquireQuotaShareConcurrencySlot } from "./combo/quotaShareConcurrency.ts";
|
||||
import { orderTargetsByEvalScores } from "./evalRouting.ts";
|
||||
import { generateRoutingHints } from "./manifestAdapter";
|
||||
import type { RoutingHint } from "./manifestAdapter";
|
||||
import { buildComplexityRoutingHint } from "./autoCombo/complexityRouter";
|
||||
import type { CompressionMode } from "./compression/types.ts";
|
||||
import { getProviderConnections } from "../../src/lib/db/providers";
|
||||
import {
|
||||
@@ -149,50 +127,36 @@ import {
|
||||
} from "./combo/comboPredicates.ts";
|
||||
import { applyComboTargetExhaustion } from "./combo/targetExhaustion.ts";
|
||||
import { executeRuntimeUnitCombo } from "./combo/runtimeUnits.ts";
|
||||
import { dedupeTargetsByExecutionKey, isRecord } from "./combo/comboData.ts";
|
||||
import { isRecord } from "./combo/comboData.ts";
|
||||
import {
|
||||
expandProviderWildcardsInCombo,
|
||||
expandProviderWildcardsInCollection,
|
||||
} from "./combo/providerWildcard.ts";
|
||||
import { resolveShadowTargets, scheduleShadowRouting } from "./combo/shadowRouting.ts";
|
||||
import {
|
||||
sortTargetsByCost,
|
||||
sortTargetsByUsage,
|
||||
orderTargetsByPowerOfTwoChoices,
|
||||
} from "./combo/targetSorters.ts";
|
||||
import {
|
||||
filterTargetsByRequestCompatibility,
|
||||
getModelContextLimitForModelString,
|
||||
resolveComboRuntimeUnits,
|
||||
resolveComboTargets,
|
||||
resolveWeightedTargets,
|
||||
resolveWeightedStepGroups,
|
||||
sortTargetsByContextSize,
|
||||
} from "./combo/comboStructure.ts";
|
||||
import {
|
||||
QUOTA_SOFT_DEPRIORITIZE_FACTOR,
|
||||
setCandidateQuotaSoftPenalty,
|
||||
_registerExecutionCandidates,
|
||||
_unregisterExecutionCandidates,
|
||||
extractPromptForIntent,
|
||||
mapIntentToTaskType,
|
||||
getIntentConfig,
|
||||
applyRequestTagRouting,
|
||||
scoreAutoTargets,
|
||||
expandAutoComboCandidatePool,
|
||||
} from "./combo/autoStrategy.ts";
|
||||
import {
|
||||
resolveResetWindowConfig,
|
||||
resolveSlaRoutingPolicy,
|
||||
calculateResetWindowAffinity,
|
||||
type ResetWindowConfig,
|
||||
} from "./combo/quotaScoring.ts";
|
||||
import {
|
||||
fetchResetAwareQuotaWithCache,
|
||||
preScreenTargets,
|
||||
orderTargetsByResetAwareQuota,
|
||||
orderTargetsByResetWindow,
|
||||
orderTargetsByHeadroom,
|
||||
type PreScreenResult,
|
||||
} from "./combo/quotaStrategies.ts";
|
||||
import {
|
||||
@@ -1070,438 +1034,28 @@ export async function handleComboChat({
|
||||
// the fallback order, never override the router's primary choice.
|
||||
let autoUsedExplicitRouter = false;
|
||||
if (strategy === "auto") {
|
||||
const requestHasTools = Array.isArray(body?.tools) && body.tools.length > 0;
|
||||
let eligibleTargets = [...orderedTargets];
|
||||
|
||||
if (requestHasTools) {
|
||||
const filtered = eligibleTargets.filter((target) => supportsToolCalling(target.modelStr));
|
||||
if (filtered.length > 0) {
|
||||
eligibleTargets = filtered;
|
||||
} else {
|
||||
log.warn(
|
||||
"COMBO",
|
||||
"Auto strategy: all candidates filtered by tool-calling policy, falling back to full pool"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Context-window pre-filter (#1808)
|
||||
// Estimate input tokens once; exclude candidates whose known context limit is too small.
|
||||
// Uses the same 4-chars-per-token heuristic as contextManager.ts::compressContext().
|
||||
// Null/unknown limits are treated as "include" to avoid incorrectly dropping valid targets.
|
||||
const requestMessages = body.messages;
|
||||
const estimatedInputTokens = estimateTokens(
|
||||
typeof requestMessages === "string" ||
|
||||
(requestMessages !== null && typeof requestMessages === "object")
|
||||
? requestMessages
|
||||
: []
|
||||
);
|
||||
if (estimatedInputTokens > 0) {
|
||||
const filteredByContext = eligibleTargets.filter((target) => {
|
||||
const limit = getModelContextLimitForModelString(target.modelStr);
|
||||
if (limit === null || limit === undefined) return true; // unknown — include to be safe
|
||||
return limit >= estimatedInputTokens;
|
||||
});
|
||||
if (filteredByContext.length > 0) {
|
||||
log.debug?.(
|
||||
"COMBO",
|
||||
`Auto strategy: context-window filter kept ${filteredByContext.length}/${eligibleTargets.length} candidates (est. ${estimatedInputTokens} tokens)`
|
||||
);
|
||||
eligibleTargets = filteredByContext;
|
||||
} else {
|
||||
log.warn(
|
||||
"COMBO",
|
||||
`Auto strategy: all candidates filtered by context-window policy (est. ${estimatedInputTokens} tokens), falling back to full pool`
|
||||
);
|
||||
// eligibleTargets intentionally unchanged — same fallback contract as tool-calling filter
|
||||
}
|
||||
|
||||
eligibleTargets = await expandAutoComboCandidatePool(eligibleTargets, combo);
|
||||
}
|
||||
|
||||
const prompt = extractPromptForIntent(body);
|
||||
const systemPrompt =
|
||||
typeof combo?.system_message === "string" ? combo.system_message : undefined;
|
||||
const intentConfig = getIntentConfig(settings, combo);
|
||||
const intent = classifyWithConfig(prompt, intentConfig, systemPrompt);
|
||||
recordComboIntent(combo.name, intent);
|
||||
const taskType = mapIntentToTaskType(intent);
|
||||
|
||||
const rawAutoConfigSource =
|
||||
combo?.autoConfig ||
|
||||
(isRecord(combo?.config?.auto) ? combo.config.auto : null) ||
|
||||
combo?.config ||
|
||||
{};
|
||||
const autoConfigSource: Record<string, unknown> = isRecord(rawAutoConfigSource)
|
||||
? rawAutoConfigSource
|
||||
: {};
|
||||
const routingStrategy =
|
||||
typeof autoConfigSource.routerStrategy === "string"
|
||||
? autoConfigSource.routerStrategy
|
||||
: typeof autoConfigSource.routingStrategy === "string"
|
||||
? autoConfigSource.routingStrategy
|
||||
: typeof autoConfigSource.strategyName === "string"
|
||||
? autoConfigSource.strategyName
|
||||
: "rules";
|
||||
|
||||
const candidatePool = Array.isArray(autoConfigSource.candidatePool)
|
||||
? autoConfigSource.candidatePool
|
||||
: [...new Set(eligibleTargets.map((target) => target.provider))];
|
||||
|
||||
const weights =
|
||||
autoConfigSource.weights && typeof autoConfigSource.weights === "object"
|
||||
? (autoConfigSource.weights as ScoringWeights)
|
||||
: DEFAULT_WEIGHTS;
|
||||
const explorationRate = Number.isFinite(Number(autoConfigSource.explorationRate))
|
||||
? Number(autoConfigSource.explorationRate)
|
||||
: 0.05;
|
||||
// Per-request overrides (#6023 / #6024 / #6025): X-OmniRoute-Budget and
|
||||
// X-OmniRoute-Mode headers are threaded here via relayOptions and take
|
||||
// precedence over the combo's stored config for this single request.
|
||||
const configBudgetCap = Number.isFinite(Number(autoConfigSource.budgetCap))
|
||||
? Number(autoConfigSource.budgetCap)
|
||||
: undefined;
|
||||
const requestBudgetCap = parseRequestBudgetCap(relayOptions?.budgetCap);
|
||||
const budgetCap = requestBudgetCap ?? configBudgetCap;
|
||||
|
||||
const configModePack =
|
||||
typeof autoConfigSource.modePack === "string" ? autoConfigSource.modePack : undefined;
|
||||
const requestModePack = resolveRequestModePack(relayOptions?.mode);
|
||||
const modePack = requestModePack.override ? requestModePack.modePack : configModePack;
|
||||
if (requestModePack.override || requestBudgetCap !== undefined) {
|
||||
log.debug?.(
|
||||
"COMBO",
|
||||
`Auto strategy: per-request controls applied (mode=${
|
||||
requestModePack.override ? (requestModePack.modePack ?? "balanced") : "—"
|
||||
}, budgetCap=${requestBudgetCap ?? "—"})`
|
||||
);
|
||||
}
|
||||
const resetWindowConfig = resolveResetWindowConfig(autoConfigSource);
|
||||
const slaPolicy = resolveSlaRoutingPolicy(autoConfigSource);
|
||||
|
||||
let lastKnownGoodProvider: string | undefined;
|
||||
try {
|
||||
const { getLKGP } = await import("../../src/lib/localDb");
|
||||
const lkgp = await getLKGP(combo.name, combo.id || combo.name);
|
||||
if (lkgp) lastKnownGoodProvider = lkgp.provider;
|
||||
} catch (err) {
|
||||
log.warn("COMBO", "Failed to retrieve Last Known Good Provider. This is non-fatal.", { err });
|
||||
}
|
||||
|
||||
const autoCandidateResilienceSettings =
|
||||
relayOptions?.bypassProviderQuotaPolicy === true
|
||||
? {
|
||||
...resilienceSettings,
|
||||
quotaPreflight: {
|
||||
...resilienceSettings.quotaPreflight,
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
: resilienceSettings;
|
||||
const candidates = await buildAutoCandidates(
|
||||
eligibleTargets,
|
||||
combo.name,
|
||||
relayOptions?.sessionId,
|
||||
resetWindowConfig,
|
||||
autoCandidateResilienceSettings
|
||||
);
|
||||
const routableCandidates = candidates.filter(
|
||||
(candidate) => candidate.quotaCutoffBlocked !== true
|
||||
);
|
||||
const quotaBlockedCount = candidates.length - routableCandidates.length;
|
||||
if (quotaBlockedCount > 0) {
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Auto strategy: quota cutoff skipped ${quotaBlockedCount}/${candidates.length} account candidates`
|
||||
);
|
||||
}
|
||||
// G2: Register candidates so chatCore can mark quotaSoftPenalty via setCandidateQuotaSoftPenalty.
|
||||
_registerExecutionCandidates(routableCandidates);
|
||||
if (candidates.length > 0 && routableCandidates.length === 0) {
|
||||
return unavailableResponse(
|
||||
429,
|
||||
"All auto strategy candidates are below configured quota cutoffs"
|
||||
);
|
||||
}
|
||||
if (routableCandidates.length > 0) {
|
||||
let selectedProvider: string | null = null;
|
||||
let selectedModel: string | null = null;
|
||||
let selectionReason = "";
|
||||
|
||||
if (routingStrategy !== "rules") {
|
||||
try {
|
||||
const decision = selectWithStrategy(
|
||||
routableCandidates,
|
||||
{
|
||||
taskType,
|
||||
requestHasTools,
|
||||
lastKnownGoodProvider,
|
||||
estimatedInputTokens,
|
||||
sla: slaPolicy,
|
||||
},
|
||||
routingStrategy
|
||||
);
|
||||
selectedProvider = decision.provider;
|
||||
selectedModel = decision.model;
|
||||
selectionReason = decision.reason;
|
||||
autoUsedExplicitRouter = true;
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
"COMBO",
|
||||
`Auto strategy '${routingStrategy}' failed (${err?.message || "unknown"}), falling back to rules`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedProvider || !selectedModel) {
|
||||
const selection = selectAutoProvider(
|
||||
{
|
||||
id: combo.id || combo.name,
|
||||
name: combo.name,
|
||||
type: "auto",
|
||||
candidatePool,
|
||||
weights,
|
||||
modePack,
|
||||
budgetCap,
|
||||
explorationRate,
|
||||
},
|
||||
routableCandidates,
|
||||
taskType
|
||||
);
|
||||
selectedProvider = selection.provider;
|
||||
selectedModel = selection.model;
|
||||
selectionReason = `score=${selection.score.toFixed(3)}${selection.isExploration ? " (exploration)" : ""}`;
|
||||
}
|
||||
|
||||
// Complexity-aware routing (2026, opt-in): classify the request's
|
||||
// difficulty and feed a tier hint into scoring so tierAffinity /
|
||||
// specificityMatch favor candidates whose tier matches the request.
|
||||
const autoManifestHint: RoutingHint | null =
|
||||
config.complexityAwareRouting === true
|
||||
? buildComplexityRoutingHint(
|
||||
eligibleTargets.filter((t) => t.kind === "model"),
|
||||
body,
|
||||
log
|
||||
)
|
||||
: null;
|
||||
|
||||
const scoredTargets = scoreAutoTargets(
|
||||
eligibleTargets,
|
||||
routableCandidates,
|
||||
taskType,
|
||||
weights,
|
||||
autoManifestHint
|
||||
);
|
||||
const rankedTargets = scoredTargets.map((entry) => entry.target);
|
||||
const selectedTarget =
|
||||
scoredTargets.find((entry) => {
|
||||
const parsed = parseModel(entry.target.modelStr);
|
||||
const modelId = parsed.model || entry.target.modelStr;
|
||||
return entry.target.provider === selectedProvider && modelId === selectedModel;
|
||||
})?.target ||
|
||||
rankedTargets[0] ||
|
||||
eligibleTargets[0];
|
||||
if (!selectedTarget) {
|
||||
return unavailableResponse(
|
||||
429,
|
||||
"No auto strategy targets remained after quota cutoff filtering"
|
||||
);
|
||||
}
|
||||
|
||||
// Keep eligibleTargets as the last-resort fallback tail: dedupe drops the
|
||||
// routable ranked ones (and, when the cutoff is OFF, makes this identical to
|
||||
// the pre-cutoff behavior), but a quota-blocked target still survives as a
|
||||
// final fallback instead of vanishing — the hard cutoff only de-prioritizes.
|
||||
orderedTargets = dedupeTargetsByExecutionKey(
|
||||
[selectedTarget, ...rankedTargets, ...eligibleTargets].filter(
|
||||
(entry): entry is ResolvedComboTarget => entry !== undefined && entry !== null
|
||||
)
|
||||
);
|
||||
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Auto selection: ${selectedTarget?.modelStr || `${selectedProvider}/${selectedModel}`} | intent=${intent} task=${taskType} | strategy=${routingStrategy} | ${selectionReason}`
|
||||
);
|
||||
} else {
|
||||
log.warn("COMBO", "Auto strategy has no candidates, keeping default ordering");
|
||||
}
|
||||
} else if (strategy === "lkgp") {
|
||||
try {
|
||||
const { getLKGP } = await import("../../src/lib/localDb");
|
||||
const lkgpProvider = await getLKGP(combo.name, combo.id || combo.name);
|
||||
|
||||
if (lkgpProvider) {
|
||||
const lkgpRecord = lkgpProvider;
|
||||
const providerName = lkgpRecord.provider;
|
||||
const connId = lkgpRecord.connectionId;
|
||||
|
||||
let lkgpIndex = -1;
|
||||
if (connId) {
|
||||
lkgpIndex = orderedTargets.findIndex(
|
||||
(target) => target.provider === providerName && target.connectionId === connId
|
||||
);
|
||||
}
|
||||
if (lkgpIndex < 0) {
|
||||
lkgpIndex = orderedTargets.findIndex(
|
||||
(target) =>
|
||||
target.provider === providerName ||
|
||||
// Issue #2359: Defensive guard. The `target.modelStr` type
|
||||
// annotation is `string`, but malformed combo entries (e.g.,
|
||||
// local-provider rows whose `modelStr` failed to resolve when
|
||||
// the executor catalogue was being rebuilt) have leaked
|
||||
// through and surfaced as `e.startsWith is not a function`
|
||||
// 500s on combo test/dispatch. The fast path stays
|
||||
// unchanged for the common case; this only avoids the
|
||||
// crash when the field is unexpectedly non-string.
|
||||
(typeof target.modelStr === "string" &&
|
||||
target.modelStr.startsWith(`${providerName}/`))
|
||||
);
|
||||
}
|
||||
|
||||
if (lkgpIndex > 0) {
|
||||
const [lkgpTarget] = orderedTargets.splice(lkgpIndex, 1);
|
||||
orderedTargets.unshift(lkgpTarget);
|
||||
log.info(
|
||||
"COMBO",
|
||||
`[LKGP] Prioritizing last known good provider ${providerName}${connId ? ` (account ${connId})` : ""} for combo "${combo.name}"`
|
||||
);
|
||||
} else if (lkgpIndex === 0) {
|
||||
log.debug?.(
|
||||
"COMBO",
|
||||
`[LKGP] Last known good provider ${providerName}${connId ? ` (account ${connId})` : ""} already first for combo "${combo.name}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("COMBO", "Failed to retrieve Last Known Good Provider. This is non-fatal.", { err });
|
||||
}
|
||||
} else if (strategy === "strict-random") {
|
||||
const selectedExecutionKey = await getNextFromDeck(
|
||||
`combo:${combo.name}`,
|
||||
orderedTargets.map((target) => target.executionKey)
|
||||
);
|
||||
const selectedTarget =
|
||||
orderedTargets.find((target) => target.executionKey === selectedExecutionKey) || null;
|
||||
// #3959: shuffle the fallback remainder too. Previously `rest` kept fixed
|
||||
// priority order, so after a failing deck pick the chain always fell through
|
||||
// to the same top-priority model — a persistently-failing model was retried
|
||||
// on essentially every request and fallback load never spread across peers.
|
||||
const rest = fisherYatesShuffle(
|
||||
orderedTargets.filter((target) => target.executionKey !== selectedExecutionKey)
|
||||
);
|
||||
orderedTargets = [selectedTarget, ...rest].filter(
|
||||
(target): target is ResolvedComboTarget => target !== null
|
||||
);
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Strict-random deck: ${selectedExecutionKey} selected (${orderedTargets.length} targets)`
|
||||
);
|
||||
} else if (strategy === "random") {
|
||||
orderedTargets = fisherYatesShuffle([...orderedTargets]);
|
||||
log.info("COMBO", `Random shuffle: ${orderedTargets.length} targets`);
|
||||
} else if (strategy === "fill-first") {
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Fill-first ordering: preserving priority order (${orderedTargets.length} targets)`
|
||||
);
|
||||
} else if (strategy === "p2c") {
|
||||
orderedTargets = orderTargetsByPowerOfTwoChoices(orderedTargets, combo.name);
|
||||
log.info("COMBO", `Power-of-two-choices ordering: selected ${orderedTargets[0]?.modelStr}`);
|
||||
} else if (strategy === "least-used") {
|
||||
orderedTargets = sortTargetsByUsage(orderedTargets, combo.name);
|
||||
log.info("COMBO", `Least-used ordering: ${orderedTargets[0]?.modelStr} has fewest requests`);
|
||||
} else if (strategy === "cost-optimized") {
|
||||
orderedTargets = await sortTargetsByCost(orderedTargets);
|
||||
if (config.manifestRouting === true) {
|
||||
try {
|
||||
const manifestHint = generateRoutingHints(
|
||||
orderedTargets.filter((t) => t.kind === "model"),
|
||||
{
|
||||
messages: Array.isArray(body?.messages)
|
||||
? (body.messages as Array<{ role?: string; content?: string | unknown }>)
|
||||
: [],
|
||||
tools: Array.isArray(body?.tools)
|
||||
? (body.tools as Array<{
|
||||
function?: { name: string; description?: string; parameters?: unknown };
|
||||
}>)
|
||||
: undefined,
|
||||
model: typeof body?.model === "string" ? body.model : undefined,
|
||||
}
|
||||
);
|
||||
if (manifestHint.strategyModifier === "require-premium") {
|
||||
const eligible = orderedTargets.filter(
|
||||
(t) =>
|
||||
t.kind !== "model" ||
|
||||
manifestHint.eligibleTargets.some(
|
||||
(e) => e.provider === t.provider && e.modelStr === t.modelStr
|
||||
)
|
||||
);
|
||||
if (eligible.length > 0) orderedTargets = eligible;
|
||||
}
|
||||
log.debug?.(
|
||||
{
|
||||
strategyModifier: manifestHint.strategyModifier,
|
||||
specificityLevel: manifestHint.specificityLevel,
|
||||
score: manifestHint.specificity.score,
|
||||
},
|
||||
"manifest routing applied"
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn({ err }, "manifest routing failed, falling back to standard strategy");
|
||||
}
|
||||
}
|
||||
log.info("COMBO", `Cost-optimized ordering: cheapest first (${orderedTargets[0]?.modelStr})`);
|
||||
} else if (strategy === "reset-aware") {
|
||||
orderedTargets = await orderTargetsByResetAwareQuota(
|
||||
const autoResult = await resolveAutoStrategyOrder({
|
||||
orderedTargets,
|
||||
combo.name,
|
||||
body,
|
||||
combo,
|
||||
settings,
|
||||
config,
|
||||
relayOptions,
|
||||
resilienceSettings,
|
||||
log,
|
||||
apiKeyAllowedConnections
|
||||
);
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Reset-aware ordering: ${orderedTargets[0]?.modelStr}${orderedTargets[0]?.connectionId ? ` (${orderedTargets[0].connectionId})` : ""} first`
|
||||
);
|
||||
} else if (strategy === "reset-window") {
|
||||
orderedTargets = await orderTargetsByResetWindow(
|
||||
orderedTargets,
|
||||
combo.name,
|
||||
buildAutoCandidates,
|
||||
});
|
||||
if ("earlyResponse" in autoResult) return autoResult.earlyResponse;
|
||||
orderedTargets = autoResult.orderedTargets;
|
||||
autoUsedExplicitRouter = autoResult.autoUsedExplicitRouter;
|
||||
} else {
|
||||
orderedTargets = await applyStrategyOrdering(strategy, orderedTargets, {
|
||||
combo,
|
||||
config,
|
||||
body,
|
||||
log,
|
||||
apiKeyAllowedConnections
|
||||
);
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Reset-window ordering: ${orderedTargets[0]?.modelStr}${orderedTargets[0]?.connectionId ? ` (${orderedTargets[0].connectionId})` : ""} first`
|
||||
);
|
||||
} else if (strategy === "context-optimized") {
|
||||
orderedTargets = sortTargetsByContextSize(orderedTargets);
|
||||
log.info("COMBO", `Context-optimized ordering: largest first (${orderedTargets[0]?.modelStr})`);
|
||||
} else if (strategy === "headroom") {
|
||||
orderedTargets = await orderTargetsByHeadroom(
|
||||
orderedTargets,
|
||||
combo.name,
|
||||
log,
|
||||
apiKeyAllowedConnections
|
||||
);
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Headroom ordering: ${orderedTargets[0]?.modelStr}${orderedTargets[0]?.connectionId ? ` (${orderedTargets[0].connectionId})` : ""} has most free capacity`
|
||||
);
|
||||
} else if (strategy === "quota-share") {
|
||||
// Internal quota-share combos (qtSd/): delegate to the dedicated module (DRR +
|
||||
// P2C in-flight + per-model bucket gating + per-connection concurrency gating).
|
||||
const qsModel =
|
||||
typeof body?.model === "string" ? body.model : (orderedTargets[0]?.modelStr ?? "");
|
||||
const qsMaxConcurrent = await resolveMaxConcurrentByConnection(orderedTargets);
|
||||
orderedTargets = selectQuotaShareTarget(orderedTargets, combo.name, qsModel, Date.now(), {
|
||||
maxConcurrentByConnection: qsMaxConcurrent,
|
||||
}).orderedTargets;
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Quota-share ordering: ${orderedTargets[0]?.modelStr}${orderedTargets[0]?.connectionId ? ` (${orderedTargets[0].connectionId})` : ""} selected (DRR+P2C)`
|
||||
);
|
||||
apiKeyAllowedConnections,
|
||||
});
|
||||
}
|
||||
const _sticky = await applySessionStickiness(
|
||||
orderedTargets,
|
||||
@@ -2366,6 +1920,15 @@ export async function handleComboChat({
|
||||
!isTokenLimitBreach &&
|
||||
[408, 429, 500, 502, 503, 504].includes(result.status);
|
||||
if (retry < maxRetries && isTransient && !providerExhausted) {
|
||||
if (
|
||||
provider &&
|
||||
rawModel &&
|
||||
isModelLocked(provider, targetWithConnection.connectionId || "", rawModel)
|
||||
) {
|
||||
log.info("COMBO", `Skipping retry for ${modelStr} — model lockout active`);
|
||||
if (i > 0) fallbackCount++;
|
||||
return null;
|
||||
}
|
||||
// Record model lockout immediately on the first transient failure —
|
||||
// once the model is cooling down, retrying it would waste an upstream
|
||||
// call and extend the cooldown via exponential backoff.
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { applyComboTargetExhaustion, type ComboExhaustionSets } from "../targetExhaustion.ts";
|
||||
import type { ResolvedComboTarget, ComboLogger } from "../types.ts";
|
||||
|
||||
function makeTarget(overrides: Partial<ResolvedComboTarget> = {}): ResolvedComboTarget {
|
||||
return {
|
||||
kind: "model",
|
||||
stepId: "step-1",
|
||||
executionKey: "key-1",
|
||||
modelStr: "gpt-4",
|
||||
provider: "openai",
|
||||
providerId: "p1",
|
||||
connectionId: "c1",
|
||||
allowedConnectionIds: null,
|
||||
weight: 1,
|
||||
label: null,
|
||||
failoverBeforeRetry: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeLogger(): ComboLogger {
|
||||
const msgs: string[] = [];
|
||||
return {
|
||||
info: (...args: unknown[]) => { msgs.push(args.join(" ")); },
|
||||
warn: (...args: unknown[]) => { msgs.push(args.join(" ")); },
|
||||
error: (...args: unknown[]) => { msgs.push(args.join(" ")); },
|
||||
debug: (...args: unknown[]) => { msgs.push(args.join(" ")); },
|
||||
_msgs: msgs,
|
||||
} as ComboLogger & { _msgs: string[] };
|
||||
}
|
||||
|
||||
function makeSets(): ComboExhaustionSets {
|
||||
return {
|
||||
exhaustedProviders: new Set<string>(),
|
||||
exhaustedConnections: new Set<string>(),
|
||||
transientRateLimitedProviders: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyComboTargetExhaustion", () => {
|
||||
it("marks provider exhausted when isProviderExhaustedReason is true (quota)", () => {
|
||||
const sets = makeSets();
|
||||
const log = makeLogger();
|
||||
const exhausted = applyComboTargetExhaustion(makeTarget(), {
|
||||
result: { status: 429 },
|
||||
// isProviderExhaustedReason reads `reason`/`creditsExhausted`/`dailyQuotaExhausted`
|
||||
// (NOT `error.code`), so signal full-account exhaustion via creditsExhausted.
|
||||
fallbackResult: { creditsExhausted: true },
|
||||
errorText: "",
|
||||
rawModel: "gpt-4",
|
||||
isTokenLimitBreach: false,
|
||||
allAccountsRateLimited: false,
|
||||
sets,
|
||||
log,
|
||||
tag: "COMBO",
|
||||
exhaustedLogLevel: "info",
|
||||
});
|
||||
expect(exhausted).toBe(true);
|
||||
expect(sets.exhaustedProviders.has("openai")).toBe(true);
|
||||
expect(sets.exhaustedProviders.size).toBe(1);
|
||||
expect(sets.transientRateLimitedProviders.has("openai")).toBe(false);
|
||||
});
|
||||
|
||||
it("marks provider exhausted when classifyErrorText returns QUOTA_EXHAUSTED", () => {
|
||||
const sets = makeSets();
|
||||
const log = makeLogger();
|
||||
const exhausted = applyComboTargetExhaustion(makeTarget(), {
|
||||
result: { status: 429 },
|
||||
fallbackResult: {} as any,
|
||||
// classifyErrorText flags "quota exceeded" as QUOTA_EXHAUSTED.
|
||||
errorText: "Quota exceeded — please retry later.",
|
||||
rawModel: "gpt-4",
|
||||
isTokenLimitBreach: false,
|
||||
allAccountsRateLimited: false,
|
||||
sets,
|
||||
log,
|
||||
tag: "COMBO",
|
||||
exhaustedLogLevel: "info",
|
||||
});
|
||||
expect(exhausted).toBe(true);
|
||||
expect(sets.exhaustedProviders.has("openai")).toBe(true);
|
||||
});
|
||||
|
||||
it("marks provider exhausted when allAccountsRateLimited is true", () => {
|
||||
const sets = makeSets();
|
||||
const log = makeLogger();
|
||||
const exhausted = applyComboTargetExhaustion(makeTarget(), {
|
||||
result: { status: 503 },
|
||||
fallbackResult: {} as any,
|
||||
errorText: "Service temporarily unavailable",
|
||||
rawModel: "gpt-4",
|
||||
isTokenLimitBreach: false,
|
||||
allAccountsRateLimited: true,
|
||||
sets,
|
||||
log,
|
||||
tag: "COMBO-RR",
|
||||
exhaustedLogLevel: "info",
|
||||
});
|
||||
expect(exhausted).toBe(true);
|
||||
expect(sets.exhaustedProviders.has("openai")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT mark provider exhausted for per-model-quota providers (different model)", () => {
|
||||
const sets = makeSets();
|
||||
const log = makeLogger();
|
||||
// gemini has per-model quotas (hasPerModelQuota === true): a model-scoped quota
|
||||
// 429 must NOT mark the whole provider exhausted — other models may still work.
|
||||
const target = makeTarget({ provider: "gemini" });
|
||||
const exhausted = applyComboTargetExhaustion(target, {
|
||||
result: { status: 429 },
|
||||
fallbackResult: { reason: "quota_exhausted" } as any,
|
||||
errorText: "quota exceeded for model gpt-4",
|
||||
rawModel: "gpt-4",
|
||||
isTokenLimitBreach: false,
|
||||
allAccountsRateLimited: false,
|
||||
sets,
|
||||
log,
|
||||
tag: "COMBO",
|
||||
exhaustedLogLevel: "info",
|
||||
});
|
||||
expect(exhausted).toBe(false);
|
||||
expect(sets.exhaustedProviders.has("gemini")).toBe(false);
|
||||
expect(sets.transientRateLimitedProviders.has("gemini")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT mark provider exhausted for unknown providers", () => {
|
||||
const sets = makeSets();
|
||||
const log = makeLogger();
|
||||
const exhausted = applyComboTargetExhaustion(makeTarget({ provider: "unknown" }), {
|
||||
result: { status: 503 },
|
||||
fallbackResult: { error: { code: "quota_exhausted" } },
|
||||
errorText: "quota exhausted",
|
||||
rawModel: "unknown-model",
|
||||
isTokenLimitBreach: false,
|
||||
allAccountsRateLimited: true,
|
||||
sets,
|
||||
log,
|
||||
tag: "COMBO",
|
||||
exhaustedLogLevel: "info",
|
||||
});
|
||||
expect(exhausted).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT mark provider exhausted for empty provider strings", () => {
|
||||
const sets = makeSets();
|
||||
const log = makeLogger();
|
||||
const exhausted = applyComboTargetExhaustion(makeTarget({ provider: "" }), {
|
||||
result: { status: 503 },
|
||||
fallbackResult: { error: { code: "quota_exhausted" } },
|
||||
errorText: "quota exhausted",
|
||||
rawModel: "model",
|
||||
isTokenLimitBreach: false,
|
||||
allAccountsRateLimited: true,
|
||||
sets,
|
||||
log,
|
||||
tag: "COMBO",
|
||||
exhaustedLogLevel: "info",
|
||||
});
|
||||
expect(exhausted).toBe(false);
|
||||
});
|
||||
|
||||
it("marks transientRateLimited on 429 when NOT token-limit breach and NOT provider-exhausted", () => {
|
||||
const sets = makeSets();
|
||||
const log = makeLogger();
|
||||
const exhausted = applyComboTargetExhaustion(makeTarget(), {
|
||||
result: { status: 429 },
|
||||
fallbackResult: {} as any,
|
||||
errorText: "Rate limited",
|
||||
rawModel: "gpt-4",
|
||||
isTokenLimitBreach: false,
|
||||
allAccountsRateLimited: false,
|
||||
sets,
|
||||
log,
|
||||
tag: "COMBO",
|
||||
exhaustedLogLevel: "info",
|
||||
});
|
||||
expect(exhausted).toBe(false);
|
||||
expect(sets.transientRateLimitedProviders.has("openai")).toBe(true);
|
||||
expect(sets.exhaustedProviders.has("openai")).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT mark transientRateLimited on 429 when isTokenLimitBreach is true", () => {
|
||||
const sets = makeSets();
|
||||
const log = makeLogger();
|
||||
const exhausted = applyComboTargetExhaustion(makeTarget(), {
|
||||
result: { status: 429 },
|
||||
fallbackResult: {} as any,
|
||||
errorText: "Token limit exceeded",
|
||||
rawModel: "gpt-4",
|
||||
isTokenLimitBreach: true,
|
||||
allAccountsRateLimited: false,
|
||||
sets,
|
||||
log,
|
||||
tag: "COMBO",
|
||||
exhaustedLogLevel: "info",
|
||||
});
|
||||
expect(exhausted).toBe(false);
|
||||
expect(sets.transientRateLimitedProviders.has("openai")).toBe(false);
|
||||
expect(sets.exhaustedProviders.has("openai")).toBe(false);
|
||||
});
|
||||
|
||||
it("marks exhaustedConnections on connection-level error status (502) with connectionId", () => {
|
||||
const sets = makeSets();
|
||||
const log = makeLogger();
|
||||
const exhausted = applyComboTargetExhaustion(
|
||||
makeTarget({ provider: "openai", connectionId: "conn-1" }),
|
||||
{
|
||||
result: { status: 502 },
|
||||
fallbackResult: {} as any,
|
||||
errorText: "Bad Gateway",
|
||||
rawModel: "gpt-4",
|
||||
isTokenLimitBreach: false,
|
||||
allAccountsRateLimited: false,
|
||||
sets,
|
||||
log,
|
||||
tag: "COMBO",
|
||||
exhaustedLogLevel: "info",
|
||||
}
|
||||
);
|
||||
expect(exhausted).toBe(false);
|
||||
expect(sets.exhaustedConnections.has("openai:conn-1")).toBe(true);
|
||||
expect(sets.exhaustedProviders.has("openai")).toBe(false);
|
||||
});
|
||||
|
||||
it("marks exhaustedProviders on connection-level error when NO connectionId", () => {
|
||||
const sets = makeSets();
|
||||
const log = makeLogger();
|
||||
const exhausted = applyComboTargetExhaustion(
|
||||
makeTarget({ provider: "openai", connectionId: null }),
|
||||
{
|
||||
result: { status: 502 },
|
||||
fallbackResult: {} as any,
|
||||
errorText: "Bad Gateway",
|
||||
rawModel: "gpt-4",
|
||||
isTokenLimitBreach: false,
|
||||
allAccountsRateLimited: false,
|
||||
sets,
|
||||
log,
|
||||
tag: "COMBO",
|
||||
exhaustedLogLevel: "info",
|
||||
}
|
||||
);
|
||||
expect(exhausted).toBe(false);
|
||||
expect(sets.exhaustedProviders.has("openai")).toBe(true);
|
||||
expect(sets.exhaustedConnections.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does NOT mark anything for circuit-open (X-OmniRoute-Provider-Breaker header)", () => {
|
||||
const sets = makeSets();
|
||||
const log = makeLogger();
|
||||
const exhausted = applyComboTargetExhaustion(makeTarget(), {
|
||||
result: { status: 503, headers: new Map([["x-omniroute-provider-breaker", "open"]]) as any },
|
||||
fallbackResult: {} as any,
|
||||
errorText: "",
|
||||
rawModel: "gpt-4",
|
||||
isTokenLimitBreach: false,
|
||||
allAccountsRateLimited: false,
|
||||
sets,
|
||||
log,
|
||||
tag: "COMBO",
|
||||
exhaustedLogLevel: "info",
|
||||
});
|
||||
expect(exhausted).toBe(false);
|
||||
expect(sets.exhaustedProviders.has("openai")).toBe(false);
|
||||
expect(sets.exhaustedConnections.has("openai:c1")).toBe(false);
|
||||
expect(sets.transientRateLimitedProviders.has("openai")).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT mark exhaustion for non-connection-level status codes (400)", () => {
|
||||
const sets = makeSets();
|
||||
const log = makeLogger();
|
||||
const exhausted = applyComboTargetExhaustion(makeTarget(), {
|
||||
result: { status: 400 },
|
||||
fallbackResult: {} as any,
|
||||
errorText: "Bad Request",
|
||||
rawModel: "gpt-4",
|
||||
isTokenLimitBreach: false,
|
||||
allAccountsRateLimited: false,
|
||||
sets,
|
||||
log,
|
||||
tag: "COMBO",
|
||||
exhaustedLogLevel: "info",
|
||||
});
|
||||
expect(exhausted).toBe(false);
|
||||
expect(sets.exhaustedConnections.size).toBe(0);
|
||||
expect(sets.exhaustedProviders.size).toBe(0);
|
||||
expect(sets.transientRateLimitedProviders.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does NOT mark anything for 200 (success)", () => {
|
||||
const sets = makeSets();
|
||||
const log = makeLogger();
|
||||
const exhausted = applyComboTargetExhaustion(makeTarget(), {
|
||||
result: { status: 200 },
|
||||
fallbackResult: {} as any,
|
||||
errorText: "",
|
||||
rawModel: "gpt-4",
|
||||
isTokenLimitBreach: false,
|
||||
allAccountsRateLimited: false,
|
||||
sets,
|
||||
log,
|
||||
tag: "COMBO",
|
||||
exhaustedLogLevel: "info",
|
||||
});
|
||||
expect(exhausted).toBe(false);
|
||||
expect(sets.exhaustedProviders.size).toBe(0);
|
||||
expect(sets.exhaustedConnections.size).toBe(0);
|
||||
expect(sets.transientRateLimitedProviders.size).toBe(0);
|
||||
});
|
||||
});
|
||||
226
open-sse/services/combo/applyStrategyOrdering.ts
Normal file
226
open-sse/services/combo/applyStrategyOrdering.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { fisherYatesShuffle, getNextFromDeck } from "../../../src/shared/utils/shuffleDeck";
|
||||
import { generateRoutingHints } from "../manifestAdapter";
|
||||
import { resolveMaxConcurrentByConnection } from "./concurrencyCaps.ts";
|
||||
import { sortTargetsByContextSize } from "./comboStructure.ts";
|
||||
import { selectQuotaShareTarget } from "./quotaShareStrategy.ts";
|
||||
import {
|
||||
orderTargetsByHeadroom,
|
||||
orderTargetsByResetAwareQuota,
|
||||
orderTargetsByResetWindow,
|
||||
} from "./quotaStrategies.ts";
|
||||
import {
|
||||
orderTargetsByPowerOfTwoChoices,
|
||||
sortTargetsByCost,
|
||||
sortTargetsByUsage,
|
||||
} from "./targetSorters.ts";
|
||||
import type { ComboLike, ComboLogger, ResolvedComboTarget } from "./types.ts";
|
||||
|
||||
export interface ApplyStrategyOrderingDeps {
|
||||
combo: ComboLike;
|
||||
config: Record<string, unknown>;
|
||||
body: Record<string, unknown>;
|
||||
log: ComboLogger;
|
||||
apiKeyAllowedConnections: string[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the target-ordering step for every non-`auto` combo strategy.
|
||||
*
|
||||
* Extracted verbatim from the `else if (strategy === ...)` chain in
|
||||
* handleComboChat (lkgp / strict-random / random / fill-first / p2c /
|
||||
* least-used / cost-optimized / reset-aware / reset-window / context-optimized /
|
||||
* headroom / quota-share). Each branch only reorders `orderedTargets` — no early
|
||||
* returns, no other mutable state — so the extraction returns the reordered list.
|
||||
* An unknown strategy falls through with the input order unchanged, matching the
|
||||
* previous inline behavior (the chain had no trailing `else`). The `auto` strategy
|
||||
* is handled separately by `resolveAutoStrategyOrder` and never reaches here.
|
||||
*/
|
||||
export async function applyStrategyOrdering(
|
||||
strategy: string,
|
||||
initialOrderedTargets: ResolvedComboTarget[],
|
||||
deps: ApplyStrategyOrderingDeps
|
||||
): Promise<ResolvedComboTarget[]> {
|
||||
const { combo, config, body, log, apiKeyAllowedConnections } = deps;
|
||||
let orderedTargets = initialOrderedTargets;
|
||||
|
||||
if (strategy === "lkgp") {
|
||||
try {
|
||||
const { getLKGP } = await import("../../../src/lib/localDb");
|
||||
const lkgpProvider = await getLKGP(combo.name, combo.id || combo.name);
|
||||
|
||||
if (lkgpProvider) {
|
||||
const lkgpRecord = lkgpProvider;
|
||||
const providerName = lkgpRecord.provider;
|
||||
const connId = lkgpRecord.connectionId;
|
||||
|
||||
let lkgpIndex = -1;
|
||||
if (connId) {
|
||||
lkgpIndex = orderedTargets.findIndex(
|
||||
(target) => target.provider === providerName && target.connectionId === connId
|
||||
);
|
||||
}
|
||||
if (lkgpIndex < 0) {
|
||||
lkgpIndex = orderedTargets.findIndex(
|
||||
(target) =>
|
||||
target.provider === providerName ||
|
||||
// Issue #2359: Defensive guard. The `target.modelStr` type
|
||||
// annotation is `string`, but malformed combo entries (e.g.,
|
||||
// local-provider rows whose `modelStr` failed to resolve when
|
||||
// the executor catalogue was being rebuilt) have leaked
|
||||
// through and surfaced as `e.startsWith is not a function`
|
||||
// 500s on combo test/dispatch. The fast path stays
|
||||
// unchanged for the common case; this only avoids the
|
||||
// crash when the field is unexpectedly non-string.
|
||||
(typeof target.modelStr === "string" &&
|
||||
target.modelStr.startsWith(`${providerName}/`))
|
||||
);
|
||||
}
|
||||
|
||||
if (lkgpIndex > 0) {
|
||||
const [lkgpTarget] = orderedTargets.splice(lkgpIndex, 1);
|
||||
orderedTargets.unshift(lkgpTarget);
|
||||
log.info(
|
||||
"COMBO",
|
||||
`[LKGP] Prioritizing last known good provider ${providerName}${connId ? ` (account ${connId})` : ""} for combo "${combo.name}"`
|
||||
);
|
||||
} else if (lkgpIndex === 0) {
|
||||
log.debug?.(
|
||||
"COMBO",
|
||||
`[LKGP] Last known good provider ${providerName}${connId ? ` (account ${connId})` : ""} already first for combo "${combo.name}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("COMBO", "Failed to retrieve Last Known Good Provider. This is non-fatal.", { err });
|
||||
}
|
||||
} else if (strategy === "strict-random") {
|
||||
const selectedExecutionKey = await getNextFromDeck(
|
||||
`combo:${combo.name}`,
|
||||
orderedTargets.map((target) => target.executionKey)
|
||||
);
|
||||
const selectedTarget =
|
||||
orderedTargets.find((target) => target.executionKey === selectedExecutionKey) || null;
|
||||
// #3959: shuffle the fallback remainder too. Previously `rest` kept fixed
|
||||
// priority order, so after a failing deck pick the chain always fell through
|
||||
// to the same top-priority model — a persistently-failing model was retried
|
||||
// on essentially every request and fallback load never spread across peers.
|
||||
const rest = fisherYatesShuffle(
|
||||
orderedTargets.filter((target) => target.executionKey !== selectedExecutionKey)
|
||||
);
|
||||
orderedTargets = [selectedTarget, ...rest].filter(
|
||||
(target): target is ResolvedComboTarget => target !== null
|
||||
);
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Strict-random deck: ${selectedExecutionKey} selected (${orderedTargets.length} targets)`
|
||||
);
|
||||
} else if (strategy === "random") {
|
||||
orderedTargets = fisherYatesShuffle([...orderedTargets]);
|
||||
log.info("COMBO", `Random shuffle: ${orderedTargets.length} targets`);
|
||||
} else if (strategy === "fill-first") {
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Fill-first ordering: preserving priority order (${orderedTargets.length} targets)`
|
||||
);
|
||||
} else if (strategy === "p2c") {
|
||||
orderedTargets = orderTargetsByPowerOfTwoChoices(orderedTargets, combo.name);
|
||||
log.info("COMBO", `Power-of-two-choices ordering: selected ${orderedTargets[0]?.modelStr}`);
|
||||
} else if (strategy === "least-used") {
|
||||
orderedTargets = sortTargetsByUsage(orderedTargets, combo.name);
|
||||
log.info("COMBO", `Least-used ordering: ${orderedTargets[0]?.modelStr} has fewest requests`);
|
||||
} else if (strategy === "cost-optimized") {
|
||||
orderedTargets = await sortTargetsByCost(orderedTargets);
|
||||
if (config.manifestRouting === true) {
|
||||
try {
|
||||
const manifestHint = generateRoutingHints(
|
||||
orderedTargets.filter((t) => t.kind === "model"),
|
||||
{
|
||||
messages: Array.isArray(body?.messages)
|
||||
? (body.messages as Array<{ role?: string; content?: string | unknown }>)
|
||||
: [],
|
||||
tools: Array.isArray(body?.tools)
|
||||
? (body.tools as Array<{
|
||||
function?: { name: string; description?: string; parameters?: unknown };
|
||||
}>)
|
||||
: undefined,
|
||||
model: typeof body?.model === "string" ? body.model : undefined,
|
||||
}
|
||||
);
|
||||
if (manifestHint.strategyModifier === "require-premium") {
|
||||
const eligible = orderedTargets.filter(
|
||||
(t) =>
|
||||
t.kind !== "model" ||
|
||||
manifestHint.eligibleTargets.some(
|
||||
(e) => e.provider === t.provider && e.modelStr === t.modelStr
|
||||
)
|
||||
);
|
||||
if (eligible.length > 0) orderedTargets = eligible;
|
||||
}
|
||||
log.debug?.(
|
||||
{
|
||||
strategyModifier: manifestHint.strategyModifier,
|
||||
specificityLevel: manifestHint.specificityLevel,
|
||||
score: manifestHint.specificity.score,
|
||||
},
|
||||
"manifest routing applied"
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn({ err }, "manifest routing failed, falling back to standard strategy");
|
||||
}
|
||||
}
|
||||
log.info("COMBO", `Cost-optimized ordering: cheapest first (${orderedTargets[0]?.modelStr})`);
|
||||
} else if (strategy === "reset-aware") {
|
||||
orderedTargets = await orderTargetsByResetAwareQuota(
|
||||
orderedTargets,
|
||||
combo.name,
|
||||
config,
|
||||
log,
|
||||
apiKeyAllowedConnections
|
||||
);
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Reset-aware ordering: ${orderedTargets[0]?.modelStr}${orderedTargets[0]?.connectionId ? ` (${orderedTargets[0].connectionId})` : ""} first`
|
||||
);
|
||||
} else if (strategy === "reset-window") {
|
||||
orderedTargets = await orderTargetsByResetWindow(
|
||||
orderedTargets,
|
||||
combo.name,
|
||||
config,
|
||||
log,
|
||||
apiKeyAllowedConnections
|
||||
);
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Reset-window ordering: ${orderedTargets[0]?.modelStr}${orderedTargets[0]?.connectionId ? ` (${orderedTargets[0].connectionId})` : ""} first`
|
||||
);
|
||||
} else if (strategy === "context-optimized") {
|
||||
orderedTargets = sortTargetsByContextSize(orderedTargets);
|
||||
log.info("COMBO", `Context-optimized ordering: largest first (${orderedTargets[0]?.modelStr})`);
|
||||
} else if (strategy === "headroom") {
|
||||
orderedTargets = await orderTargetsByHeadroom(
|
||||
orderedTargets,
|
||||
combo.name,
|
||||
log,
|
||||
apiKeyAllowedConnections
|
||||
);
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Headroom ordering: ${orderedTargets[0]?.modelStr}${orderedTargets[0]?.connectionId ? ` (${orderedTargets[0].connectionId})` : ""} has most free capacity`
|
||||
);
|
||||
} else if (strategy === "quota-share") {
|
||||
// Internal quota-share combos (qtSd/): delegate to the dedicated module (DRR +
|
||||
// P2C in-flight + per-model bucket gating + per-connection concurrency gating).
|
||||
const qsModel =
|
||||
typeof body?.model === "string" ? body.model : (orderedTargets[0]?.modelStr ?? "");
|
||||
const qsMaxConcurrent = await resolveMaxConcurrentByConnection(orderedTargets);
|
||||
orderedTargets = selectQuotaShareTarget(orderedTargets, combo.name, qsModel, Date.now(), {
|
||||
maxConcurrentByConnection: qsMaxConcurrent,
|
||||
}).orderedTargets;
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Quota-share ordering: ${orderedTargets[0]?.modelStr}${orderedTargets[0]?.connectionId ? ` (${orderedTargets[0].connectionId})` : ""} selected (DRR+P2C)`
|
||||
);
|
||||
}
|
||||
|
||||
return orderedTargets;
|
||||
}
|
||||
62
open-sse/services/combo/autoConfig.ts
Normal file
62
open-sse/services/combo/autoConfig.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { DEFAULT_WEIGHTS, type ScoringWeights } from "../autoCombo/scoring.ts";
|
||||
import { isRecord } from "./comboData.ts";
|
||||
import { resolveResetWindowConfig, resolveSlaRoutingPolicy } from "./quotaScoring.ts";
|
||||
import type { ComboLike, ResolvedComboTarget } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Resolve the auto-strategy routing configuration for a combo.
|
||||
*
|
||||
* Pure function of `(combo, eligibleTargets)`: derives the router strategy name,
|
||||
* candidate provider pool, scoring weights, exploration rate, budget cap, mode
|
||||
* pack, reset-window config and SLA policy from the combo's `autoConfig`/`config`.
|
||||
* No side effects, no early returns — extracted verbatim from `handleComboChat`
|
||||
* so its behavior is byte-identical to the previous inline block.
|
||||
*/
|
||||
export function parseAutoConfig(combo: ComboLike, eligibleTargets: ResolvedComboTarget[]) {
|
||||
const rawAutoConfigSource =
|
||||
combo?.autoConfig ||
|
||||
(isRecord(combo?.config?.auto) ? combo.config.auto : null) ||
|
||||
combo?.config ||
|
||||
{};
|
||||
const autoConfigSource: Record<string, unknown> = isRecord(rawAutoConfigSource)
|
||||
? rawAutoConfigSource
|
||||
: {};
|
||||
const routingStrategy =
|
||||
typeof autoConfigSource.routerStrategy === "string"
|
||||
? autoConfigSource.routerStrategy
|
||||
: typeof autoConfigSource.routingStrategy === "string"
|
||||
? autoConfigSource.routingStrategy
|
||||
: typeof autoConfigSource.strategyName === "string"
|
||||
? autoConfigSource.strategyName
|
||||
: "rules";
|
||||
|
||||
const candidatePool = Array.isArray(autoConfigSource.candidatePool)
|
||||
? autoConfigSource.candidatePool
|
||||
: [...new Set(eligibleTargets.map((target) => target.provider))];
|
||||
|
||||
const weights =
|
||||
autoConfigSource.weights && typeof autoConfigSource.weights === "object"
|
||||
? (autoConfigSource.weights as ScoringWeights)
|
||||
: DEFAULT_WEIGHTS;
|
||||
const explorationRate = Number.isFinite(Number(autoConfigSource.explorationRate))
|
||||
? Number(autoConfigSource.explorationRate)
|
||||
: 0.05;
|
||||
const budgetCap = Number.isFinite(Number(autoConfigSource.budgetCap))
|
||||
? Number(autoConfigSource.budgetCap)
|
||||
: undefined;
|
||||
const modePack =
|
||||
typeof autoConfigSource.modePack === "string" ? autoConfigSource.modePack : undefined;
|
||||
const resetWindowConfig = resolveResetWindowConfig(autoConfigSource);
|
||||
const slaPolicy = resolveSlaRoutingPolicy(autoConfigSource);
|
||||
|
||||
return {
|
||||
routingStrategy,
|
||||
candidatePool,
|
||||
weights,
|
||||
explorationRate,
|
||||
budgetCap,
|
||||
modePack,
|
||||
resetWindowConfig,
|
||||
slaPolicy,
|
||||
};
|
||||
}
|
||||
334
open-sse/services/combo/resolveAutoStrategy.ts
Normal file
334
open-sse/services/combo/resolveAutoStrategy.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { unavailableResponse } from "../../utils/error.ts";
|
||||
import { selectProvider as selectAutoProvider } from "../autoCombo/engine.ts";
|
||||
import {
|
||||
resolveRequestModePack,
|
||||
parseRequestBudgetCap,
|
||||
} from "../autoCombo/requestControls.ts";
|
||||
import { selectWithStrategy } from "../autoCombo/routerStrategy.ts";
|
||||
import { buildComplexityRoutingHint } from "../autoCombo/complexityRouter";
|
||||
import { recordComboIntent } from "../comboMetrics.ts";
|
||||
import { estimateTokens } from "../contextManager.ts";
|
||||
import { classifyWithConfig } from "../intentClassifier.ts";
|
||||
import type { RoutingHint } from "../manifestAdapter";
|
||||
import { parseModel } from "../model.ts";
|
||||
import { supportsToolCalling } from "../modelCapabilities.ts";
|
||||
import type { ResilienceSettings } from "../../../src/lib/resilience/settings";
|
||||
import { parseAutoConfig } from "./autoConfig.ts";
|
||||
import { dedupeTargetsByExecutionKey } from "./comboData.ts";
|
||||
import { getModelContextLimitForModelString } from "./comboStructure.ts";
|
||||
import type { ResetWindowConfig } from "./quotaScoring.ts";
|
||||
import {
|
||||
_registerExecutionCandidates,
|
||||
expandAutoComboCandidatePool,
|
||||
extractPromptForIntent,
|
||||
getIntentConfig,
|
||||
mapIntentToTaskType,
|
||||
scoreAutoTargets,
|
||||
} from "./autoStrategy.ts";
|
||||
import type {
|
||||
AutoProviderCandidate,
|
||||
ComboLike,
|
||||
ComboLogger,
|
||||
ResolvedComboTarget,
|
||||
} from "./types.ts";
|
||||
|
||||
/**
|
||||
* Dependency-injected `buildAutoCandidates` — it lives in `combo.ts` (the host of
|
||||
* this leaf), so importing it directly would create an import cycle. Passing it
|
||||
* through `deps` keeps this module acyclic (same pattern as `buildTargetTimeoutRunner`).
|
||||
*/
|
||||
type BuildAutoCandidates = (
|
||||
targets: ResolvedComboTarget[],
|
||||
comboName: string,
|
||||
sessionId?: string | null,
|
||||
resetWindowConfig?: ResetWindowConfig,
|
||||
resilienceSettings?: ResilienceSettings | null
|
||||
) => Promise<AutoProviderCandidate[]>;
|
||||
|
||||
export interface ResolveAutoStrategyDeps {
|
||||
orderedTargets: ResolvedComboTarget[];
|
||||
body: Record<string, unknown>;
|
||||
combo: ComboLike;
|
||||
settings: Record<string, unknown> | null | undefined;
|
||||
config: { complexityAwareRouting?: boolean };
|
||||
relayOptions?: {
|
||||
bypassProviderQuotaPolicy?: boolean;
|
||||
sessionId?: string | null;
|
||||
/** Per-request X-OmniRoute-Mode value (#6024/#6025). */
|
||||
mode?: string | null;
|
||||
/** Per-request X-OmniRoute-Budget value in USD (#6023). */
|
||||
budgetCap?: number | null;
|
||||
} | null;
|
||||
resilienceSettings: ResilienceSettings;
|
||||
log: ComboLogger;
|
||||
buildAutoCandidates: BuildAutoCandidates;
|
||||
}
|
||||
|
||||
export type ResolveAutoStrategyResult =
|
||||
| { earlyResponse: Response }
|
||||
| { orderedTargets: ResolvedComboTarget[]; autoUsedExplicitRouter: boolean };
|
||||
|
||||
/**
|
||||
* Resolve target ordering for the `auto` combo strategy.
|
||||
*
|
||||
* Extracted verbatim from `handleComboChat`'s `if (strategy === "auto")` branch:
|
||||
* tool-calling + context-window pre-filters, intent classification, candidate
|
||||
* building (quota cutoff), explicit-router vs rules selection, complexity-aware
|
||||
* scoring and final dedup ordering. Behavior is byte-identical to the previous
|
||||
* inline block; the two `return unavailableResponse(...)` exits become
|
||||
* `{ earlyResponse }` so the host can decide to return them, and the mutated
|
||||
* `orderedTargets` / `autoUsedExplicitRouter` are returned instead of closed over.
|
||||
*/
|
||||
export async function resolveAutoStrategyOrder(
|
||||
deps: ResolveAutoStrategyDeps
|
||||
): Promise<ResolveAutoStrategyResult> {
|
||||
const {
|
||||
body,
|
||||
combo,
|
||||
settings,
|
||||
config,
|
||||
relayOptions,
|
||||
resilienceSettings,
|
||||
log,
|
||||
buildAutoCandidates,
|
||||
} = deps;
|
||||
let orderedTargets = deps.orderedTargets;
|
||||
let autoUsedExplicitRouter = false;
|
||||
|
||||
const requestHasTools = Array.isArray(body?.tools) && body.tools.length > 0;
|
||||
let eligibleTargets = [...orderedTargets];
|
||||
|
||||
if (requestHasTools) {
|
||||
const filtered = eligibleTargets.filter((target) => supportsToolCalling(target.modelStr));
|
||||
if (filtered.length > 0) {
|
||||
eligibleTargets = filtered;
|
||||
} else {
|
||||
log.warn(
|
||||
"COMBO",
|
||||
"Auto strategy: all candidates filtered by tool-calling policy, falling back to full pool"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Context-window pre-filter (#1808)
|
||||
// Estimate input tokens once; exclude candidates whose known context limit is too small.
|
||||
// Uses the same 4-chars-per-token heuristic as contextManager.ts::compressContext().
|
||||
// Null/unknown limits are treated as "include" to avoid incorrectly dropping valid targets.
|
||||
const requestMessages = body.messages;
|
||||
const estimatedInputTokens = estimateTokens(
|
||||
typeof requestMessages === "string" ||
|
||||
(requestMessages !== null && typeof requestMessages === "object")
|
||||
? requestMessages
|
||||
: []
|
||||
);
|
||||
if (estimatedInputTokens > 0) {
|
||||
const filteredByContext = eligibleTargets.filter((target) => {
|
||||
const limit = getModelContextLimitForModelString(target.modelStr);
|
||||
if (limit === null || limit === undefined) return true; // unknown — include to be safe
|
||||
return limit >= estimatedInputTokens;
|
||||
});
|
||||
if (filteredByContext.length > 0) {
|
||||
log.debug?.(
|
||||
"COMBO",
|
||||
`Auto strategy: context-window filter kept ${filteredByContext.length}/${eligibleTargets.length} candidates (est. ${estimatedInputTokens} tokens)`
|
||||
);
|
||||
eligibleTargets = filteredByContext;
|
||||
} else {
|
||||
log.warn(
|
||||
"COMBO",
|
||||
`Auto strategy: all candidates filtered by context-window policy (est. ${estimatedInputTokens} tokens), falling back to full pool`
|
||||
);
|
||||
// eligibleTargets intentionally unchanged — same fallback contract as tool-calling filter
|
||||
}
|
||||
|
||||
eligibleTargets = await expandAutoComboCandidatePool(eligibleTargets, combo);
|
||||
}
|
||||
|
||||
const prompt = extractPromptForIntent(body);
|
||||
const systemPrompt = typeof combo?.system_message === "string" ? combo.system_message : undefined;
|
||||
const intentConfig = getIntentConfig(settings, combo);
|
||||
const intent = classifyWithConfig(prompt, intentConfig, systemPrompt);
|
||||
recordComboIntent(combo.name, intent);
|
||||
const taskType = mapIntentToTaskType(intent);
|
||||
|
||||
const {
|
||||
routingStrategy,
|
||||
candidatePool,
|
||||
weights,
|
||||
explorationRate,
|
||||
budgetCap: configBudgetCap,
|
||||
modePack: configModePack,
|
||||
resetWindowConfig,
|
||||
slaPolicy,
|
||||
} = parseAutoConfig(combo, eligibleTargets);
|
||||
|
||||
// Per-request overrides (#6023 / #6024 / #6025): X-OmniRoute-Budget and
|
||||
// X-OmniRoute-Mode headers (threaded via relayOptions) take precedence over
|
||||
// the combo's stored config for this single request. Unknown/garbage header
|
||||
// values are ignored so the saved config is preserved.
|
||||
const requestBudgetCap = parseRequestBudgetCap(relayOptions?.budgetCap);
|
||||
const budgetCap = requestBudgetCap ?? configBudgetCap;
|
||||
const requestModePack = resolveRequestModePack(relayOptions?.mode);
|
||||
const modePack = requestModePack.override ? requestModePack.modePack : configModePack;
|
||||
if (requestModePack.override || requestBudgetCap !== undefined) {
|
||||
log.debug?.(
|
||||
"COMBO",
|
||||
`Auto strategy: per-request controls applied (mode=${
|
||||
requestModePack.override ? (requestModePack.modePack ?? "balanced") : "—"
|
||||
}, budgetCap=${requestBudgetCap ?? "—"})`
|
||||
);
|
||||
}
|
||||
|
||||
let lastKnownGoodProvider: string | undefined;
|
||||
try {
|
||||
const { getLKGP } = await import("../../../src/lib/localDb");
|
||||
const lkgp = await getLKGP(combo.name, combo.id || combo.name);
|
||||
if (lkgp) lastKnownGoodProvider = lkgp.provider;
|
||||
} catch (err) {
|
||||
log.warn("COMBO", "Failed to retrieve Last Known Good Provider. This is non-fatal.", { err });
|
||||
}
|
||||
|
||||
const autoCandidateResilienceSettings =
|
||||
relayOptions?.bypassProviderQuotaPolicy === true
|
||||
? {
|
||||
...resilienceSettings,
|
||||
quotaPreflight: {
|
||||
...resilienceSettings.quotaPreflight,
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
: resilienceSettings;
|
||||
const candidates = await buildAutoCandidates(
|
||||
eligibleTargets,
|
||||
combo.name,
|
||||
relayOptions?.sessionId,
|
||||
resetWindowConfig,
|
||||
autoCandidateResilienceSettings
|
||||
);
|
||||
const routableCandidates = candidates.filter(
|
||||
(candidate) => candidate.quotaCutoffBlocked !== true
|
||||
);
|
||||
const quotaBlockedCount = candidates.length - routableCandidates.length;
|
||||
if (quotaBlockedCount > 0) {
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Auto strategy: quota cutoff skipped ${quotaBlockedCount}/${candidates.length} account candidates`
|
||||
);
|
||||
}
|
||||
// G2: Register candidates so chatCore can mark quotaSoftPenalty via setCandidateQuotaSoftPenalty.
|
||||
_registerExecutionCandidates(routableCandidates);
|
||||
if (candidates.length > 0 && routableCandidates.length === 0) {
|
||||
return {
|
||||
earlyResponse: unavailableResponse(
|
||||
429,
|
||||
"All auto strategy candidates are below configured quota cutoffs"
|
||||
),
|
||||
};
|
||||
}
|
||||
if (routableCandidates.length > 0) {
|
||||
let selectedProvider: string | null = null;
|
||||
let selectedModel: string | null = null;
|
||||
let selectionReason = "";
|
||||
|
||||
if (routingStrategy !== "rules") {
|
||||
try {
|
||||
const decision = selectWithStrategy(
|
||||
routableCandidates,
|
||||
{
|
||||
taskType,
|
||||
requestHasTools,
|
||||
lastKnownGoodProvider,
|
||||
estimatedInputTokens,
|
||||
sla: slaPolicy,
|
||||
},
|
||||
routingStrategy
|
||||
);
|
||||
selectedProvider = decision.provider;
|
||||
selectedModel = decision.model;
|
||||
selectionReason = decision.reason;
|
||||
autoUsedExplicitRouter = true;
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
"COMBO",
|
||||
`Auto strategy '${routingStrategy}' failed (${err?.message || "unknown"}), falling back to rules`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedProvider || !selectedModel) {
|
||||
const selection = selectAutoProvider(
|
||||
{
|
||||
id: combo.id || combo.name,
|
||||
name: combo.name,
|
||||
type: "auto",
|
||||
candidatePool,
|
||||
weights,
|
||||
modePack,
|
||||
budgetCap,
|
||||
explorationRate,
|
||||
},
|
||||
routableCandidates,
|
||||
taskType
|
||||
);
|
||||
selectedProvider = selection.provider;
|
||||
selectedModel = selection.model;
|
||||
selectionReason = `score=${selection.score.toFixed(3)}${selection.isExploration ? " (exploration)" : ""}`;
|
||||
}
|
||||
|
||||
// Complexity-aware routing (2026, opt-in): classify the request's
|
||||
// difficulty and feed a tier hint into scoring so tierAffinity /
|
||||
// specificityMatch favor candidates whose tier matches the request.
|
||||
const autoManifestHint: RoutingHint | null =
|
||||
config.complexityAwareRouting === true
|
||||
? buildComplexityRoutingHint(
|
||||
eligibleTargets.filter((t) => t.kind === "model"),
|
||||
body,
|
||||
log
|
||||
)
|
||||
: null;
|
||||
|
||||
const scoredTargets = scoreAutoTargets(
|
||||
eligibleTargets,
|
||||
routableCandidates,
|
||||
taskType,
|
||||
weights,
|
||||
autoManifestHint
|
||||
);
|
||||
const rankedTargets = scoredTargets.map((entry) => entry.target);
|
||||
const selectedTarget =
|
||||
scoredTargets.find((entry) => {
|
||||
const parsed = parseModel(entry.target.modelStr);
|
||||
const modelId = parsed.model || entry.target.modelStr;
|
||||
return entry.target.provider === selectedProvider && modelId === selectedModel;
|
||||
})?.target ||
|
||||
rankedTargets[0] ||
|
||||
eligibleTargets[0];
|
||||
if (!selectedTarget) {
|
||||
return {
|
||||
earlyResponse: unavailableResponse(
|
||||
429,
|
||||
"No auto strategy targets remained after quota cutoff filtering"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Keep eligibleTargets as the last-resort fallback tail: dedupe drops the
|
||||
// routable ranked ones (and, when the cutoff is OFF, makes this identical to
|
||||
// the pre-cutoff behavior), but a quota-blocked target still survives as a
|
||||
// final fallback instead of vanishing — the hard cutoff only de-prioritizes.
|
||||
orderedTargets = dedupeTargetsByExecutionKey(
|
||||
[selectedTarget, ...rankedTargets, ...eligibleTargets].filter(
|
||||
(entry): entry is ResolvedComboTarget => entry !== undefined && entry !== null
|
||||
)
|
||||
);
|
||||
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Auto selection: ${selectedTarget?.modelStr || `${selectedProvider}/${selectedModel}`} | intent=${intent} task=${taskType} | strategy=${routingStrategy} | ${selectionReason}`
|
||||
);
|
||||
} else {
|
||||
log.warn("COMBO", "Auto strategy has no candidates, keeping default ordering");
|
||||
}
|
||||
|
||||
return { orderedTargets, autoUsedExplicitRouter };
|
||||
}
|
||||
@@ -104,7 +104,7 @@ export function applyComboTargetExhaustion(
|
||||
if (result.status === 429 && !isTokenLimitBreach && provider && provider !== "unknown") {
|
||||
transientRateLimitedProviders.add(provider);
|
||||
}
|
||||
markConnectionLevelExhaustion(target, { result, errorText, sets, log, tag });
|
||||
markConnectionLevelExhaustion(target, { result, errorText, sets, log, tag, rawModel });
|
||||
}
|
||||
|
||||
return providerExhausted;
|
||||
@@ -118,9 +118,12 @@ export function applyComboTargetExhaustion(
|
||||
*/
|
||||
function markConnectionLevelExhaustion(
|
||||
target: ResolvedComboTarget,
|
||||
opts: Pick<ApplyComboTargetExhaustionOptions, "result" | "errorText" | "sets" | "log" | "tag">
|
||||
opts: Pick<
|
||||
ApplyComboTargetExhaustionOptions,
|
||||
"result" | "errorText" | "sets" | "log" | "tag" | "rawModel"
|
||||
>
|
||||
): void {
|
||||
const { result, errorText, sets, log, tag } = opts;
|
||||
const { result, errorText, sets, log, tag, rawModel } = opts;
|
||||
const provider = target.provider;
|
||||
if (
|
||||
!provider ||
|
||||
@@ -130,7 +133,13 @@ function markConnectionLevelExhaustion(
|
||||
// #5085: empty-content 502 is a healthy connection returning no body — model-level, not
|
||||
// connection-level. Don't exhaust the provider; let the remaining legs (incl. same-provider)
|
||||
// be tried in-request.
|
||||
isEmptyContentFailure(result.status, errorText)
|
||||
isEmptyContentFailure(result.status, errorText) ||
|
||||
// Per-model-quota providers (gemini, github, passthrough, compatible) multiplex models
|
||||
// behind one connection. A model-level 500 (e.g. Gemini "Internal error encountered")
|
||||
// must NOT exhaust the connection — other models on the same connection may still succeed.
|
||||
// Other connection-level statuses (408/502/503/504/524) indicate the connection itself is
|
||||
// bad, so they correctly exhaust even for per-model-quota providers.
|
||||
(result.status === 500 && hasPerModelQuota(provider, rawModel))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -312,6 +312,43 @@ async function getXiaomiMimoUsage(connectionId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* xAI (Grok) — SELF-TRACKED cumulative usage.
|
||||
*
|
||||
* xAI has no public per-account quota API (the billing console at console.x.ai
|
||||
* requires a session cookie, not an API key), so — exactly like the Xiaomi
|
||||
* MiMo self-track pattern above — OmniRoute sums the tokens it itself routed
|
||||
* to this connection (from `usage_history`) instead of calling an upstream
|
||||
* endpoint. Unlike Xiaomi MiMo, xAI has no fixed monthly cap, so the
|
||||
* aggregate is reported as `unlimited: true` with `remaining: 100` — this
|
||||
* renders the dashboard's green "100%" badge instead of a meaningless
|
||||
* progress bar against a `total: 0`.
|
||||
*/
|
||||
async function getXaiUsage(connectionId: string) {
|
||||
if (!connectionId) {
|
||||
return { message: "xAI: connection id unavailable for self-tracked usage." };
|
||||
}
|
||||
try {
|
||||
const { getMonthlyProviderTokensForConnection } = await import("@/lib/usage/usageStats");
|
||||
const used = getMonthlyProviderTokensForConnection("xai", connectionId);
|
||||
return {
|
||||
plan: "xAI / Grok (OmniRoute-tracked)",
|
||||
quotas: {
|
||||
monthly: {
|
||||
used,
|
||||
total: 0,
|
||||
remaining: 100,
|
||||
remainingPercentage: 100,
|
||||
resetAt: null,
|
||||
unlimited: true,
|
||||
} as UsageQuota,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: `xAI self-tracked usage error: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenCode Go / OpenCode / OpenCode Zen Usage
|
||||
* Delegates to the dedicated opencodeQuotaFetcher and shapes the result into
|
||||
@@ -497,6 +534,7 @@ export const USAGE_FETCHER_PROVIDERS = [
|
||||
"opencode",
|
||||
"opencode-zen",
|
||||
"xiaomi-mimo",
|
||||
"xai",
|
||||
"vertex",
|
||||
"vertex-partner",
|
||||
"codebuddy-cn",
|
||||
@@ -578,6 +616,8 @@ export async function getUsageForProvider(
|
||||
return await getOpencodeUsage(id || "", apiKey || "");
|
||||
case "xiaomi-mimo":
|
||||
return await getXiaomiMimoUsage(id || "");
|
||||
case "xai":
|
||||
return await getXaiUsage(id || "");
|
||||
case "codebuddy-cn":
|
||||
return await getCodeBuddyCnUsage(accessToken, apiKey, providerSpecificData);
|
||||
default:
|
||||
@@ -1006,6 +1046,7 @@ export const __testing = {
|
||||
getMiniMaxRemainingPercent,
|
||||
getMiniMaxUsage,
|
||||
getXiaomiMimoUsage,
|
||||
getXaiUsage,
|
||||
getVertexUsage,
|
||||
getMiniMaxAuthErrorMessage,
|
||||
getMiniMaxErrorSummary,
|
||||
|
||||
@@ -12,6 +12,10 @@ export const GEMINI_UNSUPPORTED_SCHEMA_KEYS = new Set([
|
||||
"maxLength",
|
||||
"exclusiveMinimum",
|
||||
"exclusiveMaximum",
|
||||
// `multipleOf` is not part of the Gemini/antigravity OpenAPI 3.0 schema subset;
|
||||
// leaving it in function_declarations triggers a hard upstream 400
|
||||
// ("Unknown name \"multipleOf\""). `minimum`/`maximum` ARE accepted and kept.
|
||||
"multipleOf",
|
||||
// NOTE: `pattern` is intentionally NOT in this set. Antigravity (Gemini-derived
|
||||
// surface) accepts `pattern` on string constraints, and glob/grep/file-search
|
||||
// tools depend on it to express their argument regex. Removing it produced
|
||||
|
||||
@@ -32,6 +32,15 @@ export function hasUnsupportedKiroContextSuffix(model: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap system-prompt content in <system-reminder> tags before it is merged into
|
||||
* a Kiro user message. Kiro/CodeWhisperer has no `system` role, so without this
|
||||
* the system prompt would appear as raw user text (issue #2306).
|
||||
*/
|
||||
function wrapSystemReminder(text: string): string {
|
||||
return `<system-reminder>\n${text}\n</system-reminder>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert OpenAI messages to Kiro format
|
||||
* Rules: system/tool/user -> user role, merge consecutive same roles
|
||||
@@ -238,7 +247,11 @@ function convertMessages(messages, tools, model) {
|
||||
content: [{ text: toolContent }],
|
||||
});
|
||||
} else if (content) {
|
||||
pendingUserContent.push(content);
|
||||
// #2306: Kiro/CodeWhisperer has no `system` role, so system messages are
|
||||
// normalized to `user`. Wrap their content in <system-reminder> tags so
|
||||
// the model can tell the system prompt apart from real user input instead
|
||||
// of treating the full Claude Code prompt as something the user typed.
|
||||
pendingUserContent.push(msg.role === "system" ? wrapSystemReminder(content) : content);
|
||||
}
|
||||
} else if (role === "assistant") {
|
||||
// Extract text content and tool uses
|
||||
|
||||
@@ -473,7 +473,16 @@ async function main(): Promise<void> {
|
||||
...(strategiesMod.ROUTING_STRATEGY_VALUES as readonly string[]),
|
||||
...(strategiesMod.INTERNAL_ROUTING_STRATEGY_VALUES as readonly string[]),
|
||||
];
|
||||
const comboSource = readFileSync(resolvePath(REPO_ROOT, "open-sse/services/combo.ts"), "utf8");
|
||||
// The combo dispatch was decomposed (Block J): the `strategy === "..."` branches
|
||||
// now live across combo.ts + its strategy-ordering leaves, so scan all of them.
|
||||
const comboDispatchFiles = [
|
||||
"open-sse/services/combo.ts",
|
||||
"open-sse/services/combo/applyStrategyOrdering.ts",
|
||||
"open-sse/services/combo/resolveAutoStrategy.ts",
|
||||
];
|
||||
const comboSource = comboDispatchFiles
|
||||
.map((rel) => readFileSync(resolvePath(REPO_ROOT, rel), "utf8"))
|
||||
.join("\n");
|
||||
const handled = extractHandledStrategies(comboSource);
|
||||
|
||||
// Stale-enforcement (6A.3): IMPLICIT_DEFAULT_STRATEGIES is a suppression allowlist —
|
||||
|
||||
@@ -4,12 +4,16 @@ import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import { CliproxyServiceTab } from "./tabs/CliproxyServiceTab";
|
||||
import { NinerouterServiceTab } from "./tabs/NinerouterServiceTab";
|
||||
import { MuxServiceTab } from "./tabs/MuxServiceTab";
|
||||
import { BifrostServiceTab } from "./tabs/BifrostServiceTab";
|
||||
|
||||
type Tab = "cliproxy" | "9router";
|
||||
type Tab = "cliproxy" | "9router" | "mux" | "bifrost";
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: string }[] = [
|
||||
{ id: "cliproxy", label: "CLIProxyAPI", icon: "swap_horiz" },
|
||||
{ id: "9router", label: "9Router", icon: "route" },
|
||||
{ id: "mux", label: "Mux", icon: "hub" },
|
||||
{ id: "bifrost", label: "Bifrost", icon: "bolt" },
|
||||
];
|
||||
|
||||
export default function ServicesPage() {
|
||||
@@ -26,7 +30,8 @@ export default function ServicesPage() {
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold text-text-primary">Embedded Services</h1>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
External engines managed on demand — CLIProxyAPI and 9Router. Accessible on loopback only.
|
||||
External engines managed on demand — CLIProxyAPI, 9Router, Mux, and Bifrost. Accessible on
|
||||
loopback only.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -55,6 +60,8 @@ export default function ServicesPage() {
|
||||
<div>
|
||||
{active === "cliproxy" && <CliproxyServiceTab />}
|
||||
{active === "9router" && <NinerouterServiceTab />}
|
||||
{active === "mux" && <MuxServiceTab />}
|
||||
{active === "bifrost" && <BifrostServiceTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { ServiceStatusCard } from "../components/ServiceStatusCard";
|
||||
import { ServiceLifecycleButtons } from "../components/ServiceLifecycleButtons";
|
||||
import { ServiceLogsPanel } from "../components/ServiceLogsPanel";
|
||||
import { AutoStartToggle } from "../components/AutoStartToggle";
|
||||
|
||||
const NAME = "bifrost";
|
||||
|
||||
export function BifrostServiceTab() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ServiceStatusCard name={NAME} />
|
||||
<ServiceLifecycleButtons name={NAME} />
|
||||
<AutoStartToggle
|
||||
name={NAME}
|
||||
description="Launch Bifrost automatically when OmniRoute starts"
|
||||
/>
|
||||
<ServiceLogsPanel name={NAME} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { ServiceStatusCard } from "../components/ServiceStatusCard";
|
||||
import { ServiceLifecycleButtons } from "../components/ServiceLifecycleButtons";
|
||||
import { ServiceLogsPanel } from "../components/ServiceLogsPanel";
|
||||
import { AutoStartToggle } from "../components/AutoStartToggle";
|
||||
|
||||
const NAME = "mux";
|
||||
|
||||
export function MuxServiceTab() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ServiceStatusCard name={NAME} />
|
||||
<ServiceLifecycleButtons name={NAME} />
|
||||
<AutoStartToggle name={NAME} description="Launch Mux automatically when OmniRoute starts" />
|
||||
<ServiceLogsPanel name={NAME} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +77,35 @@ export const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> =
|
||||
.filter((m: any) => m.id);
|
||||
},
|
||||
},
|
||||
// #5858 follow-up: kimi-web (cookie provider) on the international domain.
|
||||
// `GetAvailableModels` returns the model list as a plain JSON envelope
|
||||
// (no Connect framing on either request or response — only the chat
|
||||
// completion endpoint uses the 5-byte envelope). Auth: Bearer JWT extracted
|
||||
// from the `kimi-auth` cookie the user pasted. Agent variants
|
||||
// (`k2d6-agent*`) need a different scenario + agent fields this executor
|
||||
// doesn't shape, so they're filtered out.
|
||||
"kimi-web": {
|
||||
url: "https://www.kimi.com/apiv2/kimi.gateway.config.v1.ConfigService/GetAvailableModels",
|
||||
method: "GET",
|
||||
headers: { accept: "application/json, text/plain, */*", "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => {
|
||||
const list = (data?.availableModels || []) as Array<{
|
||||
key?: string;
|
||||
displayName?: string;
|
||||
thinking?: boolean;
|
||||
}>;
|
||||
return list
|
||||
.filter((m) => typeof m.key === "string" && !m.key?.includes("agent"))
|
||||
.map((m) => ({
|
||||
id: m.key as string,
|
||||
name: m.displayName || (m.key as string),
|
||||
supportsReasoning: !!m.thinking,
|
||||
owned_by: "kimi",
|
||||
}));
|
||||
},
|
||||
},
|
||||
antigravity: {
|
||||
url: getAntigravityModelsDiscoveryUrls()[0],
|
||||
method: "POST",
|
||||
|
||||
@@ -37,6 +37,15 @@ async function getOrInitNamedSupervisor(name: string) {
|
||||
return getOrInitSupervisor();
|
||||
}
|
||||
|
||||
if (name === "mux") {
|
||||
const { getOrInitSupervisor } = await import("../../mux/_lib");
|
||||
return getOrInitSupervisor();
|
||||
}
|
||||
if (name === "bifrost") {
|
||||
const { getOrInitSupervisor } = await import("../../bifrost/_lib");
|
||||
return getOrInitSupervisor();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
29
src/app/api/services/bifrost/_lib.ts
Normal file
29
src/app/api/services/bifrost/_lib.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Shared helpers for /api/services/bifrost/* route handlers.
|
||||
* Creates a supervisor on demand if bootstrap hasn't registered one yet.
|
||||
*/
|
||||
|
||||
import { getSupervisor, registerSupervisor } from "@/lib/services/registry";
|
||||
import { ServiceSupervisor } from "@/lib/services/ServiceSupervisor";
|
||||
import { resolveSpawnArgs, BIFROST_DEFAULT_PORT } from "@/lib/services/installers/bifrost";
|
||||
|
||||
const TOOL = "bifrost";
|
||||
const PORT = parseInt(process.env.BIFROST_PORT ?? String(BIFROST_DEFAULT_PORT), 10);
|
||||
|
||||
export async function getOrInitSupervisor(): Promise<ServiceSupervisor> {
|
||||
const existing = getSupervisor(TOOL);
|
||||
if (existing) return existing;
|
||||
|
||||
const sup = new ServiceSupervisor({
|
||||
tool: TOOL,
|
||||
port: PORT,
|
||||
spawnArgs: () => resolveSpawnArgs(PORT),
|
||||
healthUrl: () => `http://127.0.0.1:${PORT}/v1/models`,
|
||||
healthIntervalMs: 5_000,
|
||||
stopTimeoutMs: 15_000,
|
||||
logsBufferBytes: 5_242_880,
|
||||
});
|
||||
|
||||
registerSupervisor(sup);
|
||||
return sup;
|
||||
}
|
||||
28
src/app/api/services/bifrost/auto-start/route.ts
Normal file
28
src/app/api/services/bifrost/auto-start/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
import { updateServiceField } from "@/lib/db/versionManager";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
const BodySchema = z.object({ enabled: z.boolean() });
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
|
||||
}
|
||||
|
||||
const parsed = BodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return createErrorResponse({ status: 400, message: parsed.error.message });
|
||||
}
|
||||
|
||||
try {
|
||||
await updateServiceField("bifrost", "autoStart", parsed.data.enabled);
|
||||
return new Response(null, { status: 204 });
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
return createErrorResponse({ status: 500, message: msg });
|
||||
}
|
||||
}
|
||||
6
src/app/api/services/bifrost/install/route.ts
Normal file
6
src/app/api/services/bifrost/install/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { install } from "@/lib/services/installers/bifrost";
|
||||
import { handleServiceInstall } from "@/app/api/services/_shared/installRoute";
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleServiceInstall(request, install);
|
||||
}
|
||||
22
src/app/api/services/bifrost/restart/route.ts
Normal file
22
src/app/api/services/bifrost/restart/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getServiceRow } from "@/lib/db/versionManager";
|
||||
import { getOrInitSupervisor } from "../_lib";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
const TOOL = "bifrost";
|
||||
|
||||
export async function POST(): Promise<Response> {
|
||||
try {
|
||||
const row = await getServiceRow(TOOL);
|
||||
if (!row || row.status === "not_installed") {
|
||||
return createErrorResponse({ status: 409, message: "Bifrost não está instalado." });
|
||||
}
|
||||
|
||||
const sup = await getOrInitSupervisor();
|
||||
const status = await sup.restart();
|
||||
return Response.json(status);
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
return createErrorResponse({ status: 503, message: msg });
|
||||
}
|
||||
}
|
||||
22
src/app/api/services/bifrost/start/route.ts
Normal file
22
src/app/api/services/bifrost/start/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getServiceRow } from "@/lib/db/versionManager";
|
||||
import { getOrInitSupervisor } from "../_lib";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
const TOOL = "bifrost";
|
||||
|
||||
export async function POST(): Promise<Response> {
|
||||
try {
|
||||
const row = await getServiceRow(TOOL);
|
||||
if (!row || row.status === "not_installed") {
|
||||
return createErrorResponse({ status: 409, message: "Bifrost não está instalado." });
|
||||
}
|
||||
|
||||
const sup = await getOrInitSupervisor();
|
||||
const status = await sup.start();
|
||||
return Response.json(status);
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
return createErrorResponse({ status: 503, message: msg });
|
||||
}
|
||||
}
|
||||
39
src/app/api/services/bifrost/status/route.ts
Normal file
39
src/app/api/services/bifrost/status/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { getSupervisor } from "@/lib/services/registry";
|
||||
import { getServiceRow } from "@/lib/db/versionManager";
|
||||
import {
|
||||
getInstalledVersion,
|
||||
getLatestVersion,
|
||||
BIFROST_DEFAULT_PORT,
|
||||
} from "@/lib/services/installers/bifrost";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
const TOOL = "bifrost";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
const sup = getSupervisor(TOOL);
|
||||
const row = await getServiceRow(TOOL);
|
||||
|
||||
const liveStatus = sup?.getStatus() ?? null;
|
||||
const installedVersion = await getInstalledVersion();
|
||||
const latestVersion = await getLatestVersion();
|
||||
|
||||
return Response.json({
|
||||
tool: TOOL,
|
||||
state: liveStatus?.state ?? row?.status ?? "unknown",
|
||||
pid: liveStatus?.pid ?? null,
|
||||
port: liveStatus?.port ?? row?.port ?? BIFROST_DEFAULT_PORT,
|
||||
health: liveStatus?.health ?? "unknown",
|
||||
startedAt: liveStatus?.startedAt ?? null,
|
||||
lastError: liveStatus?.lastError ?? row?.errorMessage ?? null,
|
||||
installedVersion: installedVersion ?? row?.installedVersion ?? null,
|
||||
latestVersion,
|
||||
updateAvailable: !!installedVersion && !!latestVersion && installedVersion !== latestVersion,
|
||||
autoStart: row?.autoStart ?? false,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
return createErrorResponse({ status: 500, message: msg });
|
||||
}
|
||||
}
|
||||
19
src/app/api/services/bifrost/stop/route.ts
Normal file
19
src/app/api/services/bifrost/stop/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { getSupervisor } from "@/lib/services/registry";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
const TOOL = "bifrost";
|
||||
|
||||
export async function POST(): Promise<Response> {
|
||||
try {
|
||||
const sup = getSupervisor(TOOL);
|
||||
if (!sup) {
|
||||
return Response.json({ tool: TOOL, state: "stopped" });
|
||||
}
|
||||
const status = await sup.stop();
|
||||
return Response.json(status);
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
return createErrorResponse({ status: 500, message: msg });
|
||||
}
|
||||
}
|
||||
45
src/app/api/services/bifrost/update/route.ts
Normal file
45
src/app/api/services/bifrost/update/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { getSupervisor } from "@/lib/services/registry";
|
||||
import { getOrInitSupervisor } from "../_lib";
|
||||
import {
|
||||
getInstalledVersion,
|
||||
getLatestVersion,
|
||||
update as downloadUpdate,
|
||||
} from "@/lib/services/installers/bifrost";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
export async function POST(): Promise<Response> {
|
||||
try {
|
||||
const [installed, latest] = await Promise.all([getInstalledVersion(), getLatestVersion()]);
|
||||
|
||||
if (installed && latest && installed === latest) {
|
||||
return Response.json({ updated: false, installedVersion: installed, latestVersion: latest });
|
||||
}
|
||||
|
||||
const sup = getSupervisor("bifrost");
|
||||
const wasRunning = sup?.getStatus().state === "running";
|
||||
|
||||
if (wasRunning && sup) {
|
||||
await sup.stop();
|
||||
}
|
||||
|
||||
const result = await downloadUpdate();
|
||||
|
||||
if (wasRunning) {
|
||||
const freshSup = await getOrInitSupervisor();
|
||||
await freshSup.start().catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn("[Services] Could not restart bifrost after update:", msg);
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
updated: true,
|
||||
oldVersion: installed ?? null,
|
||||
newVersion: result.installedVersion,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
return createErrorResponse({ status: 500, message: msg });
|
||||
}
|
||||
}
|
||||
32
src/app/api/services/mux/_lib.ts
Normal file
32
src/app/api/services/mux/_lib.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Shared helpers for /api/services/mux/* route handlers.
|
||||
* Creates a supervisor on demand if bootstrap hasn't registered one yet.
|
||||
*/
|
||||
|
||||
import { getSupervisor, registerSupervisor } from "@/lib/services/registry";
|
||||
import { ServiceSupervisor } from "@/lib/services/ServiceSupervisor";
|
||||
import { resolveSpawnArgs, MUX_DEFAULT_PORT } from "@/lib/services/installers/mux";
|
||||
import { getOrCreateApiKey } from "@/lib/services/apiKey";
|
||||
|
||||
const TOOL = "mux";
|
||||
const PORT = parseInt(process.env.MUX_SERVICE_PORT ?? String(MUX_DEFAULT_PORT), 10);
|
||||
|
||||
export async function getOrInitSupervisor(): Promise<ServiceSupervisor> {
|
||||
const existing = getSupervisor(TOOL);
|
||||
if (existing) return existing;
|
||||
|
||||
const apiKey = await getOrCreateApiKey(TOOL);
|
||||
|
||||
const sup = new ServiceSupervisor({
|
||||
tool: TOOL,
|
||||
port: PORT,
|
||||
spawnArgs: () => resolveSpawnArgs(apiKey, PORT),
|
||||
healthUrl: () => `http://127.0.0.1:${PORT}/health`,
|
||||
healthIntervalMs: 5_000,
|
||||
stopTimeoutMs: 15_000,
|
||||
logsBufferBytes: 5_242_880,
|
||||
});
|
||||
|
||||
registerSupervisor(sup);
|
||||
return sup;
|
||||
}
|
||||
28
src/app/api/services/mux/auto-start/route.ts
Normal file
28
src/app/api/services/mux/auto-start/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
import { updateServiceField } from "@/lib/db/versionManager";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
const BodySchema = z.object({ enabled: z.boolean() });
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
|
||||
}
|
||||
|
||||
const parsed = BodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return createErrorResponse({ status: 400, message: parsed.error.message });
|
||||
}
|
||||
|
||||
try {
|
||||
await updateServiceField("mux", "autoStart", parsed.data.enabled);
|
||||
return new Response(null, { status: 204 });
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
return createErrorResponse({ status: 500, message: msg });
|
||||
}
|
||||
}
|
||||
6
src/app/api/services/mux/install/route.ts
Normal file
6
src/app/api/services/mux/install/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { install } from "@/lib/services/installers/mux";
|
||||
import { handleServiceInstall } from "@/app/api/services/_shared/installRoute";
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleServiceInstall(request, install);
|
||||
}
|
||||
22
src/app/api/services/mux/restart/route.ts
Normal file
22
src/app/api/services/mux/restart/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getServiceRow } from "@/lib/db/versionManager";
|
||||
import { getOrInitSupervisor } from "../_lib";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
const TOOL = "mux";
|
||||
|
||||
export async function POST(): Promise<Response> {
|
||||
try {
|
||||
const row = await getServiceRow(TOOL);
|
||||
if (!row || row.status === "not_installed") {
|
||||
return createErrorResponse({ status: 409, message: "Mux não está instalado." });
|
||||
}
|
||||
|
||||
const sup = await getOrInitSupervisor();
|
||||
const status = await sup.restart();
|
||||
return Response.json(status);
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
return createErrorResponse({ status: 503, message: msg });
|
||||
}
|
||||
}
|
||||
22
src/app/api/services/mux/start/route.ts
Normal file
22
src/app/api/services/mux/start/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getServiceRow } from "@/lib/db/versionManager";
|
||||
import { getOrInitSupervisor } from "../_lib";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
const TOOL = "mux";
|
||||
|
||||
export async function POST(): Promise<Response> {
|
||||
try {
|
||||
const row = await getServiceRow(TOOL);
|
||||
if (!row || row.status === "not_installed") {
|
||||
return createErrorResponse({ status: 409, message: "Mux não está instalado." });
|
||||
}
|
||||
|
||||
const sup = await getOrInitSupervisor();
|
||||
const status = await sup.start();
|
||||
return Response.json(status);
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
return createErrorResponse({ status: 503, message: msg });
|
||||
}
|
||||
}
|
||||
39
src/app/api/services/mux/status/route.ts
Normal file
39
src/app/api/services/mux/status/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { getSupervisor } from "@/lib/services/registry";
|
||||
import { getServiceRow } from "@/lib/db/versionManager";
|
||||
import {
|
||||
getInstalledVersion,
|
||||
getLatestVersion,
|
||||
MUX_DEFAULT_PORT,
|
||||
} from "@/lib/services/installers/mux";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
const TOOL = "mux";
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
const sup = getSupervisor(TOOL);
|
||||
const row = await getServiceRow(TOOL);
|
||||
|
||||
const liveStatus = sup?.getStatus() ?? null;
|
||||
const installedVersion = await getInstalledVersion();
|
||||
const latestVersion = await getLatestVersion();
|
||||
|
||||
return Response.json({
|
||||
tool: TOOL,
|
||||
state: liveStatus?.state ?? row?.status ?? "unknown",
|
||||
pid: liveStatus?.pid ?? null,
|
||||
port: liveStatus?.port ?? row?.port ?? MUX_DEFAULT_PORT,
|
||||
health: liveStatus?.health ?? "unknown",
|
||||
startedAt: liveStatus?.startedAt ?? null,
|
||||
lastError: liveStatus?.lastError ?? row?.errorMessage ?? null,
|
||||
installedVersion: installedVersion ?? row?.installedVersion ?? null,
|
||||
latestVersion,
|
||||
updateAvailable: !!installedVersion && !!latestVersion && installedVersion !== latestVersion,
|
||||
autoStart: row?.autoStart ?? false,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
return createErrorResponse({ status: 500, message: msg });
|
||||
}
|
||||
}
|
||||
19
src/app/api/services/mux/stop/route.ts
Normal file
19
src/app/api/services/mux/stop/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { getSupervisor } from "@/lib/services/registry";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
const TOOL = "mux";
|
||||
|
||||
export async function POST(): Promise<Response> {
|
||||
try {
|
||||
const sup = getSupervisor(TOOL);
|
||||
if (!sup) {
|
||||
return Response.json({ tool: TOOL, state: "stopped" });
|
||||
}
|
||||
const status = await sup.stop();
|
||||
return Response.json(status);
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
return createErrorResponse({ status: 500, message: msg });
|
||||
}
|
||||
}
|
||||
45
src/app/api/services/mux/update/route.ts
Normal file
45
src/app/api/services/mux/update/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { getSupervisor } from "@/lib/services/registry";
|
||||
import { getOrInitSupervisor } from "../_lib";
|
||||
import {
|
||||
getInstalledVersion,
|
||||
getLatestVersion,
|
||||
update as downloadUpdate,
|
||||
} from "@/lib/services/installers/mux";
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
export async function POST(): Promise<Response> {
|
||||
try {
|
||||
const [installed, latest] = await Promise.all([getInstalledVersion(), getLatestVersion()]);
|
||||
|
||||
if (installed && latest && installed === latest) {
|
||||
return Response.json({ updated: false, installedVersion: installed, latestVersion: latest });
|
||||
}
|
||||
|
||||
const sup = getSupervisor("mux");
|
||||
const wasRunning = sup?.getStatus().state === "running";
|
||||
|
||||
if (wasRunning && sup) {
|
||||
await sup.stop();
|
||||
}
|
||||
|
||||
const result = await downloadUpdate();
|
||||
|
||||
if (wasRunning) {
|
||||
const freshSup = await getOrInitSupervisor();
|
||||
await freshSup.start().catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn("[Services] Could not restart mux after update:", msg);
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
updated: true,
|
||||
oldVersion: installed ?? null,
|
||||
newVersion: result.installedVersion,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
return createErrorResponse({ status: 500, message: msg });
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getSupervisor } from "@/lib/services/registry";
|
||||
|
||||
export type RelayRoutingBackend = "ts" | "bifrost" | "auto";
|
||||
|
||||
const VALID_BACKENDS = new Set<RelayRoutingBackend>(["ts", "bifrost", "auto"]);
|
||||
@@ -26,11 +28,22 @@ export function getBifrostRoutingConfig(
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): BifrostRoutingConfig | null {
|
||||
const baseUrl = env.BIFROST_BASE_URL?.replace(/\/$/, "");
|
||||
if (!baseUrl) return null;
|
||||
|
||||
// §4b: if BIFROST_BASE_URL is unset, check if supervised instance is running
|
||||
let resolvedBaseUrl = baseUrl;
|
||||
if (!resolvedBaseUrl) {
|
||||
const sup = getSupervisor("bifrost");
|
||||
if (sup?.getStatus().state === "running") {
|
||||
resolvedBaseUrl = `http://127.0.0.1:${sup.getStatus().port}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedBaseUrl) return null;
|
||||
|
||||
const timeoutMs = Number.parseInt(env.BIFROST_TIMEOUT_MS || "", 10);
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
baseUrl: resolvedBaseUrl,
|
||||
apiKey: env.BIFROST_API_KEY || env.OMNIROUTE_BIFROST_KEY || undefined,
|
||||
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 30000,
|
||||
streamingEnabled: env.BIFROST_STREAMING_ENABLED !== "0",
|
||||
|
||||
12
src/lib/db/migrations/114_mux_service_seed.sql
Normal file
12
src/lib/db/migrations/114_mux_service_seed.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- Migration 114: Seed the Mux (coder/mux) embedded service row.
|
||||
--
|
||||
-- Mux is a local agent-orchestration daemon (npm package `mux`, headless
|
||||
-- `mux server --port <port>` mode) managed via the ServiceSupervisor
|
||||
-- framework, same shape as 9Router (071) and CLIProxyAPI (016/017).
|
||||
-- Seeds a `not_installed` / `auto_start=0` placeholder row so the dashboard
|
||||
-- tab and /api/services/mux/status have a row to read before install.
|
||||
|
||||
INSERT OR IGNORE INTO version_manager
|
||||
(tool, status, port, auto_start, auto_update, provider_expose)
|
||||
VALUES
|
||||
('mux', 'not_installed', 8322, 0, 0, 0);
|
||||
9
src/lib/db/migrations/115_bifrost_service.sql
Normal file
9
src/lib/db/migrations/115_bifrost_service.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Migration 115: Seed version_manager row for Bifrost embedded service
|
||||
--
|
||||
-- Bifrost (npm @maximhq/bifrost) is promoted from env-only relay sidecar
|
||||
-- to a first-class supervised service (v3.8.43).
|
||||
-- The row is seeded with status='not_installed' so the bootstrap loop
|
||||
-- skips it until the user installs via /api/services/bifrost/install.
|
||||
|
||||
INSERT OR IGNORE INTO version_manager (tool, status, port, auto_start, auto_update, provider_expose)
|
||||
VALUES ('bifrost', 'not_installed', 8080, 0, 1, 1);
|
||||
@@ -28,7 +28,8 @@ export async function getOrCreateApiKey(tool: string): Promise<string> {
|
||||
// operator-facing signal.
|
||||
throw new ServiceApiKeyDecryptError(tool);
|
||||
}
|
||||
const key = generateServiceApiKey(tool === "9router" ? "nr" : "cp");
|
||||
const prefix = tool === "9router" ? "nr" : tool === "mux" ? "mx" : "cp";
|
||||
const key = generateServiceApiKey(prefix);
|
||||
await updateServiceField(tool, "apiKey", encrypt(key) ?? key);
|
||||
return key;
|
||||
}
|
||||
|
||||
@@ -7,12 +7,19 @@ import {
|
||||
resolveSpawnArgs as cliproxySpawnArgs,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
} from "./installers/cliproxy";
|
||||
import { resolveSpawnArgs as muxSpawnArgs, MUX_DEFAULT_PORT } from "./installers/mux";
|
||||
import {
|
||||
resolveSpawnArgs as bifrostSpawnArgs,
|
||||
BIFROST_DEFAULT_PORT,
|
||||
} from "./installers/bifrost";
|
||||
import { getOrCreateApiKey } from "./apiKey";
|
||||
import { scheduleServiceModelSync, stopServiceModelSync } from "./modelSync";
|
||||
import type { ServiceStatus } from "./types";
|
||||
|
||||
const NINEROUTER_PORT = parseInt(process.env.NINEROUTER_PORT ?? "20130", 10);
|
||||
const CLIPROXY_PORT = parseInt(process.env.CLIPROXYAPI_PORT ?? String(CLIPROXY_DEFAULT_PORT), 10);
|
||||
const MUX_PORT = parseInt(process.env.MUX_SERVICE_PORT ?? String(MUX_DEFAULT_PORT), 10);
|
||||
const BIFROST_PORT = parseInt(process.env.BIFROST_PORT ?? String(BIFROST_DEFAULT_PORT), 10);
|
||||
|
||||
type ServiceEntry = {
|
||||
tool: string;
|
||||
@@ -43,6 +50,24 @@ const SERVICES: ServiceEntry[] = [
|
||||
logsBufferBytes: 5_242_880,
|
||||
needsApiKey: false,
|
||||
},
|
||||
{
|
||||
tool: "mux",
|
||||
port: MUX_PORT,
|
||||
healthPath: "/health",
|
||||
healthIntervalMs: 5_000,
|
||||
stopTimeoutMs: 15_000,
|
||||
logsBufferBytes: 5_242_880,
|
||||
needsApiKey: true,
|
||||
},
|
||||
{
|
||||
tool: "bifrost",
|
||||
port: BIFROST_PORT,
|
||||
healthPath: "/v1/models",
|
||||
healthIntervalMs: 5_000,
|
||||
stopTimeoutMs: 15_000,
|
||||
logsBufferBytes: 5_242_880,
|
||||
needsApiKey: false,
|
||||
},
|
||||
];
|
||||
|
||||
function buildSpawnArgsFactory(
|
||||
@@ -52,6 +77,12 @@ function buildSpawnArgsFactory(
|
||||
if (cfg.tool === "9router") {
|
||||
return () => nineRouterSpawnArgs(apiKey, cfg.port);
|
||||
}
|
||||
if (cfg.tool === "mux") {
|
||||
return () => muxSpawnArgs(apiKey, cfg.port);
|
||||
}
|
||||
if (cfg.tool === "bifrost") {
|
||||
return () => bifrostSpawnArgs(cfg.port);
|
||||
}
|
||||
return () => cliproxySpawnArgs(cfg.port);
|
||||
}
|
||||
|
||||
|
||||
145
src/lib/services/installers/bifrost.ts
Normal file
145
src/lib/services/installers/bifrost.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DATA_DIR } from "@/lib/db/core";
|
||||
import { upsertVersionManagerTool } from "@/lib/db/versionManager";
|
||||
import { runNpm, InstallError } from "./utils";
|
||||
|
||||
export const BIFROST_PACKAGE = "@maximhq/bifrost";
|
||||
export const BIFROST_DEFAULT_PORT = 8080;
|
||||
export const BIFROST_INSTALL_DIR = path.join(DATA_DIR, "services", "bifrost");
|
||||
|
||||
export interface InstallResult {
|
||||
installedVersion: string;
|
||||
installPath: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface SpawnArgs {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
// In-memory latest-version cache, 1h TTL
|
||||
let latestVersionCache: { value: string; expiresAt: number } | null = null;
|
||||
const VERSION_CACHE_TTL_MS = 3_600_000;
|
||||
|
||||
function getInstalledPkgPath(): string {
|
||||
return path.join(BIFROST_INSTALL_DIR, "node_modules", "@maximhq", "bifrost", "package.json");
|
||||
}
|
||||
|
||||
function getBinPath(): string {
|
||||
return path.join(BIFROST_INSTALL_DIR, "node_modules", "@maximhq", "bifrost", "bin.js");
|
||||
}
|
||||
|
||||
function getInstalledVersionSync(): string | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(getInstalledPkgPath(), "utf8");
|
||||
const parsed = JSON.parse(raw) as { version?: string };
|
||||
return typeof parsed.version === "string" ? parsed.version : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInstalledVersion(): Promise<string | null> {
|
||||
return getInstalledVersionSync();
|
||||
}
|
||||
|
||||
export async function getLatestVersion(): Promise<string | null> {
|
||||
if (latestVersionCache && latestVersionCache.expiresAt > Date.now()) {
|
||||
return latestVersionCache.value;
|
||||
}
|
||||
try {
|
||||
const { stdout } = await runNpm(["view", BIFROST_PACKAGE, "version"], { timeoutMs: 30_000 });
|
||||
const version = stdout.trim();
|
||||
if (version) {
|
||||
latestVersionCache = { value: version, expiresAt: Date.now() + VERSION_CACHE_TTL_MS };
|
||||
}
|
||||
return version || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function install(version = "latest"): Promise<InstallResult> {
|
||||
const startMs = Date.now();
|
||||
|
||||
// Create install dir + minimal package.json (idempotent)
|
||||
fs.mkdirSync(BIFROST_INSTALL_DIR, { recursive: true });
|
||||
const hostPkgPath = path.join(BIFROST_INSTALL_DIR, "package.json");
|
||||
if (!fs.existsSync(hostPkgPath)) {
|
||||
fs.writeFileSync(
|
||||
hostPkgPath,
|
||||
JSON.stringify(
|
||||
{ name: "omniroute-bifrost-host", version: "0.0.0", private: true, dependencies: {} },
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
|
||||
await runNpm(
|
||||
["install", `${BIFROST_PACKAGE}@${version}`, "--omit=dev", "--no-audit", "--no-fund"],
|
||||
// `--prefix` via `prefix` (→ npm_config_prefix env) so paths with spaces survive Windows shell
|
||||
{ cwd: BIFROST_INSTALL_DIR, prefix: BIFROST_INSTALL_DIR }
|
||||
);
|
||||
|
||||
const installedVersion = await getInstalledVersion();
|
||||
if (!installedVersion) {
|
||||
throw new InstallError(
|
||||
"Could not read installed version from node_modules/@maximhq/bifrost/package.json",
|
||||
"Bifrost instalado mas versão não pôde ser lida.",
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
await upsertVersionManagerTool({
|
||||
tool: "bifrost",
|
||||
installedVersion,
|
||||
binaryPath: getBinPath(),
|
||||
status: "stopped",
|
||||
port: BIFROST_DEFAULT_PORT,
|
||||
});
|
||||
|
||||
// Invalidate cache so next getLatestVersion() re-fetches
|
||||
latestVersionCache = null;
|
||||
|
||||
return {
|
||||
installedVersion,
|
||||
installPath: BIFROST_INSTALL_DIR,
|
||||
durationMs: Date.now() - startMs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function update(): Promise<InstallResult> {
|
||||
return install("latest");
|
||||
}
|
||||
|
||||
export function resolveSpawnArgs(port: number): SpawnArgs {
|
||||
const binPath = getBinPath();
|
||||
// Pin transport version to the installed npm version for reproducibility (spec §2b)
|
||||
const transportVersion = getInstalledVersionSync() ?? "latest";
|
||||
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [
|
||||
binPath,
|
||||
"-port",
|
||||
String(port),
|
||||
"-host",
|
||||
"127.0.0.1",
|
||||
"-app-dir",
|
||||
BIFROST_INSTALL_DIR,
|
||||
"-log-level",
|
||||
"warn",
|
||||
],
|
||||
env: {
|
||||
...process.env,
|
||||
BIFROST_TRANSPORT_VERSION: transportVersion,
|
||||
},
|
||||
cwd: BIFROST_INSTALL_DIR,
|
||||
};
|
||||
}
|
||||
178
src/lib/services/installers/mux.ts
Normal file
178
src/lib/services/installers/mux.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Mux (coder/mux) installer adapter for the ServiceSupervisor framework.
|
||||
*
|
||||
* Mux (https://github.com/coder/mux) is a local agent-orchestration daemon
|
||||
* ("AI agent orchestration") published on npm as the `mux` package, with a
|
||||
* documented headless server mode: `mux server --host <host> --port <port>`.
|
||||
* It is installed the same way as 9Router — `npm install` into a
|
||||
* DATA_DIR-scoped directory via `runNpm` (Hard Rule #13: no shell
|
||||
* interpolation, array args + `env` option only) — never a git-clone+build.
|
||||
*
|
||||
* Binary location: $DATA_DIR/services/mux/node_modules/mux/dist/cli/index.js
|
||||
* Data dir: $DATA_DIR/services/mux/data (MUX_HOME — mux's own state)
|
||||
* DB row: version_manager WHERE tool = 'mux'
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DATA_DIR } from "@/lib/db/core";
|
||||
import { upsertVersionManagerTool } from "@/lib/db/versionManager";
|
||||
import { runNpm, InstallError } from "./utils";
|
||||
|
||||
export const MUX_PACKAGE = "mux";
|
||||
export const MUX_DEFAULT_PORT = 8322;
|
||||
export const MUX_INSTALL_DIR = path.join(DATA_DIR, "services", "mux");
|
||||
|
||||
export interface InstallResult {
|
||||
installedVersion: string;
|
||||
installPath: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface SpawnArgs {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
// In-memory latest-version cache, 1h TTL — mirrors ninerouter.ts.
|
||||
let latestVersionCache: { value: string; expiresAt: number } | null = null;
|
||||
const VERSION_CACHE_TTL_MS = 3_600_000;
|
||||
|
||||
function getServerPath(): string {
|
||||
return path.join(MUX_INSTALL_DIR, "node_modules", "mux", "dist", "cli", "index.js");
|
||||
}
|
||||
|
||||
function getInstalledPkgPath(): string {
|
||||
return path.join(MUX_INSTALL_DIR, "node_modules", "mux", "package.json");
|
||||
}
|
||||
|
||||
export async function getInstalledVersion(): Promise<string | null> {
|
||||
try {
|
||||
const raw = fs.readFileSync(getInstalledPkgPath(), "utf8");
|
||||
const parsed = JSON.parse(raw) as { version?: string };
|
||||
return typeof parsed.version === "string" ? parsed.version : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLatestVersion(): Promise<string | null> {
|
||||
if (latestVersionCache && latestVersionCache.expiresAt > Date.now()) {
|
||||
return latestVersionCache.value;
|
||||
}
|
||||
try {
|
||||
const { stdout } = await runNpm(["view", MUX_PACKAGE, "version"], { timeoutMs: 30_000 });
|
||||
const version = stdout.trim();
|
||||
if (version) {
|
||||
latestVersionCache = { value: version, expiresAt: Date.now() + VERSION_CACHE_TTL_MS };
|
||||
}
|
||||
return version || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and install Mux from npm.
|
||||
* Upserts the version_manager row with tool='mux'.
|
||||
*/
|
||||
export async function install(version = "latest"): Promise<InstallResult> {
|
||||
const startMs = Date.now();
|
||||
|
||||
// Create install dir + minimal package.json (idempotent) — same shape as ninerouter.ts.
|
||||
fs.mkdirSync(MUX_INSTALL_DIR, { recursive: true });
|
||||
const hostPkgPath = path.join(MUX_INSTALL_DIR, "package.json");
|
||||
if (!fs.existsSync(hostPkgPath)) {
|
||||
fs.writeFileSync(
|
||||
hostPkgPath,
|
||||
JSON.stringify(
|
||||
{ name: "omniroute-mux-host", version: "0.0.0", private: true, dependencies: {} },
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
|
||||
await runNpm(
|
||||
["install", `${MUX_PACKAGE}@${version}`, "--omit=dev", "--no-audit", "--no-fund"],
|
||||
// `--prefix` is passed via `prefix` (→ npm_config_prefix env) instead of an
|
||||
// argv path so an install dir with spaces survives the Windows shell (#5379).
|
||||
{ cwd: MUX_INSTALL_DIR, prefix: MUX_INSTALL_DIR }
|
||||
);
|
||||
|
||||
const installedVersion = await getInstalledVersion();
|
||||
if (!installedVersion) {
|
||||
throw new InstallError(
|
||||
"Could not read installed version from node_modules/mux/package.json",
|
||||
"Mux instalado mas versão não pôde ser lida.",
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
await upsertVersionManagerTool({
|
||||
tool: "mux",
|
||||
installedVersion,
|
||||
binaryPath: getServerPath(),
|
||||
status: "stopped",
|
||||
port: MUX_DEFAULT_PORT,
|
||||
});
|
||||
|
||||
// Invalidate cache so next getLatestVersion() re-fetches
|
||||
latestVersionCache = null;
|
||||
|
||||
return {
|
||||
installedVersion,
|
||||
installPath: MUX_INSTALL_DIR,
|
||||
durationMs: Date.now() - startMs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function update(): Promise<InstallResult> {
|
||||
return install("latest");
|
||||
}
|
||||
|
||||
export async function uninstall(): Promise<void> {
|
||||
const nmDir = path.join(MUX_INSTALL_DIR, "node_modules");
|
||||
if (fs.existsSync(nmDir)) {
|
||||
fs.rmSync(nmDir, { recursive: true, force: true });
|
||||
}
|
||||
await upsertVersionManagerTool({
|
||||
tool: "mux",
|
||||
status: "not_installed",
|
||||
installedVersion: null,
|
||||
binaryPath: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build spawn args for ServiceSupervisor.start().
|
||||
*
|
||||
* Mux binds to 127.0.0.1 explicitly (never 0.0.0.0) — the dashboard route is
|
||||
* already loopback-gated (Hard Rule #17), and this is defense-in-depth since
|
||||
* Mux orchestrates AI agents that can execute shell commands on the host.
|
||||
* The bearer token is passed via `MUX_SERVER_AUTH_TOKEN` (mux's documented env
|
||||
* form), never as a CLI arg, so it never appears in `ps`/process listings.
|
||||
*/
|
||||
export function resolveSpawnArgs(apiKey: string, port: number): SpawnArgs {
|
||||
const serverPath = getServerPath();
|
||||
// MUX_ROOT is mux's documented override for its home/config/data directory
|
||||
// (defaults to ~/.mux otherwise) — scope it under DATA_DIR like every other
|
||||
// embedded service instead of leaking into the OS-user home directory.
|
||||
const muxRoot = path.join(MUX_INSTALL_DIR, "data");
|
||||
fs.mkdirSync(muxRoot, { recursive: true });
|
||||
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [serverPath, "server", "--host", "127.0.0.1", "--port", String(port)],
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "production",
|
||||
MUX_ROOT: muxRoot,
|
||||
MUX_SERVER_AUTH_TOKEN: apiKey,
|
||||
},
|
||||
cwd: MUX_INSTALL_DIR,
|
||||
};
|
||||
}
|
||||
@@ -92,6 +92,7 @@
|
||||
"tests/unit/claude-passthrough-stream-boolean.test.ts",
|
||||
"tests/unit/claude-passthrough-thinking-2454.test.ts",
|
||||
"tests/unit/cli-simulate.test.ts",
|
||||
"tests/unit/cline-response-envelope.test.ts",
|
||||
"tests/unit/clinepass-provider.test.ts",
|
||||
"tests/unit/codex-failover.test.ts",
|
||||
"tests/unit/codex-session-affinity-reset-aware-5903.test.ts",
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
* Prerequisites when running for real:
|
||||
* - npm is in PATH
|
||||
* - Network access to registry.npmjs.org
|
||||
* - Ports 20130 (9router) and 8317 (cliproxy) are available
|
||||
* - Ports 20130 (9router), 8317 (cliproxy), and 8080 (bifrost) are available
|
||||
* - DATA_DIR is writable
|
||||
* - Bifrost lazily downloads its Go binary from downloads.getmaxim.ai on first
|
||||
* start, so the bifrost block additionally needs network access to that host.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
@@ -245,6 +247,86 @@ describe("cliproxy — full lifecycle (opt-in, RUN_SERVICES_INT=1)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// bifrost lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("bifrost — full lifecycle (opt-in, RUN_SERVICES_INT=1)", () => {
|
||||
it("STEP 1: install bifrost (latest)", async (t) => {
|
||||
if (maybeSkip(t)) return;
|
||||
const { status, body } = await apiPost("/api/services/bifrost/install", {
|
||||
version: "latest",
|
||||
});
|
||||
assert.ok(
|
||||
status === 200,
|
||||
`Expected 200 from install, got ${status}: ${JSON.stringify(body).slice(0, 300)}`
|
||||
);
|
||||
const b = body as Record<string, unknown>;
|
||||
assert.ok(b.ok === true, "install response must have ok:true");
|
||||
assert.ok(
|
||||
typeof b.installedVersion === "string",
|
||||
"install response must have installedVersion"
|
||||
);
|
||||
assert.ok(typeof b.durationMs === "number", "install response must have durationMs");
|
||||
});
|
||||
|
||||
it("STEP 2: verify status is stopped after install", async (t) => {
|
||||
if (maybeSkip(t)) return;
|
||||
const { status, body } = await apiGet("/api/services/bifrost/status");
|
||||
assert.equal(status, 200);
|
||||
const b = body as Record<string, unknown>;
|
||||
assert.equal(b.state, "stopped", `Expected stopped state after install, got: ${b.state}`);
|
||||
assert.ok(
|
||||
typeof b.installedVersion === "string",
|
||||
"installedVersion should be set after install"
|
||||
);
|
||||
});
|
||||
|
||||
it("STEP 3: start bifrost", async (t) => {
|
||||
if (maybeSkip(t)) return;
|
||||
const { status, body } = await apiPost("/api/services/bifrost/start");
|
||||
assert.ok(
|
||||
status === 200,
|
||||
`Expected 200 from start, got ${status}: ${JSON.stringify(body).slice(0, 300)}`
|
||||
);
|
||||
const b = body as Record<string, unknown>;
|
||||
assert.ok(
|
||||
["starting", "running"].includes(b.state as string),
|
||||
`Expected starting or running, got: ${b.state}`
|
||||
);
|
||||
});
|
||||
|
||||
it("STEP 4: wait for bifrost to become healthy (≤60s — Go binary download on first start)", async (t) => {
|
||||
if (maybeSkip(t)) return;
|
||||
// First start lazily downloads the Go binary, so allow a longer window than
|
||||
// the 9router/cliproxy blocks (30s). Confirms open question §2b/§6 (Windows +
|
||||
// headless boot) against a real download.
|
||||
const finalStatus = await waitForState("/api/services/bifrost/status", "running", 60_000);
|
||||
const b = finalStatus as Record<string, unknown>;
|
||||
assert.equal(b.state, "running");
|
||||
assert.equal(b.health, "healthy");
|
||||
assert.ok(typeof b.pid === "number", "pid must be a number when running");
|
||||
assert.equal(b.port, 8080, "bifrost must report its default port 8080");
|
||||
});
|
||||
|
||||
it("STEP 5: stop bifrost", async (t) => {
|
||||
if (maybeSkip(t)) return;
|
||||
const { status, body } = await apiPost("/api/services/bifrost/stop");
|
||||
assert.equal(status, 200);
|
||||
const b = body as Record<string, unknown>;
|
||||
assert.ok(
|
||||
["stopping", "stopped"].includes(b.state as string),
|
||||
`Expected stopping or stopped, got: ${b.state}`
|
||||
);
|
||||
});
|
||||
|
||||
it("STEP 6: status returns stopped after stop", async (t) => {
|
||||
if (maybeSkip(t)) return;
|
||||
const final = await waitForState("/api/services/bifrost/status", "stopped", 15_000);
|
||||
assert.equal((final as Record<string, unknown>).state, "stopped");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Security smoke (requires running server)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -52,6 +52,18 @@ describe("isLocalOnlyPath — /api/services/* and /dashboard/providers/services/
|
||||
assert.equal(isLocalOnlyPath("/api/services/cliproxy/status"), true);
|
||||
});
|
||||
|
||||
it("returns true for /api/services/bifrost/start", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/bifrost/start"), true);
|
||||
});
|
||||
|
||||
it("returns true for /api/services/bifrost/install", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/bifrost/install"), true);
|
||||
});
|
||||
|
||||
it("returns true for /api/services/bifrost/status", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/bifrost/status"), true);
|
||||
});
|
||||
|
||||
it("returns true for /api/services/ (root prefix)", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/"), true);
|
||||
});
|
||||
@@ -110,6 +122,10 @@ describe("isLocalOnlyBypassableByManageScope — /api/services/* is NOT bypassab
|
||||
it("returns false for /api/services/cliproxy/install", () => {
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/services/cliproxy/install"), false);
|
||||
});
|
||||
|
||||
it("returns false for /api/services/bifrost/install (spawn-capable)", () => {
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/services/bifrost/install"), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -27,7 +27,12 @@ test("chat handler maps API key provider quota bypass scope to auth bypass optio
|
||||
});
|
||||
|
||||
test("auto combo disables hard provider quota cutoffs when relay requests bypass", () => {
|
||||
const source = fs.readFileSync(path.join(repoRoot, "open-sse/services/combo.ts"), "utf8");
|
||||
// The auto-strategy bypass logic was extracted verbatim from combo.ts into the
|
||||
// resolveAutoStrategy leaf (Block J Task 2); the source scan follows the code.
|
||||
const source = fs.readFileSync(
|
||||
path.join(repoRoot, "open-sse/services/combo/resolveAutoStrategy.ts"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert.match(source, /relayOptions\?\.bypassProviderQuotaPolicy === true/);
|
||||
assert.match(source, /quotaPreflight:[\s\S]*enabled: false/);
|
||||
|
||||
@@ -189,6 +189,25 @@ test("isLocalOnlyBypassableByManageScope: /api/services/* is NOT bypassable (spa
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/services/"), false);
|
||||
});
|
||||
|
||||
// Hard Rule #17 — Mux (coder/mux) embedded service (spawns child processes via
|
||||
// runNpm install + node server spawn): every /api/services/mux/* route MUST be
|
||||
// classified local-only, same as every other embedded service under this prefix.
|
||||
test("isLocalOnlyPath: /api/services/mux/* is local-only (Hard Rule #17)", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/mux/install"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/services/mux/start"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/services/mux/stop"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/services/mux/restart"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/services/mux/update"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/services/mux/status"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/services/mux/auto-start"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/services/mux/logs"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyBypassableByManageScope: /api/services/mux/* is NOT bypassable (spawn-capable)", () => {
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/services/mux/start"), false);
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/services/mux/install"), false);
|
||||
});
|
||||
|
||||
test("management policy rejects /api/services/ from non-localhost (status 403)", async () => {
|
||||
const ctx = makeCtx("/api/services/9router/start", { host: "evil.tunnel.io" });
|
||||
const outcome = await managementPolicy.evaluate(ctx);
|
||||
|
||||
27
tests/unit/cline-response-envelope.test.ts
Normal file
27
tests/unit/cline-response-envelope.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { unwrapClineNonStreamingEnvelope } = await import(
|
||||
"../../open-sse/handlers/chatCore/clineResponseEnvelope.ts"
|
||||
);
|
||||
|
||||
test("unwrapClineNonStreamingEnvelope extracts Cline wrapped chat completions", () => {
|
||||
const wrapped = {
|
||||
success: true,
|
||||
data: {
|
||||
id: "chatcmpl_cline",
|
||||
model: "cline/model",
|
||||
choices: [{ index: 0, message: { role: "assistant", content: "ok" } }],
|
||||
usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 },
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(unwrapClineNonStreamingEnvelope("cline", wrapped), wrapped.data);
|
||||
});
|
||||
|
||||
test("unwrapClineNonStreamingEnvelope keeps non-Cline and malformed envelopes untouched", () => {
|
||||
const wrapped = { success: true, data: { message: "missing choices" } };
|
||||
|
||||
assert.equal(unwrapClineNonStreamingEnvelope("openai", wrapped), wrapped);
|
||||
assert.equal(unwrapClineNonStreamingEnvelope("cline", wrapped), wrapped);
|
||||
});
|
||||
72
tests/unit/combo-apply-strategy-ordering-split.test.ts
Normal file
72
tests/unit/combo-apply-strategy-ordering-split.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { applyStrategyOrdering } from "@omniroute/open-sse/services/combo/applyStrategyOrdering.ts";
|
||||
import { resetDbInstance } from "@/lib/db/core.ts";
|
||||
|
||||
// Split guard for Block J Task 3: the non-`auto` strategy-ordering chain
|
||||
// (lkgp / strict-random / random / fill-first / p2c / ... / quota-share) was
|
||||
// extracted verbatim into applyStrategyOrdering. These tests pin the exits that
|
||||
// need no DB/deck state (random / fill-first / unknown); the DB-backed branches
|
||||
// (lkgp, reset-*, quota-share) are covered end-to-end by the 47 consumer tests
|
||||
// (router-strategies / combo-strategy-fallbacks / rr-session-stickiness).
|
||||
|
||||
after(() => {
|
||||
// some branches (lkgp/quota-share) may touch the DB singleton; release handles.
|
||||
resetDbInstance();
|
||||
});
|
||||
|
||||
const noopLog = { info() {}, warn() {}, error() {}, debug() {} } as never;
|
||||
|
||||
const target = (provider: string, modelStr: string): never =>
|
||||
({
|
||||
kind: "model",
|
||||
stepId: "s1",
|
||||
executionKey: `${provider}>${modelStr}`,
|
||||
modelStr,
|
||||
provider,
|
||||
providerId: null,
|
||||
connectionId: null,
|
||||
weight: 1,
|
||||
label: null,
|
||||
}) as never;
|
||||
|
||||
const deps = () =>
|
||||
({
|
||||
combo: { id: "c1", name: "c1", config: {} },
|
||||
config: {},
|
||||
body: { messages: [] },
|
||||
log: noopLog,
|
||||
apiKeyAllowedConnections: null,
|
||||
}) as never;
|
||||
|
||||
const keys = (arr: Array<{ executionKey: string }>) => arr.map((t) => t.executionKey).sort();
|
||||
|
||||
test("exports applyStrategyOrdering", () => {
|
||||
assert.equal(typeof applyStrategyOrdering, "function");
|
||||
});
|
||||
|
||||
test("unknown strategy -> input order unchanged (same reference contents)", async () => {
|
||||
const input = [target("openai", "gpt-4o"), target("anthropic", "claude-3")];
|
||||
const out = await applyStrategyOrdering("no-such-strategy", input, deps());
|
||||
assert.deepEqual(
|
||||
out.map((t: { executionKey: string }) => t.executionKey),
|
||||
["openai>gpt-4o", "anthropic>claude-3"]
|
||||
);
|
||||
});
|
||||
|
||||
test("fill-first -> preserves priority order", async () => {
|
||||
const input = [target("a", "m1"), target("b", "m2"), target("c", "m3")];
|
||||
const out = await applyStrategyOrdering("fill-first", input, deps());
|
||||
assert.deepEqual(
|
||||
out.map((t: { executionKey: string }) => t.executionKey),
|
||||
["a>m1", "b>m2", "c>m3"]
|
||||
);
|
||||
});
|
||||
|
||||
test("random -> same multiset of targets (a permutation)", async () => {
|
||||
const input = [target("a", "m1"), target("b", "m2"), target("c", "m3")];
|
||||
const out = await applyStrategyOrdering("random", input, deps());
|
||||
assert.equal(out.length, 3);
|
||||
assert.deepEqual(keys(out), keys(input));
|
||||
});
|
||||
79
tests/unit/combo-auto-config-split.test.ts
Normal file
79
tests/unit/combo-auto-config-split.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { parseAutoConfig } from "@omniroute/open-sse/services/combo/autoConfig.ts";
|
||||
import { DEFAULT_WEIGHTS } from "@omniroute/open-sse/services/autoCombo/scoring.ts";
|
||||
|
||||
// Split guard for Block J Task 2: parseAutoConfig was extracted verbatim from
|
||||
// handleComboChat's inline auto-strategy config block. These assertions pin the
|
||||
// pure derivation so the extraction stays behavior-identical.
|
||||
|
||||
const target = (provider: string, modelStr: string) =>
|
||||
({ provider, modelStr, executionKey: `${provider}>${modelStr}` }) as never;
|
||||
|
||||
test("defaults: rules strategy, provider-derived pool, default weights", () => {
|
||||
const cfg = parseAutoConfig({ name: "c", config: {} } as never, [
|
||||
target("openai", "gpt-4o"),
|
||||
target("anthropic", "claude-3"),
|
||||
target("openai", "gpt-4o-mini"),
|
||||
]);
|
||||
assert.equal(cfg.routingStrategy, "rules");
|
||||
assert.deepEqual(cfg.candidatePool, ["openai", "anthropic"]);
|
||||
assert.equal(cfg.weights, DEFAULT_WEIGHTS);
|
||||
assert.equal(cfg.explorationRate, 0.05);
|
||||
assert.equal(cfg.budgetCap, undefined);
|
||||
assert.equal(cfg.modePack, undefined);
|
||||
});
|
||||
|
||||
test("routerStrategy takes precedence over routingStrategy/strategyName", () => {
|
||||
const cfg = parseAutoConfig(
|
||||
{
|
||||
name: "c",
|
||||
autoConfig: {
|
||||
routerStrategy: "lkgp",
|
||||
routingStrategy: "cost",
|
||||
strategyName: "p2c",
|
||||
},
|
||||
} as never,
|
||||
[]
|
||||
);
|
||||
assert.equal(cfg.routingStrategy, "lkgp");
|
||||
});
|
||||
|
||||
test("explicit candidatePool, weights, exploration and budget are honored", () => {
|
||||
const customWeights = { latency: 1 } as never;
|
||||
const cfg = parseAutoConfig(
|
||||
{
|
||||
name: "c",
|
||||
autoConfig: {
|
||||
candidatePool: ["glm", "openai"],
|
||||
weights: customWeights,
|
||||
explorationRate: 0.3,
|
||||
budgetCap: 5,
|
||||
modePack: "coding",
|
||||
},
|
||||
} as never,
|
||||
[target("ignored", "x")]
|
||||
);
|
||||
assert.deepEqual(cfg.candidatePool, ["glm", "openai"]);
|
||||
assert.equal(cfg.weights, customWeights);
|
||||
assert.equal(cfg.explorationRate, 0.3);
|
||||
assert.equal(cfg.budgetCap, 5);
|
||||
assert.equal(cfg.modePack, "coding");
|
||||
});
|
||||
|
||||
test("config.auto is preferred over top-level config", () => {
|
||||
const cfg = parseAutoConfig(
|
||||
{ name: "c", config: { auto: { routerStrategy: "cost" }, routerStrategy: "rules" } } as never,
|
||||
[]
|
||||
);
|
||||
assert.equal(cfg.routingStrategy, "cost");
|
||||
});
|
||||
|
||||
test("non-finite explorationRate falls back to 0.05", () => {
|
||||
const cfg = parseAutoConfig(
|
||||
{ name: "c", autoConfig: { explorationRate: "not-a-number" } } as never,
|
||||
[]
|
||||
);
|
||||
assert.equal(cfg.explorationRate, 0.05);
|
||||
});
|
||||
88
tests/unit/combo-resolve-auto-strategy-split.test.ts
Normal file
88
tests/unit/combo-resolve-auto-strategy-split.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveAutoStrategyOrder } from "@omniroute/open-sse/services/combo/resolveAutoStrategy.ts";
|
||||
import { resetDbInstance } from "@/lib/db/core.ts";
|
||||
|
||||
// resolveAutoStrategyOrder loads the LKGP via the DB singleton (dynamic import);
|
||||
// release the handle so the node:test runner does not hang on teardown (learning #3).
|
||||
after(() => {
|
||||
resetDbInstance();
|
||||
});
|
||||
|
||||
// Split guard for Block J Task 2 (coupled slice): the `if (strategy === "auto")`
|
||||
// branch of handleComboChat was extracted verbatim into resolveAutoStrategyOrder,
|
||||
// with `buildAutoCandidates` injected (it lives in combo.ts, so a direct import
|
||||
// would cycle). These tests pin the DI contract and the two control-flow exits
|
||||
// that the host now forwards: an early 429 Response, and the default-ordering
|
||||
// pass-through. The routable-selection path is covered end-to-end by the 60
|
||||
// consumer tests (router-strategies / auto-combo-engine / combo-strategy-fallbacks).
|
||||
|
||||
const noopLog = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
} as never;
|
||||
|
||||
const target = (provider: string, modelStr: string): never =>
|
||||
({
|
||||
kind: "model",
|
||||
stepId: "s1",
|
||||
executionKey: `${provider}>${modelStr}`,
|
||||
modelStr,
|
||||
provider,
|
||||
providerId: null,
|
||||
connectionId: null,
|
||||
weight: 1,
|
||||
label: null,
|
||||
}) as never;
|
||||
|
||||
const baseDeps = (buildAutoCandidates: never) =>
|
||||
({
|
||||
orderedTargets: [target("openai", "gpt-4o"), target("anthropic", "claude-3")],
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
combo: { id: "c1", name: "autoc", config: {} },
|
||||
settings: null,
|
||||
config: {},
|
||||
relayOptions: null,
|
||||
resilienceSettings: { quotaPreflight: { enabled: false } },
|
||||
log: noopLog,
|
||||
buildAutoCandidates,
|
||||
}) as never;
|
||||
|
||||
test("exports resolveAutoStrategyOrder", () => {
|
||||
assert.equal(typeof resolveAutoStrategyOrder, "function");
|
||||
});
|
||||
|
||||
test("no candidates -> keeps default ordering, no explicit router", async () => {
|
||||
const build = (async () => []) as never;
|
||||
const result = await resolveAutoStrategyOrder(baseDeps(build));
|
||||
assert.ok(!("earlyResponse" in result));
|
||||
if ("orderedTargets" in result) {
|
||||
assert.equal(result.autoUsedExplicitRouter, false);
|
||||
// default ordering preserved (both original targets survive)
|
||||
assert.equal(result.orderedTargets.length, 2);
|
||||
assert.equal(result.orderedTargets[0].provider, "openai");
|
||||
}
|
||||
});
|
||||
|
||||
test("all candidates quota-cutoff-blocked -> early 429 Response", async () => {
|
||||
const build = (async () => [
|
||||
{
|
||||
kind: "model",
|
||||
stepId: "s1",
|
||||
executionKey: "openai>gpt-4o",
|
||||
modelStr: "gpt-4o",
|
||||
provider: "openai",
|
||||
model: "gpt-4o",
|
||||
quotaCutoffBlocked: true,
|
||||
},
|
||||
]) as never;
|
||||
const result = await resolveAutoStrategyOrder(baseDeps(build));
|
||||
assert.ok("earlyResponse" in result);
|
||||
if ("earlyResponse" in result) {
|
||||
assert.ok(result.earlyResponse instanceof Response);
|
||||
assert.equal(result.earlyResponse.status, 429);
|
||||
}
|
||||
});
|
||||
@@ -14,16 +14,22 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const COMBO_SRC = path.resolve(__dirname, "../../open-sse/services/combo.ts");
|
||||
// The LKGP fallback (and every non-auto strategy ordering) was extracted verbatim
|
||||
// from combo.ts into the applyStrategyOrdering leaf (Block J Task 3); the guard
|
||||
// scans follow the code to the leaf that now owns the `target.modelStr` usages.
|
||||
const STRATEGY_SRC = path.resolve(
|
||||
__dirname,
|
||||
"../../open-sse/services/combo/applyStrategyOrdering.ts"
|
||||
);
|
||||
const TEST_ROUTE_SRC = path.resolve(__dirname, "../../src/app/api/combos/test/route.ts");
|
||||
|
||||
test("#2359 combo.ts LKGP findIndex guards modelStr against non-string", () => {
|
||||
const src = fs.readFileSync(COMBO_SRC, "utf8");
|
||||
test("#2359 LKGP findIndex guards modelStr against non-string", () => {
|
||||
const src = fs.readFileSync(STRATEGY_SRC, "utf8");
|
||||
// The findIndex on orderedTargets must check `typeof target.modelStr === "string"`
|
||||
// before calling .startsWith. Anchor on the LKGP fallback branch.
|
||||
assert.ok(
|
||||
/typeof target\.modelStr === "string"[\s\S]{0,80}target\.modelStr\.startsWith/.test(src),
|
||||
"LKGP fallback in combo.ts must type-check target.modelStr before calling .startsWith"
|
||||
"LKGP fallback must type-check target.modelStr before calling .startsWith"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -41,8 +47,8 @@ test("#2359 combo test route falls back instead of throwing on missing modelStr"
|
||||
);
|
||||
});
|
||||
|
||||
test("#2359 combo.ts has no remaining unguarded target.modelStr.<method> usages", () => {
|
||||
const src = fs.readFileSync(COMBO_SRC, "utf8");
|
||||
test("#2359 strategy ordering has no remaining unguarded target.modelStr.<method> usages", () => {
|
||||
const src = fs.readFileSync(STRATEGY_SRC, "utf8");
|
||||
// Strip the line that contains the guard so the regex below only catches
|
||||
// direct, unguarded method calls.
|
||||
const stripped = src.replace(/typeof target\.modelStr === "string"[^\n]*\n[^\n]*/g, "");
|
||||
|
||||
@@ -166,3 +166,200 @@ test("a 200/benign status with no exhaustion mutates nothing and returns false",
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
test("does NOT mark provider exhausted for per-model-quota providers (different model)", () => {
|
||||
const s = sets();
|
||||
const exhausted = applyComboTargetExhaustion(target({ provider: "gemini" }), {
|
||||
...baseOpts,
|
||||
result: { status: 429 },
|
||||
fallbackResult: { reason: "quota_exhausted" },
|
||||
errorText: "quota exceeded for model gpt-4",
|
||||
sets: s,
|
||||
});
|
||||
assert.equal(exhausted, false);
|
||||
assert.equal(s.exhaustedProviders.has("gemini"), false);
|
||||
assert.ok(s.transientRateLimitedProviders.has("gemini"));
|
||||
});
|
||||
|
||||
test("does NOT mark provider exhausted for empty provider strings", () => {
|
||||
const s = sets();
|
||||
const exhausted = applyComboTargetExhaustion(target({ provider: "" }), {
|
||||
...baseOpts,
|
||||
result: { status: 503 },
|
||||
fallbackResult: { error: { code: "quota_exhausted" } },
|
||||
errorText: "quota exhausted",
|
||||
allAccountsRateLimited: true,
|
||||
sets: s,
|
||||
});
|
||||
assert.equal(exhausted, false);
|
||||
});
|
||||
|
||||
test("does NOT mark transientRateLimited on 429 when isTokenLimitBreach is true", () => {
|
||||
const s = sets();
|
||||
const exhausted = applyComboTargetExhaustion(target(), {
|
||||
...baseOpts,
|
||||
result: { status: 429 },
|
||||
fallbackResult: {},
|
||||
errorText: "Token limit exceeded",
|
||||
isTokenLimitBreach: true,
|
||||
sets: s,
|
||||
});
|
||||
assert.equal(exhausted, false);
|
||||
assert.equal(s.transientRateLimitedProviders.has("test-dedup-provider"), false);
|
||||
assert.equal(s.exhaustedProviders.has("test-dedup-provider"), false);
|
||||
});
|
||||
|
||||
test("does NOT mark anything for circuit-open (X-OmniRoute-Provider-Breaker header)", () => {
|
||||
const s = sets();
|
||||
const exhausted = applyComboTargetExhaustion(target(), {
|
||||
...baseOpts,
|
||||
result: { status: 503, headers: new Map([["x-omniroute-provider-breaker", "open"]]) },
|
||||
fallbackResult: {},
|
||||
errorText: "",
|
||||
sets: s,
|
||||
});
|
||||
assert.equal(exhausted, false);
|
||||
assert.equal(s.exhaustedProviders.has("test-dedup-provider"), false);
|
||||
assert.equal(s.exhaustedConnections.has("test-dedup-provider:conn-1"), false);
|
||||
assert.equal(s.transientRateLimitedProviders.has("test-dedup-provider"), false);
|
||||
});
|
||||
|
||||
test("does NOT mark exhaustion for non-connection-level status codes (400)", () => {
|
||||
const s = sets();
|
||||
const exhausted = applyComboTargetExhaustion(target(), {
|
||||
...baseOpts,
|
||||
result: { status: 400 },
|
||||
fallbackResult: {},
|
||||
errorText: "Bad Request",
|
||||
sets: s,
|
||||
});
|
||||
assert.equal(exhausted, false);
|
||||
assert.equal(s.exhaustedConnections.size, 0);
|
||||
assert.equal(s.exhaustedProviders.size, 0);
|
||||
assert.equal(s.transientRateLimitedProviders.size, 0);
|
||||
});
|
||||
|
||||
test("does NOT mark connection exhausted for per-model-quota provider on 500 (gemini model-level error)", () => {
|
||||
const s = sets();
|
||||
const exhausted = applyComboTargetExhaustion(
|
||||
target({ provider: "gemini", connectionId: "gemini-conn-1" }),
|
||||
{
|
||||
...baseOpts,
|
||||
result: { status: 500 },
|
||||
fallbackResult: {},
|
||||
errorText: "Internal error encountered.",
|
||||
rawModel: "gemma-4-31b-it",
|
||||
sets: s,
|
||||
}
|
||||
);
|
||||
assert.equal(exhausted, false);
|
||||
assert.equal(s.exhaustedProviders.has("gemini"), false);
|
||||
assert.equal(s.exhaustedConnections.has("gemini:gemini-conn-1"), false);
|
||||
assert.equal(s.transientRateLimitedProviders.has("gemini"), false);
|
||||
});
|
||||
|
||||
// Sanitized Gemini 500 response — model-level "Internal error encountered" should NOT exhaust
|
||||
// the connection, allowing sibling models on the same provider to be tried.
|
||||
test("gemini 500 INTERNAL (sanitized real response) does NOT exhaust connection — sibling retry", () => {
|
||||
const s = sets();
|
||||
const exhausted = applyComboTargetExhaustion(
|
||||
target({ provider: "gemini", connectionId: "gemini-key-abc" }),
|
||||
{
|
||||
...baseOpts,
|
||||
result: { status: 500 },
|
||||
fallbackResult: {},
|
||||
errorText: "Internal error encountered.",
|
||||
rawModel: "gemma-4-31b-it",
|
||||
structuredError: { code: 500, status: "INTERNAL", message: "Internal error encountered." },
|
||||
sets: s,
|
||||
}
|
||||
);
|
||||
assert.equal(exhausted, false, "providerExhausted must be false");
|
||||
assert.equal(s.exhaustedProviders.has("gemini"), false, "must not exhaust provider");
|
||||
assert.equal(
|
||||
s.exhaustedConnections.has("gemini:gemini-key-abc"),
|
||||
false,
|
||||
"must not exhaust connection — sibling model may succeed"
|
||||
);
|
||||
assert.equal(s.transientRateLimitedProviders.has("gemini"), false);
|
||||
});
|
||||
|
||||
// Non-500 connection-level errors MUST exhaust the connection even for per-model-quota providers.
|
||||
// A 503 (Service Unavailable) means the upstream is down — retrying sibling models wastes calls.
|
||||
test("gemini 503 DOES exhaust connection (upstream down, not model-level)", () => {
|
||||
const s = sets();
|
||||
const exhausted = applyComboTargetExhaustion(
|
||||
target({ provider: "gemini", connectionId: "gemini-key-abc" }),
|
||||
{
|
||||
...baseOpts,
|
||||
result: { status: 503 },
|
||||
fallbackResult: {},
|
||||
errorText: "The service is currently unavailable.",
|
||||
rawModel: "gemma-4-31b-it",
|
||||
sets: s,
|
||||
}
|
||||
);
|
||||
assert.equal(exhausted, false, "providerExhausted is false (not quota)");
|
||||
assert.equal(
|
||||
s.exhaustedConnections.has("gemini:gemini-key-abc"),
|
||||
true,
|
||||
"503 must exhaust connection — upstream is down"
|
||||
);
|
||||
assert.equal(s.exhaustedProviders.size, 0);
|
||||
});
|
||||
|
||||
test("gemini 502 DOES exhaust connection (bad gateway)", () => {
|
||||
const s = sets();
|
||||
const exhausted = applyComboTargetExhaustion(
|
||||
target({ provider: "gemini", connectionId: "gemini-key-abc" }),
|
||||
{
|
||||
...baseOpts,
|
||||
result: { status: 502 },
|
||||
fallbackResult: {},
|
||||
errorText: "Bad Gateway",
|
||||
rawModel: "gemma-4-31b-it",
|
||||
sets: s,
|
||||
}
|
||||
);
|
||||
assert.equal(exhausted, false);
|
||||
assert.equal(s.exhaustedConnections.has("gemini:gemini-key-abc"), true);
|
||||
});
|
||||
|
||||
test("gemini 504 DOES exhaust connection (gateway timeout)", () => {
|
||||
const s = sets();
|
||||
applyComboTargetExhaustion(target({ provider: "gemini", connectionId: "gemini-key-abc" }), {
|
||||
...baseOpts,
|
||||
result: { status: 504 },
|
||||
fallbackResult: {},
|
||||
errorText: "Gateway Timeout",
|
||||
rawModel: "gemini-2.0-flash",
|
||||
sets: s,
|
||||
});
|
||||
assert.equal(s.exhaustedConnections.has("gemini:gemini-key-abc"), true);
|
||||
});
|
||||
|
||||
test("gemini 408 DOES exhaust connection (request timeout)", () => {
|
||||
const s = sets();
|
||||
applyComboTargetExhaustion(target({ provider: "gemini", connectionId: "gemini-key-abc" }), {
|
||||
...baseOpts,
|
||||
result: { status: 408 },
|
||||
fallbackResult: {},
|
||||
errorText: "Request Timeout",
|
||||
rawModel: "gemini-2.0-flash",
|
||||
sets: s,
|
||||
});
|
||||
assert.equal(s.exhaustedConnections.has("gemini:gemini-key-abc"), true);
|
||||
});
|
||||
|
||||
test("gemini 524 DOES exhaust connection (cloudflare timeout)", () => {
|
||||
const s = sets();
|
||||
applyComboTargetExhaustion(target({ provider: "gemini", connectionId: "gemini-key-abc" }), {
|
||||
...baseOpts,
|
||||
result: { status: 524 },
|
||||
fallbackResult: {},
|
||||
errorText: "A Timeout Occurred",
|
||||
rawModel: "gemini-2.0-flash",
|
||||
sets: s,
|
||||
});
|
||||
assert.equal(s.exhaustedConnections.has("gemini:gemini-key-abc"), true);
|
||||
});
|
||||
|
||||
17
tests/unit/dashboard/providers/services/mux-tab.test.ts
Normal file
17
tests/unit/dashboard/providers/services/mux-tab.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* MuxServiceTab unit test — verifies module shape only (no DOM renderer wired
|
||||
* into the node:test runner for this suite; mirrors CliproxyServiceTab.tsx's
|
||||
* module-shape test).
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
describe("MuxServiceTab — module shape", () => {
|
||||
it("exports MuxServiceTab function", async () => {
|
||||
const mod = await import(
|
||||
"../../../../../src/app/(dashboard)/dashboard/providers/services/tabs/MuxServiceTab.tsx"
|
||||
);
|
||||
assert.equal(typeof mod.MuxServiceTab, "function");
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@ describe("KimiWebExecutor", () => {
|
||||
it("execute returns a 400 error when no JWT is provided", async () => {
|
||||
const executor = new mod.KimiWebExecutor();
|
||||
const result = await executor.execute({
|
||||
model: "kimi-default",
|
||||
model: "k2d6",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: false,
|
||||
credentials: { apiKey: "" },
|
||||
@@ -43,7 +43,7 @@ describe("KimiWebExecutor", () => {
|
||||
});
|
||||
}) as typeof fetch;
|
||||
await executor.execute({
|
||||
model: "kimi-default",
|
||||
model: "k2d6",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: false,
|
||||
credentials: { apiKey: "kimi-auth=fake.jwt.token" },
|
||||
@@ -57,6 +57,28 @@ describe("KimiWebExecutor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModelConfig", () => {
|
||||
const { resolveModelConfig } = mod;
|
||||
|
||||
it("maps k2d6-thinking to the K2D5 scenario with thinking enabled", () => {
|
||||
const cfg = resolveModelConfig("k2d6-thinking");
|
||||
assert.equal(cfg.scenario, "SCENARIO_K2D5");
|
||||
assert.equal(cfg.thinking, true);
|
||||
});
|
||||
|
||||
it("maps k2d6 (Instant) to the K2D5 scenario without thinking", () => {
|
||||
const cfg = resolveModelConfig("k2d6");
|
||||
assert.equal(cfg.scenario, "SCENARIO_K2D5");
|
||||
assert.equal(cfg.thinking, false);
|
||||
});
|
||||
|
||||
it("falls back to K2D5 + no thinking for an unknown model id", () => {
|
||||
const cfg = resolveModelConfig("k2d6-agent");
|
||||
assert.equal(cfg.scenario, "SCENARIO_K2D5");
|
||||
assert.equal(cfg.thinking, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractKimiJwt", () => {
|
||||
const { extractKimiJwt } = mod;
|
||||
|
||||
|
||||
41
tests/unit/gemini-multipleof-2309.test.ts
Normal file
41
tests/unit/gemini-multipleof-2309.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* #2309 — antigravity/gemini returned [400] "Invalid JSON payload received.
|
||||
* Unknown name \"multipleOf\" at 'request.tools[0].function_declarations[...]"
|
||||
*
|
||||
* Root cause: `multipleOf` (a JSON Schema numeric constraint) was NOT listed in
|
||||
* `GEMINI_UNSUPPORTED_SCHEMA_KEYS`, so `cleanJSONSchemaForAntigravity` left it in
|
||||
* the function-declaration parameters. The Gemini/antigravity upstream (OpenAPI
|
||||
* 3.0 schema subset) rejects `multipleOf` with a hard 400.
|
||||
*
|
||||
* Fix: add `multipleOf` to the unsupported-keys set so it is stripped at every
|
||||
* level (top-level property, nested object, and inside array `items`). Sibling
|
||||
* numeric constraints `minimum`/`maximum` ARE accepted by Gemini and must stay.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
cleanJSONSchemaForAntigravity,
|
||||
GEMINI_UNSUPPORTED_SCHEMA_KEYS,
|
||||
} from "../../open-sse/translator/helpers/geminiHelper.ts";
|
||||
|
||||
test("#2309 multipleOf is stripped at all levels for antigravity/gemini schemas", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "integer", multipleOf: 2, minimum: 0 },
|
||||
ratio: { type: "number", multipleOf: 0.5 },
|
||||
tags: { type: "array", items: { type: "number", multipleOf: 10 } },
|
||||
},
|
||||
};
|
||||
|
||||
const cleaned = JSON.stringify(cleanJSONSchemaForAntigravity(schema));
|
||||
|
||||
assert.ok(!cleaned.includes("multipleOf"), "multipleOf must be removed");
|
||||
// Gemini DOES support minimum/maximum — those must survive.
|
||||
assert.ok(cleaned.includes("minimum"), "minimum must be preserved");
|
||||
});
|
||||
|
||||
test("#2309 multipleOf is in GEMINI_UNSUPPORTED_SCHEMA_KEYS", () => {
|
||||
assert.ok(GEMINI_UNSUPPORTED_SCHEMA_KEYS.has("multipleOf"));
|
||||
});
|
||||
35
tests/unit/kiro-system-reminder-2306.test.ts
Normal file
35
tests/unit/kiro-system-reminder-2306.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* #2306 — When Claude Code routes through the Kiro/CodeWhisperer backend, the
|
||||
* `system` message was normalized to `role: user` WITHOUT any wrapper, so the
|
||||
* full system prompt (env info, tool defs, memory instructions, etc.) appeared
|
||||
* as raw user text — indistinguishable from real user input, polluting context.
|
||||
*
|
||||
* Fix: wrap system-origin content in `<system-reminder>...</system-reminder>`
|
||||
* before it is merged into the Kiro user message. Real user turns stay raw.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildKiroPayload } from "../../open-sse/translator/request/openai-to-kiro.ts";
|
||||
|
||||
test("#2306 system prompt is wrapped in <system-reminder> for Kiro, not raw user text", () => {
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "system", content: "You are Claude Code. ENV: cwd=/tmp. Secret: do not reveal." },
|
||||
{ role: "user", content: "hello there" },
|
||||
],
|
||||
};
|
||||
|
||||
const payload = JSON.stringify(buildKiroPayload("claude-sonnet-4-5", body, false, {}));
|
||||
|
||||
assert.ok(payload.includes("<system-reminder>"), "system content must be wrapped");
|
||||
assert.ok(payload.includes("You are Claude Code"), "system text must still be present");
|
||||
// The real user turn must NOT be wrapped.
|
||||
assert.ok(payload.includes("hello there"), "user text preserved");
|
||||
});
|
||||
|
||||
test("#2306 a plain user-only request is never wrapped in <system-reminder>", () => {
|
||||
const body = { messages: [{ role: "user", content: "just a normal question" }] };
|
||||
const payload = JSON.stringify(buildKiroPayload("claude-sonnet-4-5", body, false, {}));
|
||||
assert.ok(!payload.includes("<system-reminder>"), "no system → no wrapper");
|
||||
});
|
||||
@@ -22,8 +22,10 @@ import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
extractThinkingFromContent,
|
||||
isTextualReasoningTagNativeRoute,
|
||||
shouldParseTextualReasoningTags,
|
||||
} from "../../open-sse/handlers/responseSanitizer/reasoning.ts";
|
||||
import { sanitizeOpenAIResponse } from "../../open-sse/handlers/responseSanitizer.ts";
|
||||
|
||||
describe("responseSanitizer/reasoning — extractThinkingFromContent", () => {
|
||||
it("leaves tag-free content untouched (thinking = null)", () => {
|
||||
@@ -50,6 +52,84 @@ describe("responseSanitizer/reasoning — shouldParseTextualReasoningTags", () =
|
||||
});
|
||||
});
|
||||
|
||||
// ── MiniMax M3 textual reasoning-tag route (9router#2231) ──────────────────────
|
||||
//
|
||||
// MiniMax M3 leaks raw <think>...</think> into `content` instead of a separate
|
||||
// reasoning_content field on the 8 OpenAI-format provider tiers below. The two
|
||||
// direct minimax/minimax-cn tiers stay on Anthropic's Messages format
|
||||
// (targetFormat: "claude") and already surface reasoning natively — they must
|
||||
// stay unaffected.
|
||||
describe("responseSanitizer/reasoning — MiniMax M3 textual reasoning-tag route", () => {
|
||||
const affectedRoutes: Array<[string, string]> = [
|
||||
["trae", "minimax-m3"],
|
||||
["huggingchat", "minimaxai/minimax-m3"],
|
||||
["bazaarlink", "minimax-m3"],
|
||||
["ollama-cloud", "minimax-m3"],
|
||||
["opencode", "minimax-m3-free"],
|
||||
["cline", "minimax/minimax-m3"],
|
||||
["opencode-zen", "minimax-m3"],
|
||||
["codebuddy-cn", "minimax-m3"],
|
||||
];
|
||||
|
||||
for (const [provider, model] of affectedRoutes) {
|
||||
it(`isTextualReasoningTagNativeRoute("${provider}", "${model}") === true`, () => {
|
||||
assert.equal(isTextualReasoningTagNativeRoute(provider, model), true);
|
||||
});
|
||||
}
|
||||
|
||||
it("shouldParseTextualReasoningTags is true for a mixed-case MiniMax M3 model id (huggingchat)", () => {
|
||||
assert.equal(shouldParseTextualReasoningTags("huggingchat", "MiniMaxAI/MiniMax-M3"), true);
|
||||
});
|
||||
|
||||
it("extracts <think>...</think> from delta.content into reasoning_content on an affected route", () => {
|
||||
const chunk = {
|
||||
choices: [{ index: 0, delta: { content: "<think>reasoning here</think>final answer" } }],
|
||||
};
|
||||
const sanitized = sanitizeOpenAIResponse(chunk, {
|
||||
parseTextualReasoningTags: shouldParseTextualReasoningTags("trae", "minimax-m3"),
|
||||
}) as { choices: Array<{ delta: { content: string; reasoning_content?: string } }> };
|
||||
|
||||
const delta = sanitized.choices[0].delta;
|
||||
assert.equal(delta.content, "final answer");
|
||||
assert.equal(delta.reasoning_content, "reasoning here");
|
||||
});
|
||||
|
||||
it("leaves <think> tags untouched in content when the route is not tag-native (pre-fix behavior)", () => {
|
||||
const chunk = {
|
||||
choices: [{ index: 0, delta: { content: "<think>reasoning here</think>final answer" } }],
|
||||
};
|
||||
const sanitized = sanitizeOpenAIResponse(chunk, {
|
||||
parseTextualReasoningTags: shouldParseTextualReasoningTags("openai", "gpt-4"),
|
||||
}) as { choices: Array<{ delta: { content: string; reasoning_content?: string } }> };
|
||||
|
||||
const delta = sanitized.choices[0].delta;
|
||||
assert.equal(delta.content, "<think>reasoning here</think>final answer");
|
||||
assert.equal(delta.reasoning_content, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("responseSanitizer/reasoning — MiniMax M3 fix regression guards", () => {
|
||||
it("direct minimax tier (claude format) stays unaffected", () => {
|
||||
assert.equal(isTextualReasoningTagNativeRoute("minimax", "minimax-m3"), false);
|
||||
assert.equal(shouldParseTextualReasoningTags("minimax", "MiniMax-M3"), false);
|
||||
});
|
||||
|
||||
it("direct minimax-cn tier (claude format) stays unaffected", () => {
|
||||
assert.equal(isTextualReasoningTagNativeRoute("minimax-cn", "minimax-m3"), false);
|
||||
assert.equal(shouldParseTextualReasoningTags("minimax-cn", "MiniMax-M3"), false);
|
||||
});
|
||||
|
||||
it("MiniMax M2.x (non-M3) models on OpenAI-format tiers stay unaffected", () => {
|
||||
assert.equal(isTextualReasoningTagNativeRoute("trae", "minimax-m2.7"), false);
|
||||
});
|
||||
|
||||
it("existing deepseek-r1 / qwq textual-reasoning routes are unaffected", () => {
|
||||
assert.equal(shouldParseTextualReasoningTags("together", "deepseek-ai/DeepSeek-R1"), true);
|
||||
assert.equal(shouldParseTextualReasoningTags("cloudflare-ai", "@cf/qwen/qwq-32b"), true);
|
||||
assert.equal(shouldParseTextualReasoningTags("openrouter", "deepseek/deepseek-v4-pro"), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── host public API surface ──────────────────────────────────────────────────
|
||||
|
||||
const host = await import("../../open-sse/handlers/responseSanitizer.ts");
|
||||
|
||||
27
tests/unit/services/bifrost-route-guard.test.ts
Normal file
27
tests/unit/services/bifrost-route-guard.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isLocalOnlyPath } from "../../../src/server/authz/routeGuard.ts";
|
||||
|
||||
test("isLocalOnlyPath: /api/services/bifrost/start is local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/bifrost/start"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyPath: /api/services/bifrost/install is local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/bifrost/install"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyPath: /api/services/bifrost/status is local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/bifrost/status"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyPath: /api/services/bifrost/stop is local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/bifrost/stop"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyPath: /api/services/bifrost/auto-start is local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/bifrost/auto-start"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyPath: /api/services/bifrost/logs is local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/bifrost/logs"), true);
|
||||
});
|
||||
98
tests/unit/services/bifrost-routing-backend.test.ts
Normal file
98
tests/unit/services/bifrost-routing-backend.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Unit tests for §4b routing-layer wiring:
|
||||
* getBifrostRoutingConfig uses supervised instance port when BIFROST_BASE_URL is unset.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { registerSupervisor, unregisterSupervisor } from "../../../src/lib/services/registry.ts";
|
||||
import { ServiceSupervisor } from "../../../src/lib/services/ServiceSupervisor.ts";
|
||||
import { getBifrostRoutingConfig } from "../../../src/app/api/v1/relay/chat/completions/routingBackend.ts";
|
||||
|
||||
test("getBifrostRoutingConfig: returns null when BIFROST_BASE_URL unset and no supervised service", () => {
|
||||
const result = getBifrostRoutingConfig({} as NodeJS.ProcessEnv);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("getBifrostRoutingConfig: uses BIFROST_BASE_URL when set (explicit env wins)", () => {
|
||||
const result = getBifrostRoutingConfig({
|
||||
BIFROST_BASE_URL: "http://localhost:9999",
|
||||
} as NodeJS.ProcessEnv);
|
||||
assert.ok(result !== null);
|
||||
assert.equal(result?.baseUrl, "http://localhost:9999");
|
||||
});
|
||||
|
||||
test("getBifrostRoutingConfig: uses supervised port when BIFROST_BASE_URL unset and bifrost running", () => {
|
||||
// Register a stub supervisor whose getStatus() reports running on port 8080
|
||||
const stub = {
|
||||
getStatus: () => ({
|
||||
tool: "bifrost",
|
||||
state: "running" as const,
|
||||
port: 8080,
|
||||
health: "healthy" as const,
|
||||
pid: 1234,
|
||||
startedAt: new Date().toISOString(),
|
||||
lastError: null,
|
||||
}),
|
||||
} as unknown as ServiceSupervisor;
|
||||
|
||||
registerSupervisor(stub);
|
||||
|
||||
try {
|
||||
const result = getBifrostRoutingConfig({} as NodeJS.ProcessEnv);
|
||||
assert.ok(result !== null, "should return config when supervised instance is running");
|
||||
assert.equal(result?.baseUrl, "http://127.0.0.1:8080");
|
||||
assert.equal(result?.enabled, true);
|
||||
} finally {
|
||||
unregisterSupervisor("bifrost");
|
||||
}
|
||||
});
|
||||
|
||||
test("getBifrostRoutingConfig: explicit BIFROST_BASE_URL overrides supervised port", () => {
|
||||
const stub = {
|
||||
getStatus: () => ({
|
||||
tool: "bifrost",
|
||||
state: "running" as const,
|
||||
port: 8080,
|
||||
health: "healthy" as const,
|
||||
pid: 1234,
|
||||
startedAt: new Date().toISOString(),
|
||||
lastError: null,
|
||||
}),
|
||||
} as unknown as ServiceSupervisor;
|
||||
|
||||
registerSupervisor(stub);
|
||||
|
||||
try {
|
||||
const result = getBifrostRoutingConfig({
|
||||
BIFROST_BASE_URL: "http://remote-host:9999",
|
||||
} as NodeJS.ProcessEnv);
|
||||
assert.ok(result !== null);
|
||||
// Explicit env wins
|
||||
assert.equal(result?.baseUrl, "http://remote-host:9999");
|
||||
} finally {
|
||||
unregisterSupervisor("bifrost");
|
||||
}
|
||||
});
|
||||
|
||||
test("getBifrostRoutingConfig: stopped supervised instance does NOT provide baseUrl", () => {
|
||||
const stub = {
|
||||
getStatus: () => ({
|
||||
tool: "bifrost",
|
||||
state: "stopped" as const,
|
||||
port: 8080,
|
||||
health: "unknown" as const,
|
||||
pid: null,
|
||||
startedAt: null,
|
||||
lastError: null,
|
||||
}),
|
||||
} as unknown as ServiceSupervisor;
|
||||
|
||||
registerSupervisor(stub);
|
||||
|
||||
try {
|
||||
const result = getBifrostRoutingConfig({} as NodeJS.ProcessEnv);
|
||||
assert.equal(result, null, "stopped supervisor should not yield a baseUrl");
|
||||
} finally {
|
||||
unregisterSupervisor("bifrost");
|
||||
}
|
||||
});
|
||||
152
tests/unit/services/installers/bifrost.test.ts
Normal file
152
tests/unit/services/installers/bifrost.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
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";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-bifrost-installer-"));
|
||||
const FAKE_BIN_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-bifrost-fake-bin-"));
|
||||
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
|
||||
const originalPath = process.env.PATH ?? "";
|
||||
process.env.PATH = `${FAKE_BIN_DIR}:${originalPath}`;
|
||||
|
||||
const INSTALL_DIR = path.join(TEST_DATA_DIR, "services", "bifrost");
|
||||
const fakeNpmScript = `#!/bin/sh
|
||||
set -e
|
||||
CMD="$1"
|
||||
shift
|
||||
if [ "$CMD" = "install" ]; then
|
||||
PREFIX=""
|
||||
while [ $# -gt 0 ]; do
|
||||
if [ "$1" = "--prefix" ]; then PREFIX="$2"; shift 2; else shift; fi
|
||||
done
|
||||
if [ -z "$PREFIX" ]; then PREFIX="$npm_config_prefix"; fi
|
||||
PKG_DIR="$PREFIX/node_modules/@maximhq/bifrost"
|
||||
mkdir -p "$PKG_DIR"
|
||||
echo '{"name":"@maximhq/bifrost","version":"1.6.3"}' > "$PKG_DIR/package.json"
|
||||
touch "$PKG_DIR/bin.js"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$CMD" = "view" ]; then
|
||||
echo "1.6.3"
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
`;
|
||||
const fakeNpmPath = path.join(FAKE_BIN_DIR, "npm");
|
||||
fs.writeFileSync(fakeNpmPath, fakeNpmScript, { mode: 0o755 });
|
||||
|
||||
execSync("which npm", { env: process.env });
|
||||
|
||||
// DB bootstrap (must be before bifrost import due to db/core eager init)
|
||||
const core = await import("../../../../src/lib/db/core.ts");
|
||||
const db = core.getDbInstance();
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO version_manager (tool, status, port, auto_start, auto_update, provider_expose)
|
||||
VALUES ('bifrost', 'not_installed', 8080, 0, 1, 1)`
|
||||
).run();
|
||||
|
||||
const {
|
||||
install,
|
||||
update,
|
||||
getInstalledVersion,
|
||||
getLatestVersion,
|
||||
resolveSpawnArgs,
|
||||
BIFROST_DEFAULT_PORT,
|
||||
BIFROST_INSTALL_DIR,
|
||||
} = await import("../../../../src/lib/services/installers/bifrost.ts");
|
||||
|
||||
test.after(() => {
|
||||
process.env.PATH = originalPath;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(FAKE_BIN_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("BIFROST_DEFAULT_PORT is 8080", () => {
|
||||
assert.equal(BIFROST_DEFAULT_PORT, 8080);
|
||||
});
|
||||
|
||||
test("install creates host package.json structure", async () => {
|
||||
const result = await install("1.6.3");
|
||||
|
||||
const hostPkg = path.join(BIFROST_INSTALL_DIR, "package.json");
|
||||
assert.ok(fs.existsSync(hostPkg), "host package.json should exist");
|
||||
const parsedHost = JSON.parse(fs.readFileSync(hostPkg, "utf8")) as {
|
||||
name: string;
|
||||
private: boolean;
|
||||
};
|
||||
assert.equal(parsedHost.name, "omniroute-bifrost-host");
|
||||
assert.ok(parsedHost.private);
|
||||
|
||||
assert.equal(result.installedVersion, "1.6.3");
|
||||
assert.equal(result.installPath, BIFROST_INSTALL_DIR);
|
||||
assert.ok(result.durationMs >= 0);
|
||||
});
|
||||
|
||||
test("getInstalledVersion reads from node_modules/@maximhq/bifrost/package.json", async () => {
|
||||
const ver = await getInstalledVersion();
|
||||
assert.equal(ver, "1.6.3", "should read version from installed package");
|
||||
});
|
||||
|
||||
test("update calls npm install with latest (idempotent)", async () => {
|
||||
const result = await update();
|
||||
assert.equal(result.installedVersion, "1.6.3");
|
||||
});
|
||||
|
||||
test("getLatestVersion returns version string from npm view", async () => {
|
||||
const ver = await getLatestVersion();
|
||||
assert.equal(ver, "1.6.3");
|
||||
});
|
||||
|
||||
test("resolveSpawnArgs shape: command is node, bin.js path, Go single-dash flags", () => {
|
||||
const args = resolveSpawnArgs(8080);
|
||||
|
||||
assert.equal(args.command, process.execPath, "command must be current node binary");
|
||||
assert.ok(args.args[0]?.includes("bin.js"), "args[0] should point to bin.js");
|
||||
|
||||
// Go-style single-dash flags
|
||||
const portIdx = args.args.indexOf("-port");
|
||||
assert.ok(portIdx !== -1, "must have -port flag");
|
||||
assert.equal(args.args[portIdx + 1], "8080");
|
||||
|
||||
const hostIdx = args.args.indexOf("-host");
|
||||
assert.ok(hostIdx !== -1, "must have -host flag");
|
||||
assert.equal(args.args[hostIdx + 1], "127.0.0.1");
|
||||
|
||||
const appDirIdx = args.args.indexOf("-app-dir");
|
||||
assert.ok(appDirIdx !== -1, "must have -app-dir flag");
|
||||
assert.ok(args.args[appDirIdx + 1]?.includes("bifrost"), "-app-dir must point into bifrost dir");
|
||||
|
||||
const logLevelIdx = args.args.indexOf("-log-level");
|
||||
assert.ok(logLevelIdx !== -1, "must have -log-level flag");
|
||||
assert.equal(args.args[logLevelIdx + 1], "warn");
|
||||
|
||||
// BIFROST_TRANSPORT_VERSION must be set in env
|
||||
assert.ok(
|
||||
typeof args.env.BIFROST_TRANSPORT_VERSION === "string" &&
|
||||
args.env.BIFROST_TRANSPORT_VERSION.length > 0,
|
||||
"BIFROST_TRANSPORT_VERSION must be set in env"
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveSpawnArgs with different port passes correct -port value", () => {
|
||||
const args = resolveSpawnArgs(9090);
|
||||
const portIdx = args.args.indexOf("-port");
|
||||
assert.ok(portIdx !== -1);
|
||||
assert.equal(args.args[portIdx + 1], "9090");
|
||||
});
|
||||
|
||||
test("INSTALL_DIR constant points into DATA_DIR/services/bifrost", () => {
|
||||
assert.ok(BIFROST_INSTALL_DIR.includes("bifrost"), "install dir must include 'bifrost'");
|
||||
assert.ok(
|
||||
BIFROST_INSTALL_DIR.startsWith(TEST_DATA_DIR),
|
||||
"install dir must be under TEST_DATA_DIR"
|
||||
);
|
||||
assert.equal(INSTALL_DIR, BIFROST_INSTALL_DIR);
|
||||
});
|
||||
105
tests/unit/services/installers/mux.test.ts
Normal file
105
tests/unit/services/installers/mux.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Mux installer unit tests.
|
||||
*
|
||||
* All tests are pure-logic: no real file I/O, no network, no DB.
|
||||
* resolveSpawnArgs() performs fs.mkdirSync as a side effect (creating
|
||||
* MUX_ROOT under DATA_DIR), so — mirroring cliproxy.test.ts — we replicate
|
||||
* its pure argument-building contract here instead of invoking the real
|
||||
* function, keeping this suite side-effect-free.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
|
||||
// ── exported constants ────────────────────────────────────────────────────────
|
||||
|
||||
describe("mux installer — exports", () => {
|
||||
it("MUX_DEFAULT_PORT is 8322", async () => {
|
||||
const { MUX_DEFAULT_PORT } = await import("../../../../src/lib/services/installers/mux.ts");
|
||||
assert.equal(MUX_DEFAULT_PORT, 8322);
|
||||
});
|
||||
|
||||
it("MUX_PACKAGE is the npm package name 'mux'", async () => {
|
||||
const { MUX_PACKAGE } = await import("../../../../src/lib/services/installers/mux.ts");
|
||||
assert.equal(MUX_PACKAGE, "mux");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getInstalledVersion ───────────────────────────────────────────────────────
|
||||
|
||||
describe("getInstalledVersion", () => {
|
||||
it("reads version from node_modules/mux/package.json", () => {
|
||||
// Replicates the logic in getInstalledVersion(): reads a JSON file at a
|
||||
// DATA_DIR-scoped, non-user-controlled path and pulls out `.version`.
|
||||
const fakePkg = JSON.stringify({ name: "mux", version: "0.27.0" });
|
||||
const parsed = JSON.parse(fakePkg) as { version?: string };
|
||||
assert.equal(parsed.version, "0.27.0");
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveSpawnArgs (pure argument-building contract) ─────────────────────────
|
||||
|
||||
describe("resolveSpawnArgs — argument-building contract", () => {
|
||||
const MUX_INSTALL_DIR = path.join("/fake", "services", "mux");
|
||||
|
||||
function buildArgs(apiKey: string, port: number) {
|
||||
const serverPath = path.join(MUX_INSTALL_DIR, "node_modules", "mux", "dist", "cli", "index.js");
|
||||
return {
|
||||
command: "node",
|
||||
args: [serverPath, "server", "--host", "127.0.0.1", "--port", String(port)],
|
||||
env: { MUX_SERVER_AUTH_TOKEN: apiKey },
|
||||
cwd: MUX_INSTALL_DIR,
|
||||
};
|
||||
}
|
||||
|
||||
it("binds host to 127.0.0.1 explicitly — never 0.0.0.0", () => {
|
||||
const spawnArgs = buildArgs("mx_fake_token", 8322);
|
||||
const hostIdx = spawnArgs.args.indexOf("--host");
|
||||
assert.ok(hostIdx !== -1);
|
||||
assert.equal(spawnArgs.args[hostIdx + 1], "127.0.0.1");
|
||||
});
|
||||
|
||||
it("passes the port via --port flag as a string", () => {
|
||||
const spawnArgs = buildArgs("mx_fake_token", 9001);
|
||||
const portIdx = spawnArgs.args.indexOf("--port");
|
||||
assert.ok(portIdx !== -1);
|
||||
assert.equal(spawnArgs.args[portIdx + 1], "9001");
|
||||
});
|
||||
|
||||
it("invokes the 'server' subcommand", () => {
|
||||
const spawnArgs = buildArgs("mx_fake_token", 8322);
|
||||
assert.ok(spawnArgs.args.includes("server"));
|
||||
});
|
||||
|
||||
it("passes the auth token via MUX_SERVER_AUTH_TOKEN env var, never as an argv entry", () => {
|
||||
const token = "mx_super_secret_token_value";
|
||||
const spawnArgs = buildArgs(token, 8322);
|
||||
|
||||
assert.equal(spawnArgs.env.MUX_SERVER_AUTH_TOKEN, token);
|
||||
assert.ok(
|
||||
!spawnArgs.args.some((a) => a.includes(token)),
|
||||
"token must never appear in argv (would leak via `ps`)"
|
||||
);
|
||||
});
|
||||
|
||||
it("targets the installed server entry point under node_modules/mux/dist/cli", () => {
|
||||
const spawnArgs = buildArgs("mx_fake_token", 8322);
|
||||
assert.ok(spawnArgs.args[0].endsWith(path.join("dist", "cli", "index.js")));
|
||||
assert.ok(spawnArgs.args[0].includes(path.join("node_modules", "mux")));
|
||||
});
|
||||
});
|
||||
|
||||
// ── path safety ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("path safety", () => {
|
||||
it("resolveSpawnArgs takes only (apiKey: string, port: number) — no arbitrary path input", () => {
|
||||
// resolveSpawnArgs never accepts a user-controlled path; every filesystem
|
||||
// path it builds is derived from DATA_DIR + static path segments.
|
||||
const port = 8322;
|
||||
assert.equal(typeof port, "number", "port must always be a number, not a string");
|
||||
const portStr = String(port);
|
||||
assert.ok(!portStr.includes("/"), "port string cannot contain path separator");
|
||||
assert.ok(!portStr.includes(".."), "port string cannot contain traversal");
|
||||
});
|
||||
});
|
||||
@@ -81,7 +81,9 @@ test("OpenAI -> Kiro preserves prior history, tool uses and accumulated tool res
|
||||
assert.equal(result.conversationState.history.length, 2);
|
||||
assert.deepEqual(result.conversationState.history[0], {
|
||||
userInputMessage: {
|
||||
content: "Rules\n\nHello",
|
||||
// #2306: the system prompt ("Rules") is wrapped in <system-reminder> before
|
||||
// being merged into the Kiro user turn, instead of leaking as raw user text.
|
||||
content: "<system-reminder>\nRules\n</system-reminder>\n\nHello",
|
||||
modelId: "claude-sonnet-4",
|
||||
origin: "AI_EDITOR",
|
||||
},
|
||||
@@ -233,11 +235,11 @@ test("OpenAI -> Kiro derives a stable conversationId for the same first history
|
||||
|
||||
assert.equal(
|
||||
(first.conversationState as any).history[0].userInputMessage.content,
|
||||
"Rules\n\nHello"
|
||||
"<system-reminder>\nRules\n</system-reminder>\n\nHello"
|
||||
);
|
||||
assert.equal(
|
||||
(second as any).conversationState.history[0].userInputMessage.content,
|
||||
"Rules\n\nHello"
|
||||
"<system-reminder>\nRules\n</system-reminder>\n\nHello"
|
||||
);
|
||||
assert.equal(first.conversationState.conversationId, second.conversationState.conversationId);
|
||||
});
|
||||
@@ -291,7 +293,10 @@ test("OpenAI -> Kiro merges adjacent user history turns after role normalization
|
||||
|
||||
const firstUser = history[0].userInputMessage;
|
||||
assert.ok(firstUser, "first history turn should be a user turn");
|
||||
assert.equal(firstUser.content, "System rules\n\nFirst question");
|
||||
assert.equal(
|
||||
firstUser.content,
|
||||
"<system-reminder>\nSystem rules\n</system-reminder>\n\nFirst question"
|
||||
);
|
||||
assert.equal(history[1].assistantResponseMessage?.content, "Answer 1");
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@ const messages = JSON.parse(
|
||||
|
||||
test("#5695 Quick Start step 1 links to the API Manager (API Keys), not Endpoint", () => {
|
||||
// The endpoint render-prop Link inside the step1Desc rich block.
|
||||
const hrefMatch = source.match(/t\.rich\("step1Desc"[\s\S]*?<Link href="([^"]*)"/);
|
||||
// NB: tolerate Prettier splitting `<Link href=...>` across lines (\s+ between
|
||||
// the tag and the attr) — otherwise the regex skips the multi-line step1 Link
|
||||
// and wrongly matches the single-line step2 `/dashboard/providers` Link.
|
||||
const hrefMatch = source.match(/t\.rich\("step1Desc"[\s\S]*?<Link\s+href="([^"]*)"/);
|
||||
assert.ok(hrefMatch, "expected to find the step1Desc endpoint Link in HomePageClient.tsx");
|
||||
assert.equal(
|
||||
hrefMatch![1],
|
||||
|
||||
@@ -675,7 +675,7 @@ test("Kimi Web: targets www.kimi.com (international)", async () => {
|
||||
const executor = new KimiWebExecutor();
|
||||
const result = await executor.execute({
|
||||
...noopExecuteInput,
|
||||
model: "kimi-default",
|
||||
model: "k2d6",
|
||||
credentials: { apiKey: "kimi-auth=eyJ.eyJzdWI.signature" },
|
||||
});
|
||||
assert.ok(result.response instanceof Response);
|
||||
@@ -695,7 +695,7 @@ test("Kimi Web: missing JWT returns a 400 before fetching", async () => {
|
||||
const executor = new KimiWebExecutor();
|
||||
const result = await executor.execute({
|
||||
...noopExecuteInput,
|
||||
model: "kimi-default",
|
||||
model: "k2d6",
|
||||
credentials: { apiKey: "" },
|
||||
});
|
||||
assert.equal(result.response.status, 400);
|
||||
@@ -707,7 +707,7 @@ test("Kimi Web: error response returns error result", async () => {
|
||||
const executor = new KimiWebExecutor();
|
||||
const result = await executor.execute({
|
||||
...noopExecuteInput,
|
||||
model: "kimi-default",
|
||||
model: "k2d6",
|
||||
credentials: { apiKey: "kimi-auth=eyJ.eyJzdWI.signature" },
|
||||
});
|
||||
assert.ok(result.response instanceof Response);
|
||||
|
||||
136
tests/unit/xai-usage.test.ts
Normal file
136
tests/unit/xai-usage.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* tests/unit/xai-usage.test.ts
|
||||
*
|
||||
* xAI (Grok) has no public per-account quota API (the billing console at
|
||||
* console.x.ai requires a session cookie, not an API key), so — exactly like
|
||||
* the Xiaomi MiMo self-track pattern — OmniRoute self-tracks it: it sums the
|
||||
* tokens it routed to the connection from `usage_history` and surfaces them
|
||||
* as a cumulative, uncapped ("unlimited") usage figure on the quota
|
||||
* dashboard. These tests cover the aggregation helper + the fetcher shape,
|
||||
* with a real temp DB, and assert provider + connection scoping (no bleed).
|
||||
*/
|
||||
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
// DATA_DIR must be set before any module that opens the DB is imported.
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "omni-xai-usage-"));
|
||||
process.env.DATA_DIR = TMP;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { getMonthlyProviderTokensForConnection } = await import(
|
||||
"../../src/lib/usage/usageStats.ts"
|
||||
);
|
||||
const { __testing, USAGE_FETCHER_PROVIDERS, getUsageForProvider } = await import(
|
||||
"../../open-sse/services/usage.ts"
|
||||
);
|
||||
const { getXaiUsage } = __testing;
|
||||
|
||||
function insertUsage(
|
||||
connectionId: string,
|
||||
provider: string,
|
||||
tokensIn: number,
|
||||
tokensOut: number,
|
||||
timestamp: string
|
||||
) {
|
||||
const db = core.getDbInstance();
|
||||
db.prepare(
|
||||
`INSERT INTO usage_history (provider, connection_id, tokens_input, tokens_output, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
).run(provider, connectionId, tokensIn, tokensOut, timestamp);
|
||||
}
|
||||
|
||||
describe("xAI self-tracked usage", () => {
|
||||
before(() => {
|
||||
core.getDbInstance(); // trigger migrations
|
||||
const now = new Date();
|
||||
const inWindow = now.toISOString();
|
||||
const outOfWindow = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 15)
|
||||
).toISOString();
|
||||
// in-window usage for conn-x: 2.0M + 0.3M
|
||||
insertUsage("conn-x", "xai", 2_000_000, 0, inWindow);
|
||||
insertUsage("conn-x", "xai", 0, 300_000, inWindow);
|
||||
// out-of-window usage must NOT count toward the current aggregate
|
||||
insertUsage("conn-x", "xai", 9_000_000, 9_000_000, outOfWindow);
|
||||
// a different connection must not bleed in
|
||||
insertUsage("conn-y", "xai", 5_000_000, 0, inWindow);
|
||||
// a different provider on the same connection must not bleed in
|
||||
insertUsage("conn-x", "minimax", 8_000_000, 0, inWindow);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
core.resetDbInstance();
|
||||
try {
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort temp cleanup
|
||||
}
|
||||
});
|
||||
|
||||
it("registers 'xai' as a usage-fetcher provider", () => {
|
||||
assert.ok(
|
||||
(USAGE_FETCHER_PROVIDERS as readonly string[]).includes("xai"),
|
||||
"xai must be listed in USAGE_FETCHER_PROVIDERS"
|
||||
);
|
||||
});
|
||||
|
||||
it("aggregates only in-window tokens for the given provider+connection", () => {
|
||||
// 2.0M + 0.3M = 2.3M; excludes out-of-window, conn-y, and minimax rows.
|
||||
assert.equal(getMonthlyProviderTokensForConnection("xai", "conn-x"), 2_300_000);
|
||||
});
|
||||
|
||||
it("returns 0 for an unknown connection (fail-open, no bleed)", () => {
|
||||
assert.equal(getMonthlyProviderTokensForConnection("xai", "conn-none"), 0);
|
||||
});
|
||||
|
||||
it("getXaiUsage returns a cumulative unlimited quota scoped to the connection", async () => {
|
||||
const r = (await getXaiUsage("conn-x")) as {
|
||||
plan?: string;
|
||||
quotas?: Record<
|
||||
string,
|
||||
{
|
||||
used: number;
|
||||
total: number;
|
||||
remaining?: number;
|
||||
remainingPercentage?: number;
|
||||
unlimited: boolean;
|
||||
resetAt: string | null;
|
||||
}
|
||||
>;
|
||||
message?: string;
|
||||
};
|
||||
assert.ok(r.quotas, `expected quotas, got message: ${r.message}`);
|
||||
const m = r.quotas!.monthly;
|
||||
assert.ok(m, "cumulative window present");
|
||||
assert.equal(m.used, 2_300_000);
|
||||
assert.equal(m.unlimited, true, "xAI has no fixed monthly cap");
|
||||
assert.equal(m.remaining, 100, "unlimited rows report remaining: 100 (matches upstream UX)");
|
||||
});
|
||||
|
||||
it("getXaiUsage does not bleed a different connection's usage", async () => {
|
||||
const r = (await getXaiUsage("conn-y")) as {
|
||||
quotas?: { monthly?: { used: number } };
|
||||
};
|
||||
assert.equal(r.quotas?.monthly?.used, 5_000_000);
|
||||
});
|
||||
|
||||
it("getXaiUsage returns a message when connection id is missing", async () => {
|
||||
const r = (await getXaiUsage("")) as { message?: string; quotas?: unknown };
|
||||
assert.ok(r.message && !r.quotas, "no quota without a connection id");
|
||||
});
|
||||
|
||||
it("getUsageForProvider('xai', ...) delegates to getXaiUsage", async () => {
|
||||
const r = (await getUsageForProvider({
|
||||
id: "conn-x",
|
||||
provider: "xai",
|
||||
} as Parameters<typeof getUsageForProvider>[0])) as {
|
||||
quotas?: { monthly?: { used: number; unlimited: boolean } };
|
||||
};
|
||||
assert.equal(r.quotas?.monthly?.used, 2_300_000);
|
||||
assert.equal(r.quotas?.monthly?.unlimited, true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user