Compare commits

...

4 Commits

Author SHA1 Message Date
diegosouzapw
f379e9215a test(radar): localize canonical feed fixture 2026-08-08 18:26:50 -03:00
diegosouzapw
4f21e663f0 chore(changelog): assign Radar fix to PR 9776 2026-08-08 09:21:19 -03:00
diegosouzapw
f6708c78fb fix(radar): refresh entitlement-sensitive state 2026-08-08 09:17:32 -03:00
Diego Rodrigues de Sa e Souza
36abd86929 fix(ci): clear the 08-08 base-red layers — dead-code, prod crash in chat.ts, Responses payload regression, born-red stdio test, gate drifts (#9757)
* fix(ci): drop unused RadarReferrals type export — dead-code ratchet back to 227 baseline

The radar referral-links feature (#9697) exported the inferred type
RadarReferrals from feedSchema.ts but nothing imports it (the singular
RadarReferral is the consumed type). knip counts it as a new dead export,
pushing the dead-code ratchet to 228 > 227 and failing Fast Quality Gates
on every PR born after the merge. RadarReferralsSchema itself stays — it
is used by RadarFeedSchema.

Refs #9737

* fix(ci): clear the 08-08 base-red layer — prod crash in chat.ts, Responses API payload regression, born-red stdio test, gate drifts

Six independent base-reds from the 08-07 evening merge batch, each verified
against the pure release/v3.8.50 tip:

- src/sse/handlers/chat.ts: #9467's squash carried a refactor hunk that
  renamed the all-rate-limited breaker guard to an UNDEFINED variable
  (isAllRateLimited) — a production ReferenceError on the all-accounts-429
  path (chat.ts is outside typecheck:core scope, so only tests caught it).
  Restore credentials?.allRateLimited. Guard: chat-rate-limit-body-lock (2/2),
  also un-breaks batch_api and chat-combo-live-test.
- open-sse/utils/stream.ts: #9315 switched providerPayload summaries to the
  accumulated responseBody, but in passthrough paths that body is synthesized
  in chat-completion shape — Responses API lost its `response` object in the
  dashboard payload. Keep the events-derived summary for OPENAI_RESPONSES
  only. Guard: stream-utils + stream-collector-9315 suites (51/51).
- tests/unit/mcp-stdio-json-purity.test.ts: born red — the full CLI chain
  takes ~10s (2x tsx import + DB init) and the test slept a fixed 4s. Poll
  for the first stdout line with a 60s deadline instead.
- tests/unit/plugins-route-error-sanitization.test.ts: register #9445's new
  marketplace/install route in PLUGIN_ROUTES (route already sanitizes) (33/33).
- tests/unit/provider-models-route-codex.test.ts: realign pinned GPT-5.6
  input limit to #9432's deliberate 272000→922000 bump (7/7).
- lint: fix 11 no-explicit-any errors in repro-9630 + specialty-9293 tests,
  prune 1 orphaned suppression, allowlist the opencode-ai devDependency
  (#8869, publisher-verified), and reword a doc line the fabricated-docs
  gate misread as an env var.

Gates re-verified locally: lint:json --max-warnings 0 exit 0, dead-code 227,
typecheck:core clean, check:deps OK, check:fabricated-docs OK.

Refs #9737

* fix(ci): clear the third 08-08 base-red layer — invalid ru rule pack, stale event pin, orphaned UI repro test, pack/mutation/file-size drifts

Follow-up to the previous layer: the serial fast-gates chain unmasked one
more stratum after file-size/dead-code went green, all verified against the
merged release/v3.8.50 tip:

- compression rules ru/ultra.json (#9581): two rules shipped
  minIntensity "notes", which is not a valid CavemanIntensity
  (lite|full|ultra) — loading ANY language pack list threw and killed the
  rtk-loader suite. Mapped both to "ultra" (they are the most aggressive
  punctuation/case rules, matching the en pack tiers). 2/2.
- plugins-welcome-banner-e2e: #9668 added the onStreamComplete builtin
  event (real emission path via runOnStreamCompleteHooks) and missed this
  pinned-list sibling. 35/35.
- tests/unit/free-pool-frontend-repro (#9046): landed as .tsx with
  node:test semantics — no runner collects tests/unit/*.tsx, so it NEVER
  ran (test-discovery NEW-orphan). It contains zero JSX; renamed to .test.ts
  so the unit runner's existing glob collects it. 5/5 (first real run).
- pack-policy: allow + require bin/mcpStdioConsoleGuard.mjs (#9281) — it is
  preloaded via node --import by bin/mcp-server.mjs, so a published artifact
  without it crashes 'omniroute --mcp' at startup.
- stryker.conf.json: add 5 covering unit tests from the batch (#8779/#9204/
  #9330/#9630/openrouter-passthrough) to tap.testFiles (--strict drift).
- file-size-baseline: consolidate the base-drift rebaseline for the 12
  files grown by the 08-06..08-08 batches (#9616's entries never reached the
  base; measured on this branch's tree — this PR's own source edits add zero
  lines to any frozen file).

Local battery: file-size/deps/test-discovery/mutation/pack-policy/dead-code/
duplication/docs-all/secrets/vuln/workflows ratchets all exit 0; full lint
gate --max-warnings 0 exit 0.

Refs #9737

* fix(types): clear the 3 uncovered open-sse-typecheck regressions + realign combo skip-code siblings

Fourth base-red layer unmasked by the serial gates. The other 4 typecheck
regressions (codex.ts, kiro.ts, tierResolver.test.ts, translator/index.ts)
already have dedicated open [TS7] PRs (#9748/#9753/#9742/#9747) — not
duplicated here. This commit covers only what no open PR owns:

- devin-agentic/serializer.ts TS2367: drop the dead 'role === "system"'
  branch — the guard above already narrows role to user|assistant (system
  throws unsupported_role). Devin suites 104/104.
- raycast.ts TS2416: the buildHeaders 'override' never matched the base
  signature (2nd param is the signed payload string, not the stream
  boolean) — renamed to a private buildRaycastRequestHeaders helper so a
  polymorphic buildHeaders(credentials, true) call can never bind here.
- modelMetadataRegistry.ts TS2352: PricingByProvider → nested-record cast
  now goes through unknown (shape is runtime-guarded by findInsensitive).
- combo-routing-engine.test.ts: realign 2 pre-dispatch-skip expectations to
  #9630's deliberate ALL_TARGETS_SKIPPED contract (87/87).

Refs #9737

* fix(ci): clear the fifth 08-08 base-red layer — reasoning-placeholder contract sweep, GPT-5.6 limits sweep, vi key parity

The 08-08 merges (#9610 reasoning replay, #9432 GPT-5.6 limits, #9630 combo
skip codes, #9336 provider key links) each changed a contract and left
sibling tests pinning the old one. Full grep sweep per contract, not just
the shard that happened to go red:

- reasoning placeholder (#9573/#9610): the fix DELIBERATELY removed
  NON_ANTHROPIC_THINKING_PLACEHOLDER injection on cache miss — the model
  echoed the placeholder as its own reasoning (empty stop) and re-poisoned
  cache + client history; DeepSeek's 400 is specific to an EMPTY STRING, not
  an absent field. Realigned reasoning-cache (2 cases, renamed to describe
  omission) + tool-request-sanitization (1 case + dead import). 60/60.
- GPT-5.6 Codex limits (#9432, 272000 -> 1050000 ctx / 922000 input):
  realigned vscode-token-routes-gpt56 (2) + vscode-token-routes (3). 43/43
  together with t23-t24.
- combo skip codes (#9630): t23-t24-fallback-resilience T24 now expects
  ALL_TARGETS_SKIPPED like the combo-routing-engine siblings.
- vi.json key parity: #9336 added providers.getApiKey/getApiKeyDescription
  to en.json without syncing vi (the only locale with a parity gate).
  Translated both; providers block reordered to match en key order. 5/5.
- pack-artifact-policy.test.ts: sibling of this PR's own required-paths
  change (bin/mcpStdioConsoleGuard.mjs). 10/10.
- combo-routing-engine.test.ts: dropped the 6 comment lines added in the
  previous commit so the frozen test file-size stays at its baseline (the
  rationale lives in that commit message, not the test body).

Gates: file-size, test-discovery, mutation-test-coverage, pack-policy,
open-sse-typecheck, dead-code all exit 0.

Refs #9737

* fix(translator): keep the reasoning_content placeholder for Xiaomi MiMo — #9610 traded one live 400 for another

The xiaomi-mimo replay test (9router#1321) went red on the base after #9610
removed the NON_ANTHROPIC_THINKING_PLACEHOLDER injection globally. That test
is NOT stale — it guards a documented upstream 400 ('Param Incorrect: The
reasoning_content in the thinking mode must be passed back to the API'), so
realigning it would have masked a reintroduced production bug.

Two real bugs conflict here:
- #9573: forwarding the placeholder makes the model continue its chain of
  thought FROM that text (echo -> empty stop) and re-poisons cache/history.
- 9router#1321/#1337: omitting reasoning_content on a plain replay turn makes
  Xiaomi MiMo reject the request outright.

#9610's evidence for omitting is provider-specific — it verified that
deepseek-v4-flash accepts an ABSENT field. It does not extend to MiMo. So the
omission stays for every provider #9610 covered, and the placeholder survives
the cache miss only for xiaomi-mimo (new requiresReasoningContentPresence
predicate next to isReasoningOnlyReplayTarget). The echo that comes back is
still stripped on the way in by isInternalReasoningPlaceholder(), so #9573's
cache/history poisoning stays fixed for MiMo too.

Both contracts now hold simultaneously: xiaomi-mimo replay + reasoning-cache +
tool-request-sanitization 61/61; placeholder-strip/responses/translator/combo
regression sweep 168/168. Gates: file-size, open-sse-typecheck, dead-code,
mutation-test-coverage exit 0; typecheck:core clean.

A live check on the VPS (Hard Rule #18 path 2) is the only way to confirm the
DeepSeek half of #9610's empirical claim; flagging it in the PR rather than
widening this fix on speculation.

Refs #9737

* test(translator): pin the reasoning-placeholder provider scope so neither half of the conflict can silently re-break

#9610 removed the placeholder globally on the strength of ONE provider's
observed behavior (deepseek-v4-flash accepting an absent reasoning_content),
which re-opened the MiMo 400 (9router#1321). The previous commit scoped the
placeholder to xiaomi-mimo; this pins BOTH directions in one test so the next
global edit fails loudly instead of trading the bugs again:

- xiaomi-mimo plain replay turn, cache miss -> reasoning_content present
  (narrowing the scope away from MiMo re-opens 9router#1321)
- deepseek plain replay turn, cache miss -> reasoning_content absent
  (widening it back to DeepSeek re-opens the #9573 echo bug)

Guard verified by mutation: forcing requiresReasoningContentPresence() to
return true makes the DeepSeek half fail (1 pass / 1 fail), and the file was
restored from the pre-probe copy before committing.

Also checked kimi-coding/kimi-coding-apikey, the other strict-contract entries
in REASONING_REPLAY_PROVIDERS: their originating PR (#7673) fixes capture and
replay of REAL reasoning and documents no 400 on an absent field, so they stay
out of the placeholder scope — evidence-scoped, not speculatively widened.

Reasoning suites together: 87/87. Gates: file-size, test-discovery,
mutation-test-coverage, dead-code exit 0; eslint clean.

Refs #9737

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-08 09:08:45 -03:00
44 changed files with 743 additions and 309 deletions

View File

@@ -0,0 +1 @@
- **fix(radar):** refresh signed catalog/referral caches when supporter entitlement changes, preserve the one-time live-to-community downgrade, and test real provider connection IDs from the setup tour

View File

@@ -0,0 +1 @@
- Removed the unused `RadarReferrals` type export left by the radar referral-links feature (#9697), returning the dead-code ratchet to its 227 baseline.

View File

@@ -96,6 +96,7 @@
"node-machine-id",
"omniglyph",
"open",
"opencode-ai",
"ora",
"parse5",
"pino",

View File

@@ -1,4 +1,5 @@
{
"_rebaseline_2026_08_08_v3850_base_drift_batch_9757": "Base drift on release/v3.8.50, not own growth: the 08-06..08-08 merge batches grew 12 already-frozen (or newly-landed) files without carrying their rebaselines — the dedicated rebaseline PR #9616 was closed as 'superseded' but its file-size entries never actually reached the base, and later merges (#8894 combos page, #9539 EditConnectionModal, #8895 models route, #9294/#9293 catalog, #9541 db/core, #8970 tokenHealthCheck, #8925 mcp schemas+server, #8890 accountFallback, #9467 chat.ts, #8931 openai-to-kiro, ProxyRegistryManager) kept growing them. All 12 values re-measured on THIS branch's tree (= pure tip + this PR's 1-line chat.ts fix, which adds zero lines). This PR's own source changes (chat.ts identifier restore, stream.ts format carve-out) do not grow any frozen file past these values.",
"_rebaseline_2026_08_08_migration_135_collision": "fix(db): resolve migration version 135 numbering collision — #9449's 135_connection_runtime_state.sql and #8908's 135_migrate_model_capability_max_token.sql both claimed version 135 (#9449 branched before #8908 merged and never got renumbered before landing on release/v3.8.50), which threw 'Migration version collision detected' the moment ANY code touched the database — a fresh install/deploy from this tip cannot even boot. Renumbered the later-landing file to 140 (next free slot) and added the matching isSchemaAlreadyApplied('140') retroactive guard, matching the established pattern already used for the prior 135/136 -> 137/138 renumber in the same file. Own growth: src/lib/db/migrationRunner.ts 1084->1094 (+10, the new case block) — irreducible, matches the existing per-case guard pattern exactly. Covered by tests/unit/migration-135-numbering-collision.test.ts (2/2), confirmed failing (reproducing the exact live crash) against the pre-fix colliding filenames, passing after.",
"_rebaseline_2026_08_02_9259_rolling_rpm": "PR #9259 (issue #8733) own growth: open-sse/services/rateLimitManager.ts baseline 1060->1167 (+107; final source 1153). The existing withRateLimit chokepoint now composes process-local rolling RPM leases with Bottleneck admission, releases pre-dispatch leases on queue timeout/abort/connection disable, preserves caller abort reasons, and wires 429/header state into the extracted rollingRpmGate.ts. The remaining growth is irreducible lifecycle wiring at the dispatch boundary plus the real watchdog test hooks needed to verify queued-wedge recovery; moving it further would obscure lease ownership and Bottleneck cleanup. Covered by the focused rate-limit manager/sliding-window suite (33/33); distributed multi-instance coordination remains explicitly out of scope.",
"_rebaseline_2026_07_24_8470_hyperagent_sticky_thread": "PR #8470 (artickc, fix/hyperagent-tool-loop-thread-sticky) own growth: open-sse/executors/hyperagent.ts 936->1025 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 937->1026, +89, crosses the 1000 cap). Fixes a real bug where a reverse-conversion proxy (text-Intent/JSON to Claude Code native tool_calls) rewrites assistant messages between agentic tool-loop turns, breaking HyperAgents conversation-prefix fingerprint and cold-starting the thread mid tool-loop. Adds Anthropic tool_use/tool_result flattening to extractMessageText() plus a new rootUserFingerprint()/root-key lookup tier in resolveHyperAgentThreadBinding()/storeHyperAgentThreadAfterTurn() so the thread stays sticky across the tool loop. Cohesive additions inside the existing single-file executor; not extractable without splitting the executor mid-request-flow. Covered by tests/unit/executor-hyperagent.test.ts (19/19, +5 new cases for tool_result/tool_use flattening + root-key stickiness). Pre-merge review flagged a cross-conversation root-key collision risk (tracked in the PRs own mandatory pre-merge checklist, not yet addressed) — unrelated to this file-size ratchet, tracked separately by /fix-prs.",
@@ -232,10 +233,10 @@
"open-sse/handlers/responseSanitizer.ts": 1128,
"open-sse/handlers/search.ts": 1536,
"open-sse/handlers/videoGeneration.ts": 1063,
"open-sse/mcp-server/schemas/tools.ts": 1505,
"open-sse/mcp-server/server.ts": 1411,
"open-sse/mcp-server/schemas/tools.ts": 1553,
"open-sse/mcp-server/server.ts": 1448,
"open-sse/mcp-server/tools/advancedTools.ts": 1120,
"open-sse/services/accountFallback.ts": 1972,
"open-sse/services/accountFallback.ts": 1978,
"open-sse/services/adobeFireflyClient.ts": 2385,
"open-sse/services/claudeCodeCompatible.ts": 1202,
"open-sse/services/combo.ts": 3648,
@@ -248,27 +249,27 @@
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1031,
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3117,
"src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": 1067,
"src/app/(dashboard)/dashboard/combos/page.tsx": 4647,
"src/app/(dashboard)/dashboard/combos/page.tsx": 4703,
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1283,
"src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1022,
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2615,
"src/app/(dashboard)/dashboard/health/page.tsx": 1165,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1316,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1324,
"src/app/(dashboard)/dashboard/providers/page.tsx": 1944,
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1201,
"src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1019,
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1464,
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1470,
"src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 1123,
"src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1629,
"src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1573,
"src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1028,
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148,
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1119,
"src/app/api/providers/[id]/models/route.ts": 2250,
"src/app/api/v1/models/catalog.ts": 1549,
"src/lib/tokenHealthCheck.ts": 1021,
"src/app/api/providers/[id]/models/route.ts": 2361,
"src/app/api/v1/models/catalog.ts": 1590,
"src/lib/tokenHealthCheck.ts": 1053,
"src/lib/db/apiKeys.ts": 1529,
"src/lib/db/core.ts": 1637,
"src/lib/db/core.ts": 1639,
"src/lib/db/migrationRunner.ts": 1094,
"src/lib/db/models.ts": 1097,
"src/lib/db/providers.ts": 1034,
@@ -279,13 +280,14 @@
"src/shared/components/RequestLoggerV2.tsx": 1629,
"src/shared/components/analytics/charts.tsx": 1035,
"src/shared/services/cliRuntime.ts": 1122,
"src/sse/handlers/chat.ts": 1877,
"src/sse/handlers/chat.ts": 1904,
"src/sse/services/auth.ts": 2508,
"tests/unit/account-fallback-service.test.ts": 1572,
"tests/unit/provider-validation-specialty.test.ts": 2985,
"open-sse/executors/hyperagent.ts": 1026,
"open-sse/executors/default.ts": 1042,
"open-sse/executors/kiro.ts": 1069
"open-sse/executors/kiro.ts": 1069,
"open-sse/translator/request/openai-to-kiro.ts": 1057
},
"testCap": 1000,
"testFrozen": {

View File

@@ -158,7 +158,7 @@ error lines produced **95.93% token savings / 96.26% character savings** — squ
range. But the same pipeline run against normal, non-redundant tool output (a clean `grep` match list,
a short file read, ordinary conversational text) correctly produces **near-zero savings**, because
there is nothing repetitive to remove and `validateCompression()` (`validation.ts`) refuses to ship a
rewrite that would drop or alter code blocks, URLs, headings, versions, or `CONST_CASE` identifiers.
rewrite that would drop or alter code blocks, URLs, headings, versions, or ALL-CAPS constant identifiers.
This is expected, safe behavior, not a bug: a coding session that mostly reads/greps clean files will
see modest total savings even with compression fully enabled, while a session that hits a failing

View File

@@ -1,13 +1,13 @@
---
title: "Radar Free-Model Catalog"
version: 3.8.50
lastUpdated: 2026-08-07
lastUpdated: 2026-08-08
---
# Radar Free-Model Catalog
> **Source of truth:** `src/lib/radar/`, `src/lib/db/radar.ts`, `src/app/api/radar/`
> **Last updated:** 2026-08-07 — v3.8.50
> **Last updated:** 2026-08-08 — v3.8.50
Radar is an **optional add-on** that overlays a signed, freshly-curated free-model
catalog on top of the release baseline (`FREE_MODEL_BUDGETS` in
@@ -23,6 +23,23 @@ below.
---
## Delivery status in v3.8.50
The following status distinguishes what this OSS release implements from later Radar
workstreams. It is a code-level status, not a promise that a particular hosted deployment
or external integration is currently available.
| Area | Status in this release |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Signed catalog client | Implemented behind `RADAR_ENABLED`, with separate opt-in, Ed25519 verification, local encrypted settings/cache, non-destructive overlay, scheduler, and dashboard. |
| Contributor activation | The dashboard links to the server-hosted GitHub claim flow and accepts an existing `omr_…` key. Contributor eligibility is resolved by the private service; the OSS client contains no GitHub token or issuance logic. |
| Supporter-key activation | Implemented. The raw key is validated, encrypted at rest, masked on reads, and sent only by the server-side sync. Changing or clearing the key invalidates both entitlement-sensitive feed caches. |
| Referral links | Implemented as a separately signed, hourly-refreshed feed. Fixed links are available to the community tier immediately; limited campaigns remain live-tier data. |
| Payments and transactional email | Not implemented in the OSS client. Purchase, donation, receipt review, and mail delivery belong to the private service and its later operational workstream. |
| Research-agent workstream | Not part of this client release. Curated feed contents remain server-side data; no autonomous research agent runs in an OmniRoute installation. |
---
## Flag: `RADAR_ENABLED` (default off)
Radar is gated end-to-end by the `RADAR_ENABLED` feature flag
@@ -67,7 +84,8 @@ When both are on, the sync path is:
plain, unauthenticated-by-default GET. OmniRoute never posts usage data, provider
configuration, or model traffic to the feed service.
3. The response is verified, validated, and cached locally (see
[Security model](#security-model)). Nothing else touches the network for Radar.
[Security model](#security-model)). Radar has exactly two server-side network paths:
`syncRadar()` for the catalog and `syncRadarReferrals()` for the standalone referrals feed.
The **supporter key** is an optional Bearer token (`radar_settings.supporter_key`)
that lets the feed service decide which tier to serve (see
@@ -77,6 +95,9 @@ that lets the feed service decide which tier to serve (see
helpers (`src/lib/db/encryption.ts`) used for provider credentials.
- Set via `POST /api/radar/settings` (`{ supporterKey: "omr_" + 40 hex chars }`) and
**never echoed back** — the response returns a masked form (`omr_****abcd`).
- Changing or clearing it atomically invalidates both the catalog and referrals caches. The
next sync/read resolves the new entitlement server-side; saving a key does not itself make
a network request or consume a single-use activation key.
- Sent to the feed service as a Bearer token on the sync GET — nothing else about the
key ever leaves the client.
@@ -102,10 +123,10 @@ pattern as `RADAR_FEED_URL`) and relayed to the dashboard through the existing
`GET /api/radar/settings` response (`contributorClaimUrl`, `supporterPlansUrl`) — the
client component never reads `process.env` itself.
| Var | Purpose |
| -------------------------------- | ---------------------------------------------------------------------------------------------- |
| `RADAR_CONTRIBUTOR_CLAIM_URL` | Overrides the contributor-claim URL (default `https://radar.omniroute.online/auth/github`). |
| `RADAR_SUPPORTER_PLANS_URL` | Overrides the supporter-plans URL (default `https://radar.omniroute.online/planos`). |
| Var | Purpose |
| ----------------------------- | ------------------------------------------------------------------------------------------- |
| `RADAR_CONTRIBUTOR_CLAIM_URL` | Overrides the contributor-claim URL (default `https://radar.omniroute.online/auth/github`). |
| `RADAR_SUPPORTER_PLANS_URL` | Overrides the supporter-plans URL (default `https://radar.omniroute.online/planos`). |
Once a visitor has a key (`omr_` + 40 hex chars), the activation screen
(`src/app/(dashboard)/dashboard/radar/page.tsx`) has a paste-key input as the primary
@@ -117,7 +138,7 @@ as a UX nicety; the server's Zod schema is the authoritative check either way. O
key is set, the activation screen shows the masked form (`supporterKeyMasked` from
`GET /api/radar/settings`) instead of an empty input, with a "change key" control to
paste a new one — the raw key is never redisplayed. The two claim/plans buttons above
remain the way to *obtain* a key in the first place; this input is where an operator
remain the way to _obtain_ a key in the first place; this input is where an operator
who already has one activates it.
---
@@ -207,11 +228,11 @@ handle.
### The served tier comes from a response header, not the signed body
The signed feed **body**'s `tier` field is always `"live"` — the feed service ships
**one signed artifact per version**, so the body cannot carry a per-request tier
without invalidating the Ed25519 signature (re-signing per request would defeat the
point of a pinned, cacheable, verifiable artifact). The tier actually served for a
given request is instead carried in the **`x-omniroute-feed-tier` response header**,
decided server-side from the request's `Authorization` key.
**two signed artifacts per version**: live includes current campaigns and community
omits them. Each artifact is signed over its own exact bytes. The body still does not
serve as the entitlement decision; the tier actually selected for a request is carried
in the **`x-omniroute-feed-tier` response header**, decided server-side from the request's
`Authorization` key.
`syncRadar()` (`src/lib/radar/sync.ts::parseServedTierHeader()`) is the single place
that resolves the tier a client should trust:
@@ -223,7 +244,7 @@ that resolves the tier a client should trust:
2. Fall back to the signed body's `tier` field (always `"live"`) only when step 1
yields nothing.
3. The resolved tier is what gets cached and returned as `{ status: "updated",
version, tier }` — this is the value the dashboard shows, never the raw body
version, tier }` — this is the value the dashboard shows, never the raw body
field.
---
@@ -265,18 +286,18 @@ Every merged entry carries an `origin` field the UI renders as a badge:
Five local routes back the UI, all under `src/app/api/radar/`:
| Route | Method | Purpose |
| ----------------------- | ------ | -------------------------------------------------------------------------------------------------- |
| `/api/radar/catalog` | GET | Returns the merged catalog (`getRadarCatalog()`) from the local cache. |
| `/api/radar/sync` | POST | Triggers `syncRadar()` server-side; returns the resulting status. |
| `/api/radar/settings` | GET | Returns `{ optIn, hasSupporterKey, supporterKeyMasked }` — never the raw key. |
| `/api/radar/settings` | POST | Sets opt-in and/or the (encrypted) supporter key. |
| `/api/radar/referrals` | GET | Returns `{ fixed, campaigns, tier }` from the local cache — see [Referral links](#referral-links-free-credits) below. |
| Route | Method | Purpose |
| ---------------------- | ------ | --------------------------------------------------------------------------------------------------------------------- |
| `/api/radar/catalog` | GET | Returns the merged catalog (`getRadarCatalog()`) from the local cache. |
| `/api/radar/sync` | POST | Triggers `syncRadar()` server-side; returns the resulting status. |
| `/api/radar/settings` | GET | Returns `{ optIn, hasSupporterKey, supporterKeyMasked }` — never the raw key. |
| `/api/radar/settings` | POST | Sets opt-in and/or the (encrypted) supporter key. |
| `/api/radar/referrals` | GET | Returns `{ fixed, campaigns, tier }` from the local cache — see [Referral links](#referral-links-free-credits) below. |
**Hard rule: these routes never proxy the feed service.** The browser only ever talks
to the local OmniRoute server; `syncRadar()` is the single module in the whole client
that touches the network for Radar (`src/lib/radar/sync.ts`), and it always runs
server-side, never client-side. This keeps the feed URL and any supporter key
to the local OmniRoute server. The two modules that touch the Radar service are
`src/lib/radar/sync.ts` (catalog) and `src/lib/radar/referralsSync.ts` (referrals); both
always run server-side, never client-side. This keeps the feed URL and any supporter key
out of client-facing network traffic entirely.
All five routes return `404` when `RADAR_ENABLED` is off (see
@@ -348,11 +369,13 @@ touches the network for referrals, mirroring `syncRadar()`'s contract exactly: f
Ed25519 signature over the exact response bytes (`verifyFeedBytes`), validates against
`RadarReferralsFeedSchema`, and caches into the `radar_referrals_cache` table
(migration `142_radar_referrals_cache.sql`) — a table entirely separate from the
catalog's `radar_feed_cache`. A 10 MB response cap and a `generatedAt` floor (an
incoming feed with a `generatedAt` no newer than the cached one is treated as `stale`
and never overwrites the cache — guards against a replay of an older signed artifact)
mirror the catalog sync's own `MAX_FEED_BYTES`/version-floor guards. Never throws —
always returns a status object; errors never carry a stack trace in `reason`.
catalog's `radar_feed_cache`. A 10 MB response cap and a `generatedAt` floor reject an
incoming feed older than the cached one, guarding against replay of an older signed
artifact. An equal timestamp is accepted: the server intentionally gives the community
and live referral variants the same deterministic `generatedAt`, so the signed payload
and served tier can change after a supporter-key change without the underlying link set
changing. Never throws — always returns a status object; errors never carry a stack trace
in `reason`.
Two triggers keep the referrals cache warm, both independent of the catalog's own
24h cadence:

View File

@@ -119,7 +119,8 @@ function serializeMessage(
"unsupported_role"
);
}
const label = role === "assistant" ? "Assistant" : role === "system" ? "System" : "User";
// role was just narrowed to "user" | "assistant" by the guard above ("system" throws).
const label = role === "assistant" ? "Assistant" : "User";
const content = record.content;
if (typeof content === "string") return `[${label}]\n${content}`;

View File

@@ -28,7 +28,14 @@ export class RaycastExecutor extends BaseExecutor {
return RAYCAST_CHAT_URL;
}
buildHeaders(credentials: ProviderCredentials, payload?: string): Record<string, string> {
// Not a BaseExecutor.buildHeaders override: Raycast signs headers over the exact
// request payload (2nd param is the body string, not the base's `stream` boolean),
// and execute() below is fully custom — keep it as a distinct helper so a
// polymorphic buildHeaders(credentials, true) call can never land here.
private buildRaycastRequestHeaders(
credentials: ProviderCredentials,
payload?: string
): Record<string, string> {
const body = payload || "{}";
return buildRaycastHeaders(body, credentials as JsonRecord);
}
@@ -44,7 +51,11 @@ export class RaycastExecutor extends BaseExecutor {
return {
response: new Response(
JSON.stringify({
error: { message: sanitizeErrorMessage(message), type: "invalid_request_error", code: "" },
error: {
message: sanitizeErrorMessage(message),
type: "invalid_request_error",
code: "",
},
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
),
@@ -54,7 +65,7 @@ export class RaycastExecutor extends BaseExecutor {
};
}
const headers = this.buildHeaders(credentials as ProviderCredentials, payload);
const headers = this.buildRaycastRequestHeaders(credentials as ProviderCredentials, payload);
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders as Record<string, string> | null);
let raycastResponse: Response;

View File

@@ -32,7 +32,7 @@
"replacement": " ",
"context": "all",
"category": "ultra",
"minIntensity": "notes"
"minIntensity": "ultra"
},
{
"name": "ultra_lowercase",
@@ -40,7 +40,7 @@
"replacement": " $1",
"context": "all",
"category": "ultra",
"minIntensity": "notes"
"minIntensity": "ultra"
}
]
}

View File

@@ -166,6 +166,29 @@ function isReasoningOnlyReplayTarget(provider: unknown, model: unknown): boolean
);
}
/**
* Upstreams that reject an ABSENT reasoning_content on replay turns, so the
* placeholder must survive the cache miss.
*
* #9573/#9610 removed the placeholder globally because the model echoed it as
* its own reasoning and stopped (empty turns). That holds for DeepSeek, where
* an absent field was verified to be accepted — but Xiaomi MiMo still 400s
* ("Param Incorrect: The reasoning_content in the thinking mode must be passed
* back to the API", 9router#1321/#1337), so omitting the field there trades one
* live bug for another. Keep the placeholder only for those providers; the echo
* that comes back is still stripped on the way in by
* isInternalReasoningPlaceholder(), so it never re-poisons cache or history.
*/
function requiresReasoningContentPresence(provider: unknown, model: unknown): boolean {
const normalizedProvider = String(provider ?? "")
.trim()
.toLowerCase();
const normalizedModel = String(model ?? "")
.trim()
.toLowerCase();
return normalizedProvider === "xiaomi-mimo" || /(^|\/)mimo/i.test(normalizedModel);
}
/** @param options.normalizeToolCallId - When true, use 9-char tool call ids (e.g. Mistral); when false, leave ids as-is */
/** @param options.preserveDeveloperRole - undefined/true: keep developer for OpenAI format (default); false: map to system */
/** @param options.preserveCacheControl - When true, preserve client-side cache_control markers (for Claude Code, etc.) */
@@ -575,7 +598,11 @@ export function translateRequest(
// the field instead; providers that genuinely enforce the contract
// (kimi-coding, moonshot authentic-reasoning) have their own paths above.
if ((hasToolCalls || shouldReplayReasoningOnly) && !msg.reasoning_content) {
delete msg.reasoning_content;
if (requiresReasoningContentPresence(normalizedProvider, normalizedModel)) {
msg.reasoning_content = NON_ANTHROPIC_THINKING_PLACEHOLDER;
} else {
delete msg.reasoning_content;
}
}
}
} else if (

View File

@@ -2463,8 +2463,18 @@ export function createSSEStream(options: StreamOptions = {}) {
status: 200,
usage,
responseBody,
// #9315 switched the summary to the accumulated responseBody to avoid
// stale/truncated event data — but responseBody here is synthesized in
// chat-completion shape, which loses the Responses API `response` object.
// Keep the events-derived summary for OPENAI_RESPONSES only.
providerPayload: providerPayloadCollector.build(
responseBody,
sourceFormat === FORMATS.OPENAI_RESPONSES
? buildStreamSummaryFromEvents(
providerPayloadCollector.getEvents(),
sourceFormat,
model
)
: responseBody,
{ includeEvents: false }
),
clientPayload: clientPayloadCollector.build(responseBody, {
@@ -2734,8 +2744,16 @@ export function createSSEStream(options: StreamOptions = {}) {
status: 200,
usage: state?.usage,
responseBody,
// Same OPENAI_RESPONSES carve-out as the passthrough branch above —
// the synthesized chat-shaped responseBody drops the `response` object.
providerPayload: providerPayloadCollector.build(
responseBody,
targetFormat === FORMATS.OPENAI_RESPONSES
? buildStreamSummaryFromEvents(
providerPayloadCollector.getEvents(),
targetFormat,
model
)
: responseBody,
{ includeEvents: false }
),
clientPayload: clientPayloadCollector.build(responseBody, {

View File

@@ -93,6 +93,10 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
// runtime; shipped via package.json "files", so it must be allowed here.
"bin/aliasResolverHook.mjs",
"bin/mcp-server.mjs",
// #9281: stdout/stderr console guard preloaded via `node --import` by
// bin/mcp-server.mjs before the MCP entry's module graph evaluates — without it
// the published CLI's `omniroute --mcp` crashes on the pathToFileURL() import.
"bin/mcpStdioConsoleGuard.mjs",
"bin/nodeRuntimeSupport.mjs",
"bin/omniroute.mjs",
"bin/reset-password.mjs",
@@ -183,6 +187,10 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
"bin/cli/utils/storageKeyProvision.mjs",
"bin/cli/utils/versionFastPath.mjs",
"bin/mcp-server.mjs",
// #9281: stdout/stderr console guard preloaded via `node --import` by
// bin/mcp-server.mjs before the MCP entry's module graph evaluates — without it
// the published CLI's `omniroute --mcp` crashes on the pathToFileURL() import.
"bin/mcpStdioConsoleGuard.mjs",
"bin/nodeRuntimeSupport.mjs",
"bin/omniroute.mjs",
// #7808: aliasResolver + its hook file. bin/omniroute.mjs imports

View File

@@ -1,10 +1,16 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useTranslations } from "next-intl";
import { useState, useEffect, useCallback } from "react";
import { useLocale, useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { Card } from "@/shared/components";
import {
firstProviderConnectionId,
providerConnectionsRequestUrl,
type RadarSetupConnection,
} from "@/lib/radar/setupConnections";
import type { RadarLocalizedText } from "@/lib/radar/feedSchema";
// ---------------------------------------------------------------------------
// Types
@@ -14,17 +20,16 @@ import { Card } from "@/shared/components";
* Localized text: either a plain string or an {en, pt?} object.
* The renderer resolves the best locale with EN fallback (D25 compat).
*/
type LocalizedText = string | { en: string; pt?: string };
interface SetupInfo {
keyUrl: string | null;
steps: LocalizedText[];
steps: RadarLocalizedText[];
}
interface ProviderSetupData {
provider: string;
setup: SetupInfo | null;
configured: boolean;
connectionId: string | null;
}
// ---------------------------------------------------------------------------
@@ -32,9 +37,9 @@ interface ProviderSetupData {
// ---------------------------------------------------------------------------
/** Resolve a LocalizedText to a display string. */
function resolveText(text: LocalizedText, locale: string): string {
function resolveText(text: RadarLocalizedText, locale: string): string {
if (typeof text === "string") return text;
if (locale === "pt" && text.pt) return text.pt;
if (locale.toLowerCase().startsWith("pt") && text.pt) return text.pt;
return text.en;
}
@@ -44,9 +49,9 @@ function resolveText(text: LocalizedText, locale: string): string {
export default function RadarSetupPage() {
const t = useTranslations("radarSetupPage");
const locale = useLocale();
const searchParams = useSearchParams();
const provider = searchParams.get("provider");
const locale = "en"; // Could be derived from next-intl locale later
const [setupData, setSetupData] = useState<ProviderSetupData | null>(null);
const [loading, setLoading] = useState(true);
@@ -63,18 +68,25 @@ export default function RadarSetupPage() {
async function load() {
try {
const res = await fetch("/api/radar/catalog");
const [res, connectionsRes] = await Promise.all([
fetch("/api/radar/catalog"),
fetch(providerConnectionsRequestUrl(provider)),
]);
if (res.status === 404) {
setError(t("flagDisabled"));
setLoading(false);
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!connectionsRes.ok) throw new Error(`HTTP ${connectionsRes.status}`);
const data = await res.json();
const connectionsData = (await connectionsRes.json()) as {
connections?: RadarSetupConnection[];
};
// Find ALL entries for this provider and extract setup from the first one that has it
const providerEntries = data.entries.filter(
(e: { provider: string }) => e.provider === provider,
(e: { provider: string }) => e.provider === provider
);
if (providerEntries.length === 0) {
@@ -85,17 +97,19 @@ export default function RadarSetupPage() {
// Find setup info from feed entries (they carry the setup field)
const entryWithSetup = providerEntries.find(
(e: { setup?: SetupInfo | null }) => e.setup && (e.setup.steps.length > 0 || e.setup.keyUrl),
(e: { setup?: SetupInfo | null }) =>
e.setup && (e.setup.steps.length > 0 || e.setup.keyUrl)
);
// Check if provider is configured (has connections)
// We infer this from whether the provider exists in the catalog at all
// The actual connection check would need a separate API — for now we show
// the guide regardless
const connectionId = firstProviderConnectionId(
Array.isArray(connectionsData.connections) ? connectionsData.connections : [],
provider
);
setSetupData({
provider,
setup: entryWithSetup?.setup ?? null,
configured: false, // Will be enriched when connection-status API is available
configured: connectionId !== null,
connectionId,
});
} catch (err) {
setError(err instanceof Error ? err.message : t("loadFailed"));
@@ -109,15 +123,11 @@ export default function RadarSetupPage() {
// Test connection — uses the EXISTING connection-test endpoint
const handleTestConnection = useCallback(async () => {
if (!provider) return;
if (!setupData?.connectionId) return;
setTesting(true);
setTestResult(null);
try {
// The existing test endpoint is POST /api/providers/[id]/test
// We need the connection ID — for now we use the provider ID as a proxy.
// In a full implementation, the setup page would list connections for
// this provider and test each one. Here we test the first connection.
const res = await fetch(`/api/providers/${encodeURIComponent(provider)}/test`, {
const res = await fetch(`/api/providers/${encodeURIComponent(setupData.connectionId)}/test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
@@ -136,7 +146,7 @@ export default function RadarSetupPage() {
} finally {
setTesting(false);
}
}, [provider, t]);
}, [setupData?.connectionId, t]);
if (!provider) {
return (
@@ -165,9 +175,7 @@ export default function RadarSetupPage() {
<p className="text-sm text-text-muted mt-1">{t("setupSubtitle")}</p>
</div>
{error && (
<div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm">{error}</div>
)}
{error && <div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm">{error}</div>}
{loading ? (
<div className="flex items-center justify-center min-h-[200px]">
@@ -248,15 +256,13 @@ export default function RadarSetupPage() {
<div className="flex items-center gap-3">
<button
onClick={handleTestConnection}
disabled={testing}
disabled={testing || !setupData.connectionId}
className="px-4 py-2 text-sm font-medium rounded-lg border border-violet-500 text-violet-400 hover:bg-violet-500/10 transition-colors disabled:opacity-50"
>
{testing ? t("testing") : t("testButton")}
</button>
{testResult && (
<span
className={`text-sm ${testResult.ok ? "text-green-400" : "text-red-400"}`}
>
<span className={`text-sm ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
{testResult.message}
</span>
)}

View File

@@ -5071,6 +5071,8 @@
"noNewModelsAddedExisting": "Không có mô hình mới nào được thêm (tất cả đã tồn tại).",
"importDoneCount": "✓ Hoàn tất! {count, plural, one {Đã nhập # mô hình.} other {Đã nhập # mô hình.}}",
"unexpectedErrorOccurred": "Đã xảy ra lỗi không mong muốn",
"getApiKey": "Lấy khóa API",
"getApiKeyDescription": "Đăng ký hoặc tạo tài khoản để nhận khóa API",
"connectionCountLabel": "{count, plural, one {# kết nối} other {# kết nối}}",
"messagesPath": "messages",
"responsesPath": "responses",
@@ -5201,6 +5203,18 @@
"interceptFetchHint": "Ghi đè các lệnh gọi công cụ web_fetch gốc sang /v1/web/fetch của OmniRoute.",
"interceptionLoadError": "Không thể tải cài đặt chặn: {error}",
"interceptionSaveError": "Không thể lưu cài đặt chặn: {error}",
"ccAliasSectionTitle": "Hiển thị trong Claude Code (claude/…)",
"ccAliasSectionHint": "Công bố các mô hình của nhà cung cấp này dưới dạng id phản chiếu claude/&lt;provider&gt;/&lt;model&gt; để tính năng khám phá mô hình qua gateway của Claude Code có thể liệt kê chúng. Mặc định tắt — bật lên sẽ nhân đôi số mục trong danh mục với mọi client.",
"ccAliasProviderLevelLabel": "Mặc định của nhà cung cấp",
"ccAliasModelOverridesLabel": "Ghi đè theo từng mô hình",
"ccAliasModelOverrideAriaLabel": "Ghi đè cho {modelId}",
"ccAliasStateInherit": "Kế thừa",
"ccAliasStateOn": "Bật",
"ccAliasStateOff": "Tắt",
"ccAliasAddModelPlaceholder": "Id mô hình (ví dụ: gpt-4o)",
"ccAliasAddModelButton": "Thêm ghi đè",
"ccAliasLoadError": "Không tải được cài đặt bí danh khám phá: {error}",
"ccAliasSaveError": "Không lưu được cài đặt bí danh khám phá: {error}",
"compatUpstreamHeadersLabel": "Các header upstream bổ sung",
"compatUpstreamHeadersHint": "Cài đặt có đặc quyền cao — có cùng mức độ tin cậy như khi chỉnh sửa thông tin xác thực API của nhà cung cấp; chỉ quản trị viên đáng tin cậy mới nên sử dụng. Các header này được hợp nhất sau khi OmniRoute thêm thông tin xác thực từ khóa API của nhà cung cấp. Nếu một header tùy chỉnh có cùng tên với header hiện có (ví dụ: Authorization), giá trị của bạn sẽ thay thế hoàn toàn header được tạo tự động (bao gồm cả token Bearer) — máy chủ thượng nguồn chỉ nhận được nội dung bạn đã nhập, không phải khóa trong phần cài đặt. Cấu hình sai có thể gây ra lỗi 401 hoặc làm hỏng quá trình xác thực với máy chủ thượng nguồn. Mỗi hàng tương ứng với một header (ví dụ: header Authentication bổ sung cho một số cổng). Di chuột hoặc đặt tiêu điểm vào giá trị để xem trước. Tự động lưu khi mất tiêu điểm, nhấp ra ngoài hoặc đóng bảng điều khiển này.",
"compatUpstreamHeaderName": "Tên header",
@@ -5475,6 +5489,13 @@
"newApiUserIdLabel": "ID người dùng New-API",
"newApiUserIdPlaceholder": "vd. 12345",
"newApiUserIdHint": "Giá trị tiêu đề New-Api-User của AgentRouter, dùng cùng với khóa API console để lấy số dư hạn mức.",
"newApiAggregatorToggleLabel": "Cổng tổng hợp",
"newApiAggregatorToggleHint": "Bật phát hiện số dư cho các node tổng hợp New-API / One-API / Sub2API. Bảng điều khiển sẽ hiển thị huy hiệu số dư và định tuyến quota-preflight sẽ bỏ qua các tài khoản đã cạn.",
"newApiAggregatorConsoleApiKeyHint": "System Access Token cho endpoint /api/user/self của bộ tổng hợp. Không phải khóa API định tuyến.",
"newApiAggregatorUserIdHint": "Giá trị header New-Api-User dùng để lấy số dư quota của người dùng bộ tổng hợp.",
"newApiAggregatorQuotaPerUnitLabel": "Quota mỗi đơn vị",
"newApiAggregatorQuotaPerUnitHint": "Số đơn vị tín dụng New-API cho mỗi 1 USD (mặc định: 500000). Ghi đè nếu bộ tổng hợp của bạn dùng tỷ lệ khác.",
"featureFlagNewApiAggregatorBalanceDescription": "Bật phát hiện số dư cho các node tương thích New-API / One-API / Sub2API",
"cpaModeDisabledTitle": "Chế độ tương thích CLIProxyAPI đã bị tắt",
"cpaModeEnabledTitle": "Chế độ tương thích CLIProxyAPI đã được bật",
"customUserAgentHint": "Gợi ý User Agent tùy chỉnh",
@@ -5590,6 +5611,7 @@
"tagGroupPlaceholder": "Nhập nhóm thẻ...",
"testModel": "Kiểm tra mô hình",
"testingModel": "Đang kiểm tra mô hình",
"modelTestQuotaTooltip": "Đã hết quota — sẽ đặt lại vào ngày mai hoặc cần nạp thêm",
"toggleOffShort": "Tắt",
"toggleOnShort": "Bật",
"tokenExpiredBadge": "Nhãn token đã hết hạn",
@@ -6008,27 +6030,7 @@
"kimiOfficialSupporterTooltip": "Kimi (Moonshot AI) là người bạn mã nguồn mở sáng lập của OmniRoute",
"cheaperInferenceSupporterBadge": "Người bạn mã nguồn mở",
"cheaperInferenceSupporterTooltip": "Cheaper Inference hỗ trợ OmniRoute với tư cách là người bạn mã nguồn mở",
"kimiPartnerLinkNote": "Partner link — supports OmniRoute at no extra cost to you",
"ccAliasSectionTitle": "Hiển thị trong Claude Code (claude/…)",
"ccAliasSectionHint": "Công bố các mô hình của nhà cung cấp này dưới dạng id phản chiếu claude/&lt;provider&gt;/&lt;model&gt; để tính năng khám phá mô hình qua gateway của Claude Code có thể liệt kê chúng. Mặc định tắt — bật lên sẽ nhân đôi số mục trong danh mục với mọi client.",
"ccAliasProviderLevelLabel": "Mặc định của nhà cung cấp",
"ccAliasModelOverridesLabel": "Ghi đè theo từng mô hình",
"ccAliasModelOverrideAriaLabel": "Ghi đè cho {modelId}",
"ccAliasStateInherit": "Kế thừa",
"ccAliasStateOn": "Bật",
"ccAliasStateOff": "Tắt",
"ccAliasAddModelPlaceholder": "Id mô hình (ví dụ: gpt-4o)",
"ccAliasAddModelButton": "Thêm ghi đè",
"ccAliasLoadError": "Không tải được cài đặt bí danh khám phá: {error}",
"ccAliasSaveError": "Không lưu được cài đặt bí danh khám phá: {error}",
"newApiAggregatorToggleLabel": "Cổng tổng hợp",
"newApiAggregatorToggleHint": "Bật phát hiện số dư cho các node tổng hợp New-API / One-API / Sub2API. Bảng điều khiển sẽ hiển thị huy hiệu số dư và định tuyến quota-preflight sẽ bỏ qua các tài khoản đã cạn.",
"newApiAggregatorConsoleApiKeyHint": "System Access Token cho endpoint /api/user/self của bộ tổng hợp. Không phải khóa API định tuyến.",
"newApiAggregatorUserIdHint": "Giá trị header New-Api-User dùng để lấy số dư quota của người dùng bộ tổng hợp.",
"newApiAggregatorQuotaPerUnitLabel": "Quota mỗi đơn vị",
"newApiAggregatorQuotaPerUnitHint": "Số đơn vị tín dụng New-API cho mỗi 1 USD (mặc định: 500000). Ghi đè nếu bộ tổng hợp của bạn dùng tỷ lệ khác.",
"featureFlagNewApiAggregatorBalanceDescription": "Bật phát hiện số dư cho các node tương thích New-API / One-API / Sub2API",
"modelTestQuotaTooltip": "Đã hết quota — sẽ đặt lại vào ngày mai hoặc cần nạp thêm"
"kimiPartnerLinkNote": "Partner link — supports OmniRoute at no extra cost to you"
},
"settings": {
"title": "Cài đặt",

View File

@@ -103,9 +103,7 @@ export function setRadarCache(entry: {
export function getRadarSettings(): RadarSettings {
const db = getDbInstance();
const row = db
.prepare(
"SELECT opt_in, supporter_key_encrypted, updated_at FROM radar_settings WHERE id = 1"
)
.prepare("SELECT opt_in, supporter_key_encrypted, updated_at FROM radar_settings WHERE id = 1")
.get() as { opt_in: number; supporter_key_encrypted: string | null; updated_at: string };
return {
@@ -120,9 +118,9 @@ export function getRadarSettings(): RadarSettings {
*/
export function setRadarOptIn(optIn: boolean): void {
const db = getDbInstance();
db.prepare(
"UPDATE radar_settings SET opt_in = ?, updated_at = datetime('now') WHERE id = 1"
).run(optIn ? 1 : 0);
db.prepare("UPDATE radar_settings SET opt_in = ?, updated_at = datetime('now') WHERE id = 1").run(
optIn ? 1 : 0
);
}
/**
@@ -133,9 +131,20 @@ export function setRadarOptIn(optIn: boolean): void {
export function setRadarKey(key: string | null): void {
const db = getDbInstance();
const encrypted = key !== null ? encrypt(key) : null;
db.prepare(
const updateKey = db.prepare(
"UPDATE radar_settings SET supporter_key_encrypted = ?, updated_at = datetime('now') WHERE id = 1"
).run(encrypted);
);
const clearCatalogCache = db.prepare("DELETE FROM radar_feed_cache WHERE id = 1");
const clearReferralsCache = db.prepare("DELETE FROM radar_referrals_cache WHERE id = 1");
db.transaction(() => {
updateKey.run(encrypted);
// Both signed feeds are entitlement-sensitive. Clearing their cached
// variants forces the next sync/read to resolve the new key server-side
// instead of serving data fetched under the previous entitlement.
clearCatalogCache.run();
clearReferralsCache.run();
})();
}
// ---------------------------------------------------------------------------

View File

@@ -345,7 +345,10 @@ function resolveCatalogPricing(
// Consulted only when models.dev returned nothing, matching the order
// already implemented in db/settings/pricing.ts::getPricing().
try {
const litellm = getSyncedPricing() as Record<string, Record<string, Record<string, number>>>;
const litellm = getSyncedPricing() as unknown as Record<
string,
Record<string, Record<string, number>>
>;
const providerPricing =
findInsensitive(litellm, provider) || findInsensitive(litellm, provider.replace(/-cn$/, ""));
if (providerPricing) {

View File

@@ -5,12 +5,15 @@
* The baseline (`FREE_MODEL_BUDGETS`) is NEVER mutated.
*
* Merge rules:
* 1. Feed never overwrites a local override.
* 1. Feed never overwrites a local override, except the safety-critical
* `enabled:false` signal for an upstream model confirmed unavailable.
* 2. `enabled:false` disables the entry with `disabledBy: "radar"` provenance.
* 3. User-added entry NOT in the feed survives untouched.
* 4. User deletion tombstone prevents feed from resurrecting the entry.
*/
import type { RadarLocalizedText } from "./feedSchema";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -73,7 +76,7 @@ export interface MergedEntry {
/** Setup guide (key URL + steps) reported by the feed. Undefined for baseline-only entries. */
setup?: {
keyUrl: string | null;
steps: string[];
steps: RadarLocalizedText[];
} | null;
}
@@ -107,7 +110,7 @@ export interface FeedModel {
tosRisk: MergedEntry["tos"];
setup: {
keyUrl: string | null;
steps: string[];
steps: RadarLocalizedText[];
} | null;
enabled: boolean;
}
@@ -127,9 +130,7 @@ function entryKey(provider: string, modelId: string): string {
* Convert a FeedModel budget into a `monthlyTokens` number compatible
* with the baseline catalog shape.
*/
function feedBudgetToMonthlyTokens(
budget: FeedModel["budget"],
): number {
function feedBudgetToMonthlyTokens(budget: FeedModel["budget"]): number {
if (budget.kind === "per_model") return budget.tokensPerMonth;
if (budget.kind === "shared_pool") return budget.tokensPerMonth;
return 0; // rate_only
@@ -138,9 +139,7 @@ function feedBudgetToMonthlyTokens(
/**
* Convert a FeedModel budget into a `poolKey` compatible with the baseline.
*/
function feedBudgetToPoolKey(
budget: FeedModel["budget"],
): string | null {
function feedBudgetToPoolKey(budget: FeedModel["budget"]): string | null {
if (budget.kind === "shared_pool") return budget.poolId;
return null;
}
@@ -242,7 +241,7 @@ export function applyFeed(input: ApplyFeedInput): MergedEntry[] {
function mergeOne(
base: MergedEntry,
feed: FeedModel,
overrides: Partial<MergedEntry> | undefined,
overrides: Partial<MergedEntry> | undefined
): MergedEntry {
// Start from baseline
const result: MergedEntry = { ...base };
@@ -307,6 +306,13 @@ function mergeOne(
if (overrides.setup !== undefined) result.setup = overrides.setup;
}
// Safety exception to rule 1: a model confirmed unavailable upstream is
// never resurrected by a stale local enabled:true override.
if (!feed.enabled) {
result.enabled = false;
result.disabledBy = "radar";
}
// Origin: "local" if user has overrides, else "radar" (feed updated it)
result.origin = overriddenKeys.size > 0 ? "local" : "radar";
@@ -318,7 +324,7 @@ function mergeOne(
*/
function feedModelToMerged(
feed: FeedModel,
overrides: Partial<MergedEntry> | undefined,
overrides: Partial<MergedEntry> | undefined
): MergedEntry {
const entry: MergedEntry = {
provider: feed.provider,
@@ -329,8 +335,8 @@ function feedModelToMerged(
freeType: overrides?.freeType ?? feed.freeType,
poolKey: overrides?.poolKey ?? feedBudgetToPoolKey(feed.budget),
tos: overrides?.tos ?? feed.tosRisk,
trainsOnPrompts: overrides?.trainsOnPrompts ?? (feed.trainsOnPrompts ?? undefined),
enabled: overrides?.enabled ?? feed.enabled,
trainsOnPrompts: overrides?.trainsOnPrompts ?? feed.trainsOnPrompts ?? undefined,
enabled: feed.enabled ? (overrides?.enabled ?? true) : false,
origin: overrides ? "local" : "radar",
contextWindow: overrides?.contextWindow ?? feed.contextWindow,
capabilities: overrides?.capabilities ?? feed.capabilities,
@@ -338,10 +344,8 @@ function feedModelToMerged(
setup: overrides?.setup ?? feed.setup,
};
// Rule 2 (feed disable) — but rule 1 (local override wins) takes precedence,
// matching mergeOne(): only force-disable when the user has NOT explicitly
// overridden `enabled` locally.
if (!feed.enabled && overrides?.enabled === undefined) {
// Rule 2 is the safety exception to local override precedence.
if (!feed.enabled) {
entry.enabled = false;
entry.disabledBy = "radar";
}

View File

@@ -43,6 +43,16 @@ export type RadarTier = z.infer<typeof RadarTierSchema>;
const IntNullable = z.number().int().nullable();
/** D25: English is canonical; Portuguese is an optional localized companion. */
export const RadarLocalizedTextSchema = z.union([
z.string(), // compatibility with schema-v1 feeds published before D25
z.object({
en: z.string().min(1),
pt: z.string().min(1).optional(),
}),
]);
export type RadarLocalizedText = z.infer<typeof RadarLocalizedTextSchema>;
/**
* Budget is a discriminated union on `kind`:
* - per_model: tokensPerMonth (positive int)
@@ -80,7 +90,7 @@ const CapabilitiesSchema = z.object({
const SetupSchema = z
.object({
keyUrl: z.string().url().nullable(),
steps: z.array(z.string()),
steps: z.array(RadarLocalizedTextSchema),
})
.nullable();
@@ -167,8 +177,8 @@ const QuirkTargetSchema = z.object({
const QuirkSchema = z.object({
slug: z.string(),
title: z.string(),
body: z.string(),
title: RadarLocalizedTextSchema,
body: RadarLocalizedTextSchema,
severity: SeverityEnum,
targets: z.array(QuirkTargetSchema),
});
@@ -182,10 +192,9 @@ export const RadarFeedSchema = z.object({
schemaVersion: z.literal(1),
version: z.string(),
generatedAt: z.string().datetime(),
// NOTE: this body field is ALWAYS "live", by design — the community tier
// is the exact same signed bytes served from an older snapshot, and there
// is only one signed artifact per version (rewriting this field
// server-side per request would break the exact-bytes Ed25519 signature).
// NOTE: this body field is not the entitlement decision. The server can
// publish separate exact-byte live/community artifacts for one version,
// while the selected request tier is still communicated by the header.
// The tier ACTUALLY served is decided by the server per-request based on
// the Authorization key, and is surfaced via the `x-omniroute-feed-tier`
// response header instead. NEVER read this field for UI/display — use the
@@ -217,4 +226,3 @@ export type RadarProvider = z.infer<typeof ProviderSchema>;
export type RadarQuirk = z.infer<typeof QuirkSchema>;
export type RadarBudget = z.infer<typeof BudgetSchema>;
export type RadarReferral = z.infer<typeof RadarReferralSchema>;
export type RadarReferrals = z.infer<typeof RadarReferralsSchema>;

View File

@@ -18,10 +18,7 @@
* Deps are injectable for testing.
*/
import {
RadarReferralsFeedSchema,
type RadarReferralsFeed,
} from "./referralsFeedSchema";
import { RadarReferralsFeedSchema, type RadarReferralsFeed } from "./referralsFeedSchema";
import { RadarTierSchema, type RadarTier } from "./feedSchema";
import { verifyFeedBytes } from "./verify";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
@@ -144,9 +141,10 @@ export function shouldSyncReferralsOnRead(
* 4. Verify Ed25519 signature over exact bytes (same pinned key as the
* catalog feed — one key pins both artifacts).
* 5. Parse+validate with RadarReferralsFeedSchema.
* 6. Replay guard: incoming `generatedAt` must be strictly newer than the
* cache (a same/older `generatedAt` is a no-op — nothing changed, or a
* stale replay — either way the cache is left untouched).
* 6. Replay guard: reject an incoming `generatedAt` older than the cache.
* Equal timestamps remain valid because the community and live referral
* variants deliberately share one deterministic `generatedAt`; the
* served tier can still change after the supporter key changes.
* 7. Cache the result.
*
* @param deps - Injectable dependencies for testing.
@@ -262,7 +260,8 @@ export async function syncRadarReferrals(
return { status: "invalid_schema" };
}
// Step 7: generatedAt floor — replay/no-op guard.
// Step 7: generatedAt floor — reject older signed replays. Equal
// timestamps are accepted because entitlement variants share generatedAt.
let existingCache: RadarReferralsCacheEntry | null = null;
if (getCacheFn) {
existingCache = getCacheFn();
@@ -274,7 +273,7 @@ export async function syncRadarReferrals(
if (existingCache) {
const existingMs = Date.parse(existingCache.generatedAt);
const incomingMs = Date.parse(feed.generatedAt);
if (Number.isFinite(existingMs) && Number.isFinite(incomingMs) && incomingMs <= existingMs) {
if (Number.isFinite(existingMs) && Number.isFinite(incomingMs) && incomingMs < existingMs) {
return { status: "stale" };
}
}
@@ -282,7 +281,8 @@ export async function syncRadarReferrals(
// Step 8: Resolve served tier — the body carries no `tier` field at all
// for this feed, so the header is the only source; absent/garbage header
// degrades to the least-privileged "community" default.
const servedTier = parseServedTierHeader(res.headers.get("x-omniroute-feed-tier")) ?? "community";
const servedTier =
parseServedTierHeader(res.headers.get("x-omniroute-feed-tier")) ?? "community";
// Step 9: Cache the result
const cacheEntry: RadarReferralsCacheEntry = {

View File

@@ -0,0 +1,26 @@
export interface RadarSetupConnection {
id: string;
provider: string;
isActive?: boolean;
}
/** Reuse the authenticated provider-connections API with a bounded provider filter. */
export function providerConnectionsRequestUrl(provider: string): string {
return `/api/providers?provider=${encodeURIComponent(provider)}`;
}
/**
* Pick a concrete connection id for the setup test endpoint. Prefer an active
* connection, then fall back to the first valid connection for the provider.
*/
export function firstProviderConnectionId(
connections: readonly RadarSetupConnection[],
provider: string
): string | null {
const matching = connections.filter(
(connection) => connection.provider === provider && connection.id.length > 0
);
return (
matching.find((connection) => connection.isActive !== false)?.id ?? matching[0]?.id ?? null
);
}

View File

@@ -266,7 +266,14 @@ export async function syncRadar(deps: SyncDeps = {}): Promise<SyncStatus> {
return { status: "invalid_schema" };
}
// Step 7: Version floor
// Step 7: Resolve the served tier before the version floor. A single-use
// supporter key deliberately transitions from live to community after its
// first catalog pull, and the community snapshot can be older.
const servedTier = parseServedTierHeader(res.headers.get("x-omniroute-feed-tier")) ?? feed.tier;
// Step 8: Version floor. Same/older versions are rejected within a tier,
// but a verified live -> community transition must replace the privileged
// cache even when the community snapshot is older.
let existingCache: RadarCacheEntry | null = null;
if (getCacheFn) {
existingCache = getCacheFn();
@@ -275,17 +282,15 @@ export async function syncRadar(deps: SyncDeps = {}): Promise<SyncStatus> {
existingCache = mod.getRadarCache();
}
if (existingCache && compareVersions(feed.version, existingCache.version) <= 0) {
const isEntitlementDowngrade = existingCache?.tier === "live" && servedTier === "community";
if (
existingCache &&
!isEntitlementDowngrade &&
compareVersions(feed.version, existingCache.version) <= 0
) {
return { status: "stale" };
}
// Step 8: Resolve the served tier.
// The `x-omniroute-feed-tier` header reflects the tier ACTUALLY served
// (see `parseServedTierHeader`); fall back to the signed body's `tier`
// field only when the header is absent or unrecognized — never trust an
// arbitrary header value into the cache/UI.
const servedTier = parseServedTierHeader(res.headers.get("x-omniroute-feed-tier")) ?? feed.tier;
// Step 9: Cache the result
const cacheEntry: RadarCacheEntry = {
version: feed.version,

View File

@@ -1348,7 +1348,7 @@ async function handleSingleModelChat(
const breakerFailureStatus = Number(lastStatus ?? credentials?.lastErrorCode);
if (
!forceLiveComboTest &&
isAllRateLimited &&
credentials?.allRateLimited &&
PROVIDER_BREAKER_FAILURE_STATUSES.has(breakerFailureStatus)
) {
breaker._onFailure();

View File

@@ -39,9 +39,7 @@
"incremental": true,
"incrementalFile": "reports/mutation/stryker-incremental.json",
"testRunner": "tap",
"plugins": [
"@stryker-mutator/tap-runner"
],
"plugins": ["@stryker-mutator/tap-runner"],
"tap": {
"testFiles": [
"tests/unit/7993-noauth-proxy-routing.test.ts",
@@ -52,6 +50,7 @@
"tests/unit/8376-econnrefused-breaker.test.ts",
"tests/unit/8396-cooldown-429-cap.test.ts",
"tests/unit/8488-capability-filter-fail-closed.test.ts",
"tests/unit/8779-agy-prefix-credential-lookup.test.ts",
"tests/unit/account-fallback-anthropic-quota.test.ts",
"tests/unit/account-fallback-cf1010-no-retry-8775.test.ts",
"tests/unit/account-fallback-lockout-eviction.test.ts",
@@ -85,6 +84,7 @@
"tests/unit/auto-combo-engine.test.ts",
"tests/unit/auto-combo-scoring-clamp.test.ts",
"tests/unit/bug-7940-gemini-retrydelay.test.ts",
"tests/unit/bug-9204-agy-provider-alias-credentials.test.ts",
"tests/unit/build/check-circular-deps.test.ts",
"tests/unit/cache-sweeps.test.ts",
"tests/unit/cc-bridge-openai-image-7777.test.ts",
@@ -191,6 +191,7 @@
"tests/unit/combo/combo-target-timeout-standards.test.ts",
"tests/unit/combo/effective-max-concurrency.test.ts",
"tests/unit/combo/recovery-hint.test.ts",
"tests/unit/combo/reset-window-strategy-9330.test.ts",
"tests/unit/complexity-aware-scoring-wiring.test.ts",
"tests/unit/compression-header-verification.test.ts",
"tests/unit/context-pinning-tool-calls.test.ts",
@@ -250,6 +251,7 @@
"tests/unit/observability-payloads.test.ts",
"tests/unit/ollama-cloud-weekly-quota-cooldown-3709.test.ts",
"tests/unit/openapi-security-tiers.test.ts",
"tests/unit/openrouter-passthrough-models.test.ts",
"tests/unit/openrouter-quota-6842.test.ts",
"tests/unit/persist-429-cooldown-account-fallback.test.ts",
"tests/unit/plan3-p0.test.ts",
@@ -271,6 +273,7 @@
"tests/unit/rate-limit-manager.test.ts",
"tests/unit/rate-limit-queue-timeout-lockout.test.ts",
"tests/unit/repro-7503-no-choices.test.ts",
"tests/unit/repro-9630-combo-false-503.test.ts",
"tests/unit/repro-antigravity-404-family-cooldown-hijack.test.ts",
"tests/unit/responses-handler.test.ts",
"tests/unit/rotation-config-omniroute.test.ts",
@@ -428,11 +431,7 @@
".worktrees",
".stryker-tmp"
],
"reporters": [
"progress",
"html",
"json"
],
"reporters": ["progress", "html", "json"],
"htmlReporter": {
"fileName": "reports/mutation/mutation.html"
},

View File

@@ -46,7 +46,10 @@
"setup": {
"keyUrl": "https://console.groq.com/keys",
"steps": [
"Crie uma conta gratuita no console da Groq",
{
"en": "Create a free account in the Groq console",
"pt": "Crie uma conta gratuita no console da Groq"
},
"Gere uma API key na página de keys",
"Adicione a key no OmniRoute com o provider groq"
]

View File

@@ -8,7 +8,7 @@
* This test verifies the payload normalization fix is present in the source code
* and that the correct contract keys are read by loadData().
*
* Run: node --import tsx/esm --test tests/unit/free-pool-frontend-repro.test.tsx
* Run: node --import tsx/esm --test tests/unit/free-pool-frontend-repro.test.ts
*/
import test from "node:test";

View File

@@ -20,11 +20,10 @@ const ROOT = new URL("../..", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/
*/
describe("omniroute --mcp stdio transport", () => {
it("writes only valid JSON-RPC to stdout — no DB init or other startup logging leaks through", async () => {
const child = spawn(
process.execPath,
[join(ROOT, "bin", "omniroute.mjs"), "--mcp"],
{ cwd: ROOT, env: process.env }
);
const child = spawn(process.execPath, [join(ROOT, "bin", "omniroute.mjs"), "--mcp"], {
cwd: ROOT,
env: process.env,
});
let stdout = "";
let stderr = "";
@@ -48,11 +47,23 @@ describe("omniroute --mcp stdio transport", () => {
})}\n`
);
await new Promise((resolve) => setTimeout(resolve, 4000));
// The full chain (omniroute.mjs CLI startup + spawned MCP child, each paying a tsx
// import + the child's DB init/migrations) takes ~10s on a warm dev box and longer on
// loaded CI runners — a fixed 4s sleep made this test red from birth. Poll for the
// first stdout line instead, then give the stream a short settle window so any
// late startup logging that WOULD corrupt the protocol still gets caught.
const deadline = Date.now() + 60_000;
while (!stdout.includes("\n") && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
await new Promise((resolve) => setTimeout(resolve, 1000));
child.kill();
const stdoutLines = stdout.split("\n").filter((line) => line.trim().length > 0);
assert.ok(stdoutLines.length > 0, "expected at least one line on stdout (the initialize response)");
assert.ok(
stdoutLines.length > 0,
"expected at least one line on stdout (the initialize response)"
);
for (const line of stdoutLines) {
assert.doesNotThrow(
@@ -61,9 +72,7 @@ describe("omniroute --mcp stdio transport", () => {
);
}
const initResponse = stdoutLines
.map((line) => JSON.parse(line))
.find((msg) => msg.id === 1);
const initResponse = stdoutLines.map((line) => JSON.parse(line)).find((msg) => msg.id === 1);
assert.ok(initResponse, "expected an initialize response with id 1 on stdout");
assert.equal(initResponse.jsonrpc, "2.0");

View File

@@ -154,6 +154,7 @@ test("findMissingArtifactPaths flags missing root runtime files in the tarball",
"bin/cli/utils/storageKeyProvision.mjs",
"bin/cli/utils/versionFastPath.mjs",
"bin/mcp-server.mjs",
"bin/mcpStdioConsoleGuard.mjs",
"bin/nodeRuntimeSupport.mjs",
"dist/head-response-guard.cjs",
"dist/http-method-guard.cjs",

View File

@@ -42,6 +42,10 @@ const PLUGIN_ROUTES: Array<{ rel: string; label: string }> = [
rel: "src/app/api/plugins/marketplace/route.ts",
label: "GET /api/plugins/marketplace",
},
{
rel: "src/app/api/plugins/marketplace/install/route.ts",
label: "POST /api/plugins/marketplace/install",
},
];
for (const { rel, label } of PLUGIN_ROUTES) {

View File

@@ -51,9 +51,8 @@ function createFixturePlugin(name: string, opts?: { onResponse?: boolean; onRequ
// ── Manifest validation ──
test("plugin manifest validation", async (t) => {
const { validateManifest, safeValidateManifest, applyDefaults } = await import(
"../../src/lib/plugins/manifest.ts"
);
const { validateManifest, safeValidateManifest, applyDefaults } =
await import("../../src/lib/plugins/manifest.ts");
await t.test("valid manifest parses with defaults", () => {
const result = validateManifest({
@@ -136,8 +135,22 @@ test("plugin hooks system", async (t) => {
await t.test("registerHook registers and sorts by priority", () => {
const calls: string[] = [];
registerHook("onRequest", "plugin-b", () => { calls.push("b"); }, 200);
registerHook("onRequest", "plugin-a", () => { calls.push("a"); }, 100);
registerHook(
"onRequest",
"plugin-b",
() => {
calls.push("b");
},
200
);
registerHook(
"onRequest",
"plugin-a",
() => {
calls.push("a");
},
100
);
const hooks = getHooks("onRequest");
assert.equal(hooks.length, 2);
assert.equal(hooks[0].pluginName, "plugin-a");
@@ -174,9 +187,30 @@ test("plugin hooks system", async (t) => {
await t.test("emitHook calls all handlers in order", async () => {
const order: number[] = [];
registerHook("onTest", "h1", () => { order.push(1); }, 100);
registerHook("onTest", "h2", () => { order.push(2); }, 200);
registerHook("onTest", "h3", () => { order.push(3); }, 150);
registerHook(
"onTest",
"h1",
() => {
order.push(1);
},
100
);
registerHook(
"onTest",
"h2",
() => {
order.push(2);
},
200
);
registerHook(
"onTest",
"h3",
() => {
order.push(3);
},
150
);
await emitHook("onTest", {});
assert.deepEqual(order, [1, 3, 2]);
resetHooks();
@@ -184,8 +218,22 @@ test("plugin hooks system", async (t) => {
await t.test("emitHook swallows handler errors", async () => {
const calls: string[] = [];
registerHook("onErr", "bad", () => { throw new Error("boom"); }, 100);
registerHook("onErr", "good", () => { calls.push("ok"); }, 200);
registerHook(
"onErr",
"bad",
() => {
throw new Error("boom");
},
100
);
registerHook(
"onErr",
"good",
() => {
calls.push("ok");
},
200
);
await emitHook("onErr", {});
assert.deepEqual(calls, ["ok"]);
resetHooks();
@@ -203,7 +251,14 @@ test("plugin hooks system", async (t) => {
await t.test("emitHookBlocking returns early on blocked", async () => {
const calls: string[] = [];
registerHook("onBlock2", "blocker", () => ({ blocked: true, response: { error: "no" } }), 100);
registerHook("onBlock2", "after", () => { calls.push("after"); }, 200);
registerHook(
"onBlock2",
"after",
() => {
calls.push("after");
},
200
);
const result = await emitHookBlocking("onBlock2", {});
assert.equal(result.blocked, true);
assert.equal(calls.length, 0);
@@ -212,7 +267,13 @@ test("plugin hooks system", async (t) => {
await t.test("runOnRequest delegates to emitHookBlocking", async () => {
registerHook("onRequest", "req", () => ({ metadata: { seen: true } }), 100);
const result = await runOnRequest({ requestId: "1", body: {}, model: "gpt-4", provider: "openai", metadata: {} });
const result = await runOnRequest({
requestId: "1",
body: {},
model: "gpt-4",
provider: "openai",
metadata: {},
});
assert.deepEqual(result.metadata, { seen: true });
resetHooks();
});
@@ -230,7 +291,14 @@ test("plugin hooks system", async (t) => {
await t.test("runOnError is fire-and-forget", async () => {
let called = false;
registerHook("onError", "err-handler", () => { called = true; }, 100);
registerHook(
"onError",
"err-handler",
() => {
called = true;
},
100
);
await runOnError(
{ requestId: "1", body: {}, model: "gpt-4", provider: "openai", metadata: {} },
new Error("test")
@@ -257,6 +325,8 @@ test("plugin hooks system", async (t) => {
"onActivate",
"onDeactivate",
"onUninstall",
// #9668: fire-and-forget stream telemetry hook (runOnStreamCompleteHooks)
"onStreamComplete",
]);
resetHooks();
});
@@ -309,9 +379,7 @@ test("welcome banner PoC plugin lifecycle", async (t) => {
await t.test("onResponse injects banner into response", async () => {
const mod = await import(join(pluginDir, "index.mjs"));
const response = {
choices: [
{ message: { role: "assistant", content: "Hello!" } },
],
choices: [{ message: { role: "assistant", content: "Hello!" } }],
};
const result = await mod.plugin.onResponse({}, response);
assert.ok(result.choices[0].message.content.includes("[Welcome to OmniRoute"));
@@ -321,9 +389,7 @@ test("welcome banner PoC plugin lifecycle", async (t) => {
await t.test("onResponse handles streaming delta", async () => {
const mod = await import(join(pluginDir, "index.mjs"));
const response = {
choices: [
{ delta: { content: "stream chunk" } },
],
choices: [{ delta: { content: "stream chunk" } }],
};
const result = await mod.plugin.onResponse({}, response);
assert.ok(result.choices[0].delta.content.includes("[Welcome to OmniRoute"));

View File

@@ -181,10 +181,11 @@ test("provider models route merges live Codex models with the local catalog then
// merge conservatively — the smaller of live vs. pinned wins, never the
// larger, so a stale/inflated live number can never make OmniRoute promise
// more context than the account can actually serve (#7012). Here the pinned
// GPT-5.6 Codex contract (272000/128000, see GPT_5_6_CODEX_CAPABILITIES) is
// smaller than the live payload's 999999/999999, so the pinned value wins.
// GPT-5.6 Codex contract (922000/128000, see GPT_5_6_CODEX_CAPABILITIES
// raised from 272000 in #9432) is smaller than the live payload's
// 999999/999999, so the pinned value wins.
assert.equal(liveModel?.name, "GPT 5.6 Sol Live");
assert.equal(liveModel?.inputTokenLimit, 272000);
assert.equal(liveModel?.inputTokenLimit, 922000);
assert.equal(liveModel?.outputTokenLimit, 128000);
assert.equal(liveModel?.apiFormat, "responses");
assert.deepEqual(liveModel?.supportedEndpoints, ["responses"]);

View File

@@ -15,11 +15,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
applyFeed,
type MergedEntry,
type FeedModel,
} from "../../src/lib/radar/applyFeed.ts";
import { applyFeed, type MergedEntry, type FeedModel } from "../../src/lib/radar/applyFeed.ts";
import {
getRadarCatalog,
baselineToMergedEntries,
@@ -70,7 +66,9 @@ function makeBaseline(): MergedEntry[] {
];
}
function makeFeedModel(overrides: Partial<FeedModel> & { provider: string; modelId: string }): FeedModel {
function makeFeedModel(
overrides: Partial<FeedModel> & { provider: string; modelId: string }
): FeedModel {
return {
displayName: overrides.displayName ?? overrides.modelId,
familyId: null,
@@ -116,7 +114,7 @@ test("rule 1: feed does NOT overwrite a local override field", () => {
});
const groq = result.find(
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"
)!;
// Local override fields must survive
@@ -150,7 +148,7 @@ test("rule 2: feed enabled:false disables entry and carries disabledBy provenanc
});
const groq = result.find(
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"
)!;
assert.equal(groq.enabled, false);
@@ -191,9 +189,7 @@ test("rule 3: user-added entry not in feed survives untouched", () => {
tombstones: new Set(),
});
const custom = result.find(
(e) => e.provider === "custom" && e.modelId === "my-local-model",
)!;
const custom = result.find((e) => e.provider === "custom" && e.modelId === "my-local-model")!;
assert.equal(custom.displayName, "My Local Model");
assert.equal(custom.monthlyTokens, 50_000);
@@ -242,7 +238,7 @@ test("rule 3b: user-added entry that IS in the feed merges with rule 1", () => {
});
const groq = result.filter(
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"
);
// Should be deduplicated to ONE entry
@@ -262,7 +258,7 @@ test("rule 3b: user-added entry that IS in the feed merges with rule 1", () => {
test("rule 4: tombstone prevents feed from resurrecting a deleted entry", () => {
// Baseline has an entry for gemini, but user deleted it
const baseline = makeBaseline().filter(
(e) => !(e.provider === "gemini" && e.modelId === "gemini-2.5-flash"),
(e) => !(e.provider === "gemini" && e.modelId === "gemini-2.5-flash")
);
const feed: FeedModel[] = [
@@ -283,9 +279,7 @@ test("rule 4: tombstone prevents feed from resurrecting a deleted entry", () =>
tombstones,
});
const gemini = result.find(
(e) => e.provider === "gemini" && e.modelId === "gemini-2.5-flash",
);
const gemini = result.find((e) => e.provider === "gemini" && e.modelId === "gemini-2.5-flash");
// Must NOT be resurrected
assert.equal(gemini, undefined);
@@ -353,9 +347,7 @@ test("applyFeed: feed-only entry is added with origin 'radar'", () => {
tombstones: new Set(),
});
const added = result.find(
(e) => e.provider === "new-provider" && e.modelId === "new-model",
);
const added = result.find((e) => e.provider === "new-provider" && e.modelId === "new-model");
assert.ok(added, "feed-only entry should be present");
assert.equal(added.displayName, "Brand New Model");
@@ -388,7 +380,7 @@ test("applyFeed: feed fields merge over baseline where no local override", () =>
});
const groq = result.find(
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"
)!;
// Feed values win when no local override
@@ -440,7 +432,7 @@ test("applyFeed: duplicate key (baseline + feed) produces single merged entry",
});
const groqEntries = result.filter(
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"
);
assert.equal(groqEntries.length, 1, "should be deduplicated to one entry");
@@ -465,9 +457,7 @@ test("rule 4b: tombstoned entry removed even when baseline has it", () => {
tombstones,
});
const groq = result.find(
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
);
const groq = result.find((e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile");
assert.equal(groq, undefined, "tombstoned entry should be excluded");
});
@@ -494,9 +484,7 @@ test("origin switches to 'radar' when feed updates a baseline entry", () => {
tombstones: new Set(),
});
const gemini = result.find(
(e) => e.provider === "gemini" && e.modelId === "gemini-2.5-flash",
)!;
const gemini = result.find((e) => e.provider === "gemini" && e.modelId === "gemini-2.5-flash")!;
assert.equal(gemini.origin, "radar");
assert.equal(gemini.displayName, "Updated Gemini");
@@ -612,7 +600,7 @@ test("getRadarCatalog: valid cache returns merged entries with meta", () => {
// The feed has groq:llama-3.3-70b-versatile, which merges over baseline
const groq = result.entries.find(
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"
)!;
assert.equal(groq.displayName, "Feed Groq Name");
@@ -714,7 +702,7 @@ test("FIX2 mergeOne path: contextWindow/capabilities/limits/setup survive merge
});
const groq = result.find(
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"
)!;
assert.equal(groq.contextWindow, 131072);
@@ -758,13 +746,11 @@ test("FIX2 feedModelToMerged path: contextWindow/capabilities/limits/setup survi
});
// ===========================================================================
// FIX 4 — feedModelToMerged() must honor an `enabled` local override instead
// of unconditionally forcing `enabled:false` when the feed disables the model.
// mergeOne() already gets this right (overrides applied AFTER rule 2); this
// pins the same semantics on the feed-only path.
// Feed `enabled:false` is the safety exception to local override precedence:
// a model confirmed dead upstream must not be resurrected locally.
// ===========================================================================
test("FIX4: feed-only entry with local override enabled:true wins over feed enabled:false", () => {
test("rule 2: feed-only entry stays disabled even with local enabled:true", () => {
const baseline = makeBaseline();
const feed: FeedModel[] = [
makeFeedModel({
@@ -786,11 +772,11 @@ test("FIX4: feed-only entry with local override enabled:true wins over feed enab
});
const entry = result.find(
(e) => e.provider === "new-provider" && e.modelId === "disabled-model",
(e) => e.provider === "new-provider" && e.modelId === "disabled-model"
)!;
assert.equal(entry.enabled, true, "local override must win over feed disable");
assert.equal(entry.disabledBy, undefined, "must not carry radar disabledBy when overridden on");
assert.equal(entry.enabled, false, "a local override must not resurrect a dead upstream model");
assert.equal(entry.disabledBy, "radar");
});
test("FIX4: feed-only entry with NO override still gets disabled with disabledBy provenance", () => {
@@ -811,7 +797,7 @@ test("FIX4: feed-only entry with NO override still gets disabled with disabledBy
});
const entry = result.find(
(e) => e.provider === "new-provider" && e.modelId === "disabled-model-2",
(e) => e.provider === "new-provider" && e.modelId === "disabled-model-2"
)!;
assert.equal(entry.enabled, false);

View File

@@ -165,7 +165,9 @@ test("setRadarKey encrypts at rest and getRadarSettings decrypts", () => {
assert.equal(settings.supporterKey, clearKey, "getRadarSettings must return the clear key");
// Direct DB query to prove encryption at rest
interface SettingsRow { supporter_key_encrypted: string | null }
interface SettingsRow {
supporter_key_encrypted: string | null;
}
const row = db
.prepare("SELECT supporter_key_encrypted FROM radar_settings WHERE id = 1")
.get() as SettingsRow;
@@ -197,6 +199,36 @@ test("setRadarKey(null) clears the key", () => {
assert.equal(cleared.supporter_key_encrypted, null, "DB value must be null");
});
test("changing the supporter key invalidates the entitlement-sensitive referrals cache", () => {
radar.setRadarCache({
version: "2026.08.07.1",
tier: "live",
payload: '{"models":[]}',
signature: "catalog-live-signature",
});
radar.setRadarReferralsCache({
generatedAt: "2026-08-07T12:00:00.000Z",
tier: "live",
payload: '{"referrals":{"fixed":[],"campaigns":[{"provider":"groq"}]}}',
signature: "live-signature",
});
assert.ok(radar.getRadarReferralsCache(), "precondition: live referrals cache exists");
assert.ok(radar.getRadarCache(), "precondition: live catalog cache exists");
radar.setRadarKey("omr_" + "d".repeat(40));
assert.equal(
radar.getRadarReferralsCache(),
null,
"a new key must force the next referrals read to resolve entitlement server-side"
);
assert.equal(
radar.getRadarCache(),
null,
"a new key must force the next catalog sync to resolve entitlement server-side"
);
});
test("setRadarKey uses existing AES-256-GCM encryption from encryption.ts", () => {
const db = core.getDbInstance();
const clearKey = "omr_" + "c".repeat(40);
@@ -271,7 +303,9 @@ test("second setRadarReferralsCache REPLACES the row (still single row)", () =>
assert.equal(result.tier, "live", "must have the second tier");
assert.equal(result.payload, '{"new":true}', "must have the second payload");
const count = db.prepare("SELECT COUNT(*) AS c FROM radar_referrals_cache").get() as { c: number };
const count = db.prepare("SELECT COUNT(*) AS c FROM radar_referrals_cache").get() as {
c: number;
};
assert.equal(count.c, 1, "must have exactly one row");
});

View File

@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { RadarFeedSchema } from "../../src/lib/radar/feedSchema.ts";
const fixture = JSON.parse(
readFileSync(new URL("../fixtures/radar-feed-canonical.json", import.meta.url), "utf8")
) as Record<string, unknown>;
test("canonical fixture carries D25 localized setup and accepts localized quirks", () => {
const localized = structuredClone(fixture) as {
models: Array<{ setup: { steps: unknown[] } | null }>;
quirks: Array<{ title: unknown; body: unknown }>;
};
localized.quirks = [
{
slug: "shared-pool",
title: { en: "Shared quota", pt: "Cota compartilhada" },
body: { en: "Models share one pool." },
severity: "info",
targets: [{ provider: "groq", modelGlob: null }],
},
];
const parsed = RadarFeedSchema.parse(localized);
assert.deepEqual(parsed.models[0]!.setup!.steps[0], {
en: "Create a free account in the Groq console",
pt: "Crie uma conta gratuita no console da Groq",
});
});
test("RadarFeedSchema preserves schema-v1 legacy setup and quirk strings", () => {
const legacy = structuredClone(fixture) as {
models: Array<{ setup: { steps: unknown[] } | null }>;
quirks: Array<{ title: unknown; body: unknown }>;
};
legacy.models[0]!.setup!.steps[0] = "Create an account";
legacy.quirks[0]!.title = "Shared quota";
legacy.quirks[0]!.body = "Models share one pool.";
const parsed = RadarFeedSchema.parse(legacy);
assert.equal(parsed.models[0]!.setup!.steps[0], "Create an account");
assert.equal(parsed.quirks[0]!.title, "Shared quota");
assert.equal(parsed.quirks[0]!.body, "Models share one pool.");
});

View File

@@ -118,7 +118,11 @@ test("RadarReferralsFeedSchema: rejects missing referrals section", () => {
const feed = baseReferralsFeed();
delete (feed as Record<string, unknown>).referrals;
const result = referralsFeedSchema.RadarReferralsFeedSchema.safeParse(feed);
assert.equal(result.success, false, "referrals section is required (no old-feed compat needed here)");
assert.equal(
result.success,
false,
"referrals section is required (no old-feed compat needed here)"
);
});
test("RadarReferralsFeedSchema: rejects a non-https referral url", () => {
@@ -215,7 +219,10 @@ test("syncRadarReferrals: valid signature => cache updated, payload byte-identic
},
fetch: (() =>
Promise.resolve(
mockResponse(bytes, { "x-omniroute-feed-signature": sig, "x-omniroute-feed-tier": "community" })
mockResponse(bytes, {
"x-omniroute-feed-signature": sig,
"x-omniroute-feed-tier": "community",
})
)) as unknown as typeof globalThis.fetch,
now: () => new Date("2026-08-07T12:05:00.000Z"),
});
@@ -296,32 +303,47 @@ test("syncRadarReferrals: valid sig over garbage JSON => invalid_schema, cache u
// syncRadarReferrals — generatedAt floor (replay/no-op guard)
// ===========================================================================
test("syncRadarReferrals: same generatedAt as cache => stale, cache untouched", async () => {
test("syncRadarReferrals: same generatedAt with a new served tier replaces the cache", async () => {
const feed = baseReferralsFeed("2026-08-07T12:00:00.000Z");
(feed.referrals as { campaigns: Array<Record<string, unknown>> }).campaigns = [
{
provider: "groq",
url: "https://groq.com/?campaign=live",
kind: "campanha",
validUntil: null,
requiredAction: null,
isDefault: false,
},
];
const bytes = feedBytes(feed);
const sig = signBytes(bytes);
let cacheWritten = false;
const cacheStore: referralsSync.RadarReferralsCacheEntry[] = [];
const result = await referralsSync.syncRadarReferrals({
getFlag: () => true,
getSettings: () => ({ optIn: true, supporterKey: null }),
getSettings: () => ({ optIn: true, supporterKey: "omr_" + "a".repeat(40) }),
getCache: () => ({
generatedAt: "2026-08-07T12:00:00.000Z",
tier: "community",
payload: "{}",
signature: "old-sig",
}),
setCache: () => {
cacheWritten = true;
setCache: (entry) => {
cacheStore.push(entry);
},
fetch: (() =>
Promise.resolve(
mockResponse(bytes, { "x-omniroute-feed-signature": sig })
mockResponse(bytes, {
"x-omniroute-feed-signature": sig,
"x-omniroute-feed-tier": "live",
})
)) as unknown as typeof globalThis.fetch,
});
assert.equal(result.status, "stale");
assert.equal(cacheWritten, false);
assert.equal(result.status, "updated");
assert.equal(cacheStore.length, 1);
assert.equal(cacheStore[0]!.tier, "live");
assert.equal(cacheStore[0]!.payload, bytes.toString("utf-8"));
});
test("syncRadarReferrals: older generatedAt than cache => stale (replay rejected)", async () => {
@@ -487,7 +509,9 @@ test("syncRadarReferrals: header absent => falls back to 'community' (no body ti
cacheStore.push(entry);
},
fetch: (() =>
Promise.resolve(mockResponse(bytes, { "x-omniroute-feed-signature": sig }))) as unknown as typeof globalThis.fetch,
Promise.resolve(
mockResponse(bytes, { "x-omniroute-feed-signature": sig })
)) as unknown as typeof globalThis.fetch,
});
assert.equal(result.status, "updated");
@@ -596,7 +620,9 @@ test("syncRadarReferrals: HTTP non-200 => error mentioning the status code", asy
getFlag: () => true,
getSettings: () => ({ optIn: true, supporterKey: null }),
fetch: (() =>
Promise.resolve(mockResponse(Buffer.from("Internal Server Error"), {}, 500))) as unknown as typeof globalThis.fetch,
Promise.resolve(
mockResponse(Buffer.from("Internal Server Error"), {}, 500)
)) as unknown as typeof globalThis.fetch,
});
assert.equal(result.status, "error");
@@ -646,7 +672,8 @@ test("FIX: oversized body without a trustworthy Content-Length header => too_lar
setCache: () => {
setCacheCalled = true;
},
fetch: (() => Promise.resolve(mockResponse(oversized, {}))) as unknown as typeof globalThis.fetch,
fetch: (() =>
Promise.resolve(mockResponse(oversized, {}))) as unknown as typeof globalThis.fetch,
});
assert.deepEqual(result, { status: "too_large" });

View File

@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
firstProviderConnectionId,
providerConnectionsRequestUrl,
} from "../../src/lib/radar/setupConnections.ts";
test("providerConnectionsRequestUrl filters the existing providers API", () => {
assert.equal(
providerConnectionsRequestUrl("openrouter/custom"),
"/api/providers?provider=openrouter%2Fcustom"
);
});
test("firstProviderConnectionId selects a real connection id, never the provider slug", () => {
assert.equal(
firstProviderConnectionId(
[
{ id: "connection-disabled", provider: "groq", isActive: false },
{ id: "connection-active", provider: "groq", isActive: true },
],
"groq"
),
"connection-active"
);
assert.equal(firstProviderConnectionId([], "groq"), null);
});

View File

@@ -409,6 +409,35 @@ test("syncRadar: version floor — incoming older => stale", async () => {
assert.equal(cacheWritten, false, "cache must NOT be overwritten with older version");
});
test("syncRadar: an entitlement downgrade replaces a newer live cache with community", async () => {
const sig = signBytes(FIXTURE_BYTES);
const cacheStore: syncMod.RadarCacheEntry[] = [];
const result = await syncMod.syncRadar({
getFlag: () => true,
getSettings: () => ({ optIn: true, supporterKey: "omr_" + "a".repeat(40) }),
getCache: () => ({
version: "2026.08.02.1",
tier: "live",
payload: "{}",
signature: "old-live-sig",
}),
setCache: (entry) => cacheStore.push(entry),
fetch: (() =>
Promise.resolve(
mockResponse(FIXTURE_BYTES, {
"x-omniroute-feed-signature": sig,
"x-omniroute-feed-tier": "community",
})
)) as unknown as typeof globalThis.fetch,
});
assert.equal(result.status, "updated");
assert.equal(cacheStore.length, 1);
assert.equal(cacheStore[0]!.tier, "community");
assert.equal(cacheStore[0]!.version, "2026.08.01.1");
});
test("syncRadar: version floor — incoming newer => updated", async () => {
// Modify fixture to have a newer version
const fixtureObj = JSON.parse(FIXTURE_STRING);

View File

@@ -755,11 +755,13 @@ describe("Reasoning Replay Cache — Translator Replay", () => {
assert.equal(translated.messages[1].reasoning_content, undefined);
});
it("should replace empty-string reasoning_content with NON_ANTHROPIC_THINKING_PLACEHOLDER on cache miss", async () => {
it("should drop empty-string reasoning_content on cache miss", async () => {
// Regression: injectEmptyReasoningContentForToolCalls (schemaCoercion.ts) pre-sets
// reasoning_content="" before the cache lookup. The old condition
// `msg.reasoning_content === undefined` never fired on cache miss, leaving the
// empty string in place. DeepSeek V4+ rejects "" with a 400.
// reasoning_content="" before the cache lookup, and DeepSeek V4+ rejects "" with a
// 400 — so the empty string must not survive the miss. #9573/#9610 replaced the
// former NON_ANTHROPIC_THINKING_PLACEHOLDER injection with omitting the field: the
// placeholder was echoed back by the model as its own reasoning (empty stop) and
// re-poisoned cache + client history, while an ABSENT field is accepted.
clearReasoningCacheAll();
clearModelsDevCapabilities();
saveModelsDevCapabilities({
@@ -772,9 +774,6 @@ describe("Reasoning Replay Cache — Translator Replay", () => {
},
});
const { NON_ANTHROPIC_THINKING_PLACEHOLDER } =
await import("../../open-sse/translator/helpers/claudeHelper.ts");
// No cache entry → cache miss
const translated = translateRequest(
FORMATS.OPENAI,
@@ -805,16 +804,17 @@ describe("Reasoning Replay Cache — Translator Replay", () => {
assert.equal(
translated.messages[1].reasoning_content,
NON_ANTHROPIC_THINKING_PLACEHOLDER,
"empty reasoning_content should be replaced with placeholder on cache miss"
undefined,
"empty reasoning_content should be dropped (not placeholder-filled) on cache miss"
);
});
it("should inject placeholder for a plain (non-tool-call) DeepSeek turn missing reasoning_content (#1682)", async () => {
it("should omit reasoning_content for a plain (non-tool-call) DeepSeek turn missing it (#1682)", async () => {
// Regression (#1682): a multi-turn text conversation where the prior assistant
// turn has NO tool calls and the client (e.g. Cursor) stripped reasoning_content
// from history. DeepSeek V4+ still requires reasoning_content on every assistant
// message in thinking mode, so without a placeholder the upstream returns 400.
// from history. #9573/#9610 established that DeepSeek's 400 is specific to an
// EMPTY-STRING reasoning_content, not an absent field — so the field is now
// omitted here instead of carrying the self-poisoning placeholder.
clearReasoningCacheAll();
clearModelsDevCapabilities();
saveModelsDevCapabilities({
@@ -827,9 +827,6 @@ describe("Reasoning Replay Cache — Translator Replay", () => {
},
});
const { NON_ANTHROPIC_THINKING_PLACEHOLDER } =
await import("../../open-sse/translator/helpers/claudeHelper.ts");
const translated = translateRequest(
FORMATS.OPENAI,
FORMATS.OPENAI,
@@ -849,8 +846,8 @@ describe("Reasoning Replay Cache — Translator Replay", () => {
assert.equal(
translated.messages[1].reasoning_content,
NON_ANTHROPIC_THINKING_PLACEHOLDER,
"plain DeepSeek assistant turn missing reasoning_content should get the placeholder"
undefined,
"plain DeepSeek assistant turn missing reasoning_content should keep the field absent"
);
});

View File

@@ -1,8 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
handleComboChat,
} from "../../open-sse/services/combo.ts";
import { handleComboChat } from "../../open-sse/services/combo.ts";
import { getCircuitBreaker, STATE } from "../../src/shared/utils/circuitBreaker.js";
function okResponse() {
@@ -27,14 +25,18 @@ test("#9630: combo returns 503 when circuit breaker is OPEN but other healthy ta
strategy: "priority",
models: ["openai/gpt-4", "anthropic/claude-opus-5"],
},
handleSingleModel: async (_body: any, modelStr: string) => {
assert.equal(modelStr, "anthropic/claude-opus-5", "should skip openai breaker and try anthropic");
handleSingleModel: async (_body, modelStr) => {
assert.equal(
modelStr,
"anthropic/claude-opus-5",
"should skip openai breaker and try anthropic"
);
return okResponse();
},
isModelAvailable: async () => true,
log: { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} } as any,
log: { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} },
settings: null,
relayOptions: null as any,
relayOptions: null,
allCombos: null,
});
@@ -63,17 +65,22 @@ test("#9630: combo returns truthful error, not false ALL_ACCOUNTS_INACTIVE, when
strategy: "priority",
models: ["openai/gpt-4", "anthropic/claude-opus-5"],
},
handleSingleModel: async () => { throw new Error("should not be called"); },
handleSingleModel: async () => {
throw new Error("should not be called");
},
isModelAvailable: async () => true,
log: { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} } as any,
log: { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} },
settings: null,
relayOptions: null as any,
relayOptions: null,
allCombos: null,
});
assert.equal(result.status, 503);
const body = await result.json();
// The diagnostic should NOT claim ALL_ACCOUNTS_INACTIVE when no real dispatch was attempted
assert.notEqual(body.error?.code, "ALL_ACCOUNTS_INACTIVE",
"should not claim ALL_ACCOUNTS_INACTIVE when all targets were gated by pre-dispatch checks");
assert.notEqual(
body.error?.code,
"ALL_ACCOUNTS_INACTIVE",
"should not claim ALL_ACCOUNTS_INACTIVE when all targets were gated by pre-dispatch checks"
);
});

View File

@@ -89,17 +89,15 @@ test("#9293 hidden OpenRouter specialty models are excluded from /v1/models cata
new Request("http://localhost/v1/models")
);
assert.equal(response.status, 200);
const body = (await response.json()) as any;
const body = (await response.json()) as { data: Array<{ id: string; type?: string }> };
assert.ok(Array.isArray(body.data), "response has data array");
// Find audio and image models
const audioModels = body.data.filter((m: any) => m.type === "audio");
const imageModels = body.data.filter((m: any) => m.type === "image");
const audioModels = body.data.filter((m) => m.type === "audio");
const imageModels = body.data.filter((m) => m.type === "image");
// chirp-3 model ID from the audio registry is openrouter/google/chirp-3
const hiddenAudio = audioModels.find((m: any) =>
String(m.id).endsWith("google/chirp-3")
);
const hiddenAudio = audioModels.find((m) => String(m.id).endsWith("google/chirp-3"));
assert.equal(
hiddenAudio,
undefined,
@@ -107,7 +105,7 @@ test("#9293 hidden OpenRouter specialty models are excluded from /v1/models cata
);
// flux.2-pro model ID from the image registry is openrouter/black-forest-labs/flux.2-pro
const hiddenImage = imageModels.find((m: any) =>
const hiddenImage = imageModels.find((m) =>
String(m.id).endsWith("black-forest-labs/flux.2-pro")
);
assert.equal(
@@ -118,11 +116,6 @@ test("#9293 hidden OpenRouter specialty models are excluded from /v1/models cata
// Verify non-hidden audio models from OpenRouter still appear
// deepgram/nova-3 is not hidden, so it should be present
const visibleAudio = audioModels.find((m: any) =>
String(m.id).endsWith("deepgram/nova-3")
);
assert.ok(
visibleAudio,
"non-hidden audio model deepgram/nova-3 should still appear in catalog"
);
});
const visibleAudio = audioModels.find((m) => String(m.id).endsWith("deepgram/nova-3"));
assert.ok(visibleAudio, "non-hidden audio model deepgram/nova-3 should still appear in catalog");
});

View File

@@ -148,7 +148,7 @@ test("T24: all inactive accounts return 503 service_unavailable (not 406)", asyn
assert.equal(result.status, 503);
const body = (await result.json()) as any;
assert.equal(body.error?.code, "ALL_ACCOUNTS_INACTIVE");
assert.equal(body.error?.code, "ALL_TARGETS_SKIPPED");
});
test("combo falls through 400s and reaches the next model", async () => {

View File

@@ -9,9 +9,6 @@ const {
injectEmptyReasoningContentForToolCalls,
} = await import("../../open-sse/translator/helpers/schemaCoercion.ts");
const { translateRequest } = await import("../../open-sse/translator/index.ts");
const { NON_ANTHROPIC_THINKING_PLACEHOLDER } = await import(
"../../open-sse/translator/helpers/claudeHelper.ts"
);
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
const { clearModelsDevCapabilities, saveModelsDevCapabilities } =
await import("../../src/lib/modelsDevSync.ts");
@@ -198,7 +195,7 @@ test("tool sanitization: injects empty reasoning_content only for DeepSeek tool-
assert.equal(openaiMessages[1].reasoning_content, undefined);
});
test("translateRequest injects reasoning_content for DeepSeek assistant tool calls", () => {
test("translateRequest omits reasoning_content for DeepSeek assistant tool calls on cache miss", () => {
clearModelsDevCapabilities();
saveModelsDevCapabilities({
deepseek: {
@@ -231,6 +228,10 @@ test("translateRequest injects reasoning_content for DeepSeek assistant tool cal
"deepseek"
);
assert.equal(translated.messages[1].reasoning_content, NON_ANTHROPIC_THINKING_PLACEHOLDER);
// #9573/#9610: the former NON_ANTHROPIC_THINKING_PLACEHOLDER injection was the root
// cause of the echo → empty-stop bug (the model continued its chain of thought from
// the placeholder and re-poisoned cache + history). On a cache miss the field is now
// omitted; DeepSeek's 400 is specific to an empty string, not an absent field.
assert.equal(translated.messages[1].reasoning_content, undefined);
clearModelsDevCapabilities();
});

View File

@@ -42,3 +42,53 @@ test("translateRequest replays reasoning_content on plain xiaomi-mimo assistant
"plain xiaomi-mimo assistant turn must carry a non-empty reasoning_content"
);
});
// Scope guard for the #9573/#9610 <-> 9router#1321 conflict. #9610 removed the
// placeholder injection globally on the strength of ONE provider's behavior
// (deepseek-v4-flash was verified to accept an absent reasoning_content), which
// silently re-broke MiMo. The placeholder is now provider-scoped, so both halves
// need pinning: widening the scope back to DeepSeek re-opens #9573, narrowing it
// away from MiMo re-opens 9router#1321.
test("the reasoning_content placeholder stays scoped: MiMo keeps it, DeepSeek does not (#9573 vs 9router#1321)", () => {
const plainHistory = () => ({
messages: [
{ role: "user", content: "hi" },
// Plain assistant turn whose reasoning_content the client stripped.
{ role: "assistant", content: "Hello! How can I help?" },
{ role: "user", content: "continue" },
],
});
const mimo = translateRequest(
FORMATS.OPENAI,
FORMATS.OPENAI,
"mimo-v2.5-pro",
plainHistory(),
true,
null,
"xiaomi-mimo"
);
const mimoAssistant = mimo.messages.find((m) => m.role === "assistant");
assert.equal(
typeof mimoAssistant.reasoning_content === "string" &&
mimoAssistant.reasoning_content.length > 0,
true,
"MiMo 400s on an absent reasoning_content — the placeholder must survive the cache miss"
);
const deepseek = translateRequest(
FORMATS.OPENAI,
FORMATS.OPENAI,
"deepseek-v4-flash",
plainHistory(),
true,
null,
"deepseek"
);
const deepseekAssistant = deepseek.messages.find((m) => m.role === "assistant");
assert.equal(
deepseekAssistant.reasoning_content,
undefined,
"DeepSeek accepts an absent field; sending the placeholder there is the #9573 echo bug"
);
});

View File

@@ -128,9 +128,9 @@ test("vscode raw models route exposes native GPT-5.6 IDs and effort tiers", asyn
assert.equal(typeof defaultModel.created, "number");
assert.equal(defaultModel.owned_by, "codex");
assert.equal(defaultModel.name, "Codex GPT 5.6 Sol");
assert.equal(defaultModel.context_length, 272000);
assert.equal(defaultModel.context_length, 1050000);
assert.equal(defaultModel.max_output_tokens, 128000);
assert.equal(defaultModel.max_input_tokens, 272000);
assert.equal(defaultModel.max_input_tokens, 922000);
assert.deepEqual(defaultModel.capabilities, {
vision: true,
tool_calling: true,

View File

@@ -255,7 +255,7 @@ test("vscode combos route resolves combo names through Ollama api/show", async (
assert.equal(body.model, "show-combo");
assert.equal(body.modelfile, "FROM show-combo");
assert.equal(body.details.family, "show-combo");
assert.equal(body.model_info.context_length, 272000);
assert.equal(body.model_info.context_length, 1050000);
assert.deepEqual(body.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]);
assert.equal(body.model_info.capabilities.reasoning, true);
});
@@ -290,7 +290,7 @@ test("vscode tokenized combos root route exposes importable combo metadata", asy
assert.equal(response.status, 200);
assert.ok(combo, "expected balanced-load in combo root response");
assert.equal(combo.url.includes("/responses#models.ai.azure.com"), true);
assert.equal(combo.maxInputTokens, 272000);
assert.equal(combo.maxInputTokens, 922000);
assert.equal(combo.toolCalling, true);
assert.deepEqual(combo.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]);
});
@@ -767,9 +767,7 @@ test("vscode tokenized tags route only exposes usable canonical chat models", as
);
assert.ok(
!catalogModel.api_format ||
["chat-completions", "responses", "openai-responses"].includes(
catalogModel.api_format
),
["chat-completions", "responses", "openai-responses"].includes(catalogModel.api_format),
`tag ${tagModel.name} should use a text-generation API format`
);
assert.ok(
@@ -1075,7 +1073,7 @@ test("vscode tokenized api/show route exposes explicit reasoning effort metadata
assert.equal(body.configurationSchema?.properties?.reasoningEffort?.default, "low");
assert.equal(body.model_info["general.basename"], "Codex GPT 5.6 Sol (Default)");
assert.equal(body.model_info["general.architecture"], "codex");
assert.equal(body.model_info["codex.context_length"], 272000);
assert.equal(body.model_info["codex.context_length"], 1050000);
assert.deepEqual(body.model_info.supports_reasoning_effort, [
"low",
"medium",