diff --git a/.env.example b/.env.example index 29cacfe8cb..dc502f6840 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index bcdacfa0de..7698202824 100644 --- a/CHANGELOG.md +++ b/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 `` 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 `` 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) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 69e8fbf2bc..5c9e0e3db2 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -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 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, 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, 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 7913017, 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, diff --git a/docs/frameworks/EMBEDDED-SERVICES.md b/docs/frameworks/EMBEDDED-SERVICES.md index dd578c992d..66b2f59acd 100644 --- a/docs/frameworks/EMBEDDED-SERVICES.md +++ b/docs/frameworks/EMBEDDED-SERVICES.md @@ -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 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 733b737ab1..aab8d0169d 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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 `. 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: diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index bc356242cc..4f41126d89 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -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 diff --git a/open-sse/config/providers/registry/kimi/web/index.ts b/open-sse/config/providers/registry/kimi/web/index.ts index c86d3ce426..1bcd615615 100644 --- a/open-sse/config/providers/registry/kimi/web/index.ts +++ b/open-sse/config/providers/registry/kimi/web/index.ts @@ -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 }, ], }; diff --git a/open-sse/executors/kimi-web.ts b/open-sse/executors/kimi-web.ts index f45e9d1072..cf4ff4bdbe 100644 --- a/open-sse/executors/kimi-web.ts +++ b/open-sse/executors/kimi-web.ts @@ -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 diff --git a/open-sse/executors/qwen-web.ts b/open-sse/executors/qwen-web.ts index a61072d283..485eb93758 100644 --- a/open-sse/executors/qwen-web.ts +++ b/open-sse/executors/qwen-web.ts @@ -58,7 +58,9 @@ const MODEL_ALIASES: Record = { "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", diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 21500f2c30..45a58c3f57 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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)) { diff --git a/open-sse/handlers/chatCore/clineResponseEnvelope.ts b/open-sse/handlers/chatCore/clineResponseEnvelope.ts new file mode 100644 index 0000000000..0882ef3718 --- /dev/null +++ b/open-sse/handlers/chatCore/clineResponseEnvelope.ts @@ -0,0 +1,25 @@ +type JsonRecord = Record; + +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, + }; +} diff --git a/open-sse/handlers/responseSanitizer/reasoning.ts b/open-sse/handlers/responseSanitizer/reasoning.ts index cf15fc2fc6..867d455c84 100644 --- a/open-sse/handlers/responseSanitizer/reasoning.ts +++ b/open-sse/handlers/responseSanitizer/reasoning.ts @@ -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 ... 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)) ); } diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 7adafe5aba..4ce9712934 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -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 = 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. diff --git a/open-sse/services/combo/__tests__/targetExhaustion.test.ts b/open-sse/services/combo/__tests__/targetExhaustion.test.ts deleted file mode 100644 index f422aafc6a..0000000000 --- a/open-sse/services/combo/__tests__/targetExhaustion.test.ts +++ /dev/null @@ -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 { - 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(), - exhaustedConnections: new Set(), - transientRateLimitedProviders: new Set(), - }; -} - -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); - }); -}); diff --git a/open-sse/services/combo/applyStrategyOrdering.ts b/open-sse/services/combo/applyStrategyOrdering.ts new file mode 100644 index 0000000000..e8cb39ecf8 --- /dev/null +++ b/open-sse/services/combo/applyStrategyOrdering.ts @@ -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; + body: Record; + 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 { + 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; +} diff --git a/open-sse/services/combo/autoConfig.ts b/open-sse/services/combo/autoConfig.ts new file mode 100644 index 0000000000..c79c9ad032 --- /dev/null +++ b/open-sse/services/combo/autoConfig.ts @@ -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 = 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, + }; +} diff --git a/open-sse/services/combo/resolveAutoStrategy.ts b/open-sse/services/combo/resolveAutoStrategy.ts new file mode 100644 index 0000000000..bd7425723a --- /dev/null +++ b/open-sse/services/combo/resolveAutoStrategy.ts @@ -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; + +export interface ResolveAutoStrategyDeps { + orderedTargets: ResolvedComboTarget[]; + body: Record; + combo: ComboLike; + settings: Record | 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 { + 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 }; +} diff --git a/open-sse/services/combo/targetExhaustion.ts b/open-sse/services/combo/targetExhaustion.ts index 01f73d58db..7091b483ef 100644 --- a/open-sse/services/combo/targetExhaustion.ts +++ b/open-sse/services/combo/targetExhaustion.ts @@ -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 + 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; } diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 76d5fdda87..39f8296082 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -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, diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index 515765a2f2..5fd500b2da 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -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 diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index 1ae0bc1240..fc6d39abfd 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -32,6 +32,15 @@ export function hasUnsupportedKiroContextSuffix(model: unknown): boolean { ); } +/** + * Wrap system-prompt content in 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 `\n${text}\n`; +} + /** * 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 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 diff --git a/scripts/check/check-known-symbols.ts b/scripts/check/check-known-symbols.ts index d88320ed85..510d5b1f04 100644 --- a/scripts/check/check-known-symbols.ts +++ b/scripts/check/check-known-symbols.ts @@ -473,7 +473,16 @@ async function main(): Promise { ...(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 — diff --git a/src/app/(dashboard)/dashboard/providers/services/page.tsx b/src/app/(dashboard)/dashboard/providers/services/page.tsx index ab18dad077..8cc5a09443 100644 --- a/src/app/(dashboard)/dashboard/providers/services/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/services/page.tsx @@ -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() {

Embedded Services

- 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.

@@ -55,6 +60,8 @@ export default function ServicesPage() {
{active === "cliproxy" && } {active === "9router" && } + {active === "mux" && } + {active === "bifrost" && }
); diff --git a/src/app/(dashboard)/dashboard/providers/services/tabs/BifrostServiceTab.tsx b/src/app/(dashboard)/dashboard/providers/services/tabs/BifrostServiceTab.tsx new file mode 100644 index 0000000000..fd4f474c79 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/services/tabs/BifrostServiceTab.tsx @@ -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 ( +
+ + + + +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/services/tabs/MuxServiceTab.tsx b/src/app/(dashboard)/dashboard/providers/services/tabs/MuxServiceTab.tsx new file mode 100644 index 0000000000..a51dc07e81 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/services/tabs/MuxServiceTab.tsx @@ -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 ( +
+ + + + +
+ ); +} diff --git a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts index 4cffe5772b..b7bb1940a8 100644 --- a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts +++ b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts @@ -77,6 +77,35 @@ export const PROVIDER_MODELS_CONFIG: Record = .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", diff --git a/src/app/api/services/[name]/logs/route.ts b/src/app/api/services/[name]/logs/route.ts index c3c3a93a9a..697302c63f 100644 --- a/src/app/api/services/[name]/logs/route.ts +++ b/src/app/api/services/[name]/logs/route.ts @@ -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; } diff --git a/src/app/api/services/bifrost/_lib.ts b/src/app/api/services/bifrost/_lib.ts new file mode 100644 index 0000000000..93d3b8ad8d --- /dev/null +++ b/src/app/api/services/bifrost/_lib.ts @@ -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 { + 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; +} diff --git a/src/app/api/services/bifrost/auto-start/route.ts b/src/app/api/services/bifrost/auto-start/route.ts new file mode 100644 index 0000000000..3123f6cb13 --- /dev/null +++ b/src/app/api/services/bifrost/auto-start/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/app/api/services/bifrost/install/route.ts b/src/app/api/services/bifrost/install/route.ts new file mode 100644 index 0000000000..28e39f5149 --- /dev/null +++ b/src/app/api/services/bifrost/install/route.ts @@ -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 { + return handleServiceInstall(request, install); +} diff --git a/src/app/api/services/bifrost/restart/route.ts b/src/app/api/services/bifrost/restart/route.ts new file mode 100644 index 0000000000..acf74b287a --- /dev/null +++ b/src/app/api/services/bifrost/restart/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/app/api/services/bifrost/start/route.ts b/src/app/api/services/bifrost/start/route.ts new file mode 100644 index 0000000000..ad8e0e935a --- /dev/null +++ b/src/app/api/services/bifrost/start/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/app/api/services/bifrost/status/route.ts b/src/app/api/services/bifrost/status/route.ts new file mode 100644 index 0000000000..bc6a1631d7 --- /dev/null +++ b/src/app/api/services/bifrost/status/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/app/api/services/bifrost/stop/route.ts b/src/app/api/services/bifrost/stop/route.ts new file mode 100644 index 0000000000..b54bfde2e5 --- /dev/null +++ b/src/app/api/services/bifrost/stop/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/app/api/services/bifrost/update/route.ts b/src/app/api/services/bifrost/update/route.ts new file mode 100644 index 0000000000..e5abac79af --- /dev/null +++ b/src/app/api/services/bifrost/update/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/app/api/services/mux/_lib.ts b/src/app/api/services/mux/_lib.ts new file mode 100644 index 0000000000..49b1ef672d --- /dev/null +++ b/src/app/api/services/mux/_lib.ts @@ -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 { + 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; +} diff --git a/src/app/api/services/mux/auto-start/route.ts b/src/app/api/services/mux/auto-start/route.ts new file mode 100644 index 0000000000..1feb460178 --- /dev/null +++ b/src/app/api/services/mux/auto-start/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/app/api/services/mux/install/route.ts b/src/app/api/services/mux/install/route.ts new file mode 100644 index 0000000000..b895a956f6 --- /dev/null +++ b/src/app/api/services/mux/install/route.ts @@ -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 { + return handleServiceInstall(request, install); +} diff --git a/src/app/api/services/mux/restart/route.ts b/src/app/api/services/mux/restart/route.ts new file mode 100644 index 0000000000..bcfaec3508 --- /dev/null +++ b/src/app/api/services/mux/restart/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/app/api/services/mux/start/route.ts b/src/app/api/services/mux/start/route.ts new file mode 100644 index 0000000000..92cfebb6e8 --- /dev/null +++ b/src/app/api/services/mux/start/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/app/api/services/mux/status/route.ts b/src/app/api/services/mux/status/route.ts new file mode 100644 index 0000000000..512ca97b43 --- /dev/null +++ b/src/app/api/services/mux/status/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/app/api/services/mux/stop/route.ts b/src/app/api/services/mux/stop/route.ts new file mode 100644 index 0000000000..5c753a3d92 --- /dev/null +++ b/src/app/api/services/mux/stop/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/app/api/services/mux/update/route.ts b/src/app/api/services/mux/update/route.ts new file mode 100644 index 0000000000..9f25629286 --- /dev/null +++ b/src/app/api/services/mux/update/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/app/api/v1/relay/chat/completions/routingBackend.ts b/src/app/api/v1/relay/chat/completions/routingBackend.ts index 37b9e31d33..ed1459c5d0 100644 --- a/src/app/api/v1/relay/chat/completions/routingBackend.ts +++ b/src/app/api/v1/relay/chat/completions/routingBackend.ts @@ -1,3 +1,5 @@ +import { getSupervisor } from "@/lib/services/registry"; + export type RelayRoutingBackend = "ts" | "bifrost" | "auto"; const VALID_BACKENDS = new Set(["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", diff --git a/src/lib/db/migrations/114_mux_service_seed.sql b/src/lib/db/migrations/114_mux_service_seed.sql new file mode 100644 index 0000000000..eabd381f03 --- /dev/null +++ b/src/lib/db/migrations/114_mux_service_seed.sql @@ -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 ` 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); diff --git a/src/lib/db/migrations/115_bifrost_service.sql b/src/lib/db/migrations/115_bifrost_service.sql new file mode 100644 index 0000000000..b4a3f1bb02 --- /dev/null +++ b/src/lib/db/migrations/115_bifrost_service.sql @@ -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); diff --git a/src/lib/services/apiKey.ts b/src/lib/services/apiKey.ts index 67ff240332..2d58ce1bcb 100644 --- a/src/lib/services/apiKey.ts +++ b/src/lib/services/apiKey.ts @@ -28,7 +28,8 @@ export async function getOrCreateApiKey(tool: string): Promise { // 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; } diff --git a/src/lib/services/bootstrap.ts b/src/lib/services/bootstrap.ts index 6aa0030ae3..5dea02e9d4 100644 --- a/src/lib/services/bootstrap.ts +++ b/src/lib/services/bootstrap.ts @@ -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); } diff --git a/src/lib/services/installers/bifrost.ts b/src/lib/services/installers/bifrost.ts new file mode 100644 index 0000000000..a097f6cdc5 --- /dev/null +++ b/src/lib/services/installers/bifrost.ts @@ -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 { + return getInstalledVersionSync(); +} + +export async function getLatestVersion(): Promise { + 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 { + 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 { + 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, + }; +} diff --git a/src/lib/services/installers/mux.ts b/src/lib/services/installers/mux.ts new file mode 100644 index 0000000000..2ad880cfc4 --- /dev/null +++ b/src/lib/services/installers/mux.ts @@ -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 --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 { + 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 { + 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 { + 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 { + return install("latest"); +} + +export async function uninstall(): Promise { + 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, + }; +} diff --git a/stryker.conf.json b/stryker.conf.json index 2b4a90dec9..b8dac2b67c 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -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", diff --git a/tests/integration/services/full-lifecycle.int.test.ts b/tests/integration/services/full-lifecycle.int.test.ts index bb411d7324..24e8b928cc 100644 --- a/tests/integration/services/full-lifecycle.int.test.ts +++ b/tests/integration/services/full-lifecycle.int.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; + 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; + 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; + 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; + 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; + 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).state, "stopped"); + }); +}); + // --------------------------------------------------------------------------- // Security smoke (requires running server) // --------------------------------------------------------------------------- diff --git a/tests/integration/services/route-guard-services.int.test.ts b/tests/integration/services/route-guard-services.int.test.ts index 0cadfb9ce2..d9e52d899b 100644 --- a/tests/integration/services/route-guard-services.int.test.ts +++ b/tests/integration/services/route-guard-services.int.test.ts @@ -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); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/unit/api-key-provider-quota-bypass-scope.test.ts b/tests/unit/api-key-provider-quota-bypass-scope.test.ts index c19f773394..91a29de6d7 100644 --- a/tests/unit/api-key-provider-quota-bypass-scope.test.ts +++ b/tests/unit/api-key-provider-quota-bypass-scope.test.ts @@ -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/); diff --git a/tests/unit/authz/routeGuard.test.ts b/tests/unit/authz/routeGuard.test.ts index 5a79bfa2ff..2a4fd3799c 100644 --- a/tests/unit/authz/routeGuard.test.ts +++ b/tests/unit/authz/routeGuard.test.ts @@ -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); diff --git a/tests/unit/cline-response-envelope.test.ts b/tests/unit/cline-response-envelope.test.ts new file mode 100644 index 0000000000..f087ad923a --- /dev/null +++ b/tests/unit/cline-response-envelope.test.ts @@ -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); +}); diff --git a/tests/unit/combo-apply-strategy-ordering-split.test.ts b/tests/unit/combo-apply-strategy-ordering-split.test.ts new file mode 100644 index 0000000000..858d7ed3fe --- /dev/null +++ b/tests/unit/combo-apply-strategy-ordering-split.test.ts @@ -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)); +}); diff --git a/tests/unit/combo-auto-config-split.test.ts b/tests/unit/combo-auto-config-split.test.ts new file mode 100644 index 0000000000..a833cae0c4 --- /dev/null +++ b/tests/unit/combo-auto-config-split.test.ts @@ -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); +}); diff --git a/tests/unit/combo-resolve-auto-strategy-split.test.ts b/tests/unit/combo-resolve-auto-strategy-split.test.ts new file mode 100644 index 0000000000..7ffceeae16 --- /dev/null +++ b/tests/unit/combo-resolve-auto-strategy-split.test.ts @@ -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); + } +}); diff --git a/tests/unit/combo-target-defensive-modelstr.test.ts b/tests/unit/combo-target-defensive-modelstr.test.ts index f26b86c8cc..8201075a4c 100644 --- a/tests/unit/combo-target-defensive-modelstr.test.ts +++ b/tests/unit/combo-target-defensive-modelstr.test.ts @@ -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. usages", () => { - const src = fs.readFileSync(COMBO_SRC, "utf8"); +test("#2359 strategy ordering has no remaining unguarded target.modelStr. 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, ""); diff --git a/tests/unit/combo/combo-target-exhaustion.test.ts b/tests/unit/combo/combo-target-exhaustion.test.ts index 5d28703aa0..c6b0369187 100644 --- a/tests/unit/combo/combo-target-exhaustion.test.ts +++ b/tests/unit/combo/combo-target-exhaustion.test.ts @@ -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); +}); diff --git a/tests/unit/dashboard/providers/services/mux-tab.test.ts b/tests/unit/dashboard/providers/services/mux-tab.test.ts new file mode 100644 index 0000000000..51e72d731c --- /dev/null +++ b/tests/unit/dashboard/providers/services/mux-tab.test.ts @@ -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"); + }); +}); diff --git a/tests/unit/executor-kimi-web.test.ts b/tests/unit/executor-kimi-web.test.ts index 05f6e52f34..bff69e30d6 100644 --- a/tests/unit/executor-kimi-web.test.ts +++ b/tests/unit/executor-kimi-web.test.ts @@ -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; diff --git a/tests/unit/gemini-multipleof-2309.test.ts b/tests/unit/gemini-multipleof-2309.test.ts new file mode 100644 index 0000000000..5a4f7f3fc6 --- /dev/null +++ b/tests/unit/gemini-multipleof-2309.test.ts @@ -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")); +}); diff --git a/tests/unit/kiro-system-reminder-2306.test.ts b/tests/unit/kiro-system-reminder-2306.test.ts new file mode 100644 index 0000000000..db05743716 --- /dev/null +++ b/tests/unit/kiro-system-reminder-2306.test.ts @@ -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 `...` + * 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 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 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 ", () => { + 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(""), "no system → no wrapper"); +}); diff --git a/tests/unit/responsesanitizer-reasoning-split.test.ts b/tests/unit/responsesanitizer-reasoning-split.test.ts index 3387924dbc..1517d5db3b 100644 --- a/tests/unit/responsesanitizer-reasoning-split.test.ts +++ b/tests/unit/responsesanitizer-reasoning-split.test.ts @@ -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 ... 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 ... from delta.content into reasoning_content on an affected route", () => { + const chunk = { + choices: [{ index: 0, delta: { content: "reasoning herefinal 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 tags untouched in content when the route is not tag-native (pre-fix behavior)", () => { + const chunk = { + choices: [{ index: 0, delta: { content: "reasoning herefinal 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, "reasoning herefinal 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"); diff --git a/tests/unit/services/bifrost-route-guard.test.ts b/tests/unit/services/bifrost-route-guard.test.ts new file mode 100644 index 0000000000..7870f6cf57 --- /dev/null +++ b/tests/unit/services/bifrost-route-guard.test.ts @@ -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); +}); diff --git a/tests/unit/services/bifrost-routing-backend.test.ts b/tests/unit/services/bifrost-routing-backend.test.ts new file mode 100644 index 0000000000..aa5dbe6491 --- /dev/null +++ b/tests/unit/services/bifrost-routing-backend.test.ts @@ -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"); + } +}); diff --git a/tests/unit/services/installers/bifrost.test.ts b/tests/unit/services/installers/bifrost.test.ts new file mode 100644 index 0000000000..8e20574a68 --- /dev/null +++ b/tests/unit/services/installers/bifrost.test.ts @@ -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); +}); diff --git a/tests/unit/services/installers/mux.test.ts b/tests/unit/services/installers/mux.test.ts new file mode 100644 index 0000000000..97a2568aa9 --- /dev/null +++ b/tests/unit/services/installers/mux.test.ts @@ -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"); + }); +}); diff --git a/tests/unit/translator-openai-to-kiro.test.ts b/tests/unit/translator-openai-to-kiro.test.ts index 9ef079d8a2..9d74a3e20e 100644 --- a/tests/unit/translator-openai-to-kiro.test.ts +++ b/tests/unit/translator-openai-to-kiro.test.ts @@ -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 before + // being merged into the Kiro user turn, instead of leaking as raw user text. + content: "\nRules\n\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" + "\nRules\n\n\nHello" ); assert.equal( (second as any).conversationState.history[0].userInputMessage.content, - "Rules\n\nHello" + "\nRules\n\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, + "\nSystem rules\n\n\nFirst question" + ); assert.equal(history[1].assistantResponseMessage?.content, "Answer 1"); }); diff --git a/tests/unit/ui/quick-start-api-keys-link-5695.test.ts b/tests/unit/ui/quick-start-api-keys-link-5695.test.ts index bd14d442a3..c4a3eaaca0 100644 --- a/tests/unit/ui/quick-start-api-keys-link-5695.test.ts +++ b/tests/unit/ui/quick-start-api-keys-link-5695.test.ts @@ -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]*?` 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]*? { 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); diff --git a/tests/unit/xai-usage.test.ts b/tests/unit/xai-usage.test.ts new file mode 100644 index 0000000000..5e027f3c2d --- /dev/null +++ b/tests/unit/xai-usage.test.ts @@ -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[0])) as { + quotas?: { monthly?: { used: number; unlimited: boolean } }; + }; + assert.equal(r.quotas?.monthly?.used, 2_300_000); + assert.equal(r.quotas?.monthly?.unlimited, true); + }); +});