mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-09 00:32:13 +03:00
Compare commits
4 Commits
feat/9544-
...
docs/radar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f379e9215a | ||
|
|
4f21e663f0 | ||
|
|
f6708c78fb | ||
|
|
36abd86929 |
1
changelog.d/fixes/9776-radar-entitlement-refresh.md
Normal file
1
changelog.d/fixes/9776-radar-entitlement-refresh.md
Normal 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
|
||||
1
changelog.d/maintenance/9738-deadcode-radar-referrals.md
Normal file
1
changelog.d/maintenance/9738-deadcode-radar-referrals.md
Normal 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.
|
||||
@@ -96,6 +96,7 @@
|
||||
"node-machine-id",
|
||||
"omniglyph",
|
||||
"open",
|
||||
"opencode-ai",
|
||||
"ora",
|
||||
"parse5",
|
||||
"pino",
|
||||
|
||||
@@ -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 HyperAgent’s 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 PR’s 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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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/<provider>/<model> để 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/<provider>/<model> để 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",
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
26
src/lib/radar/setupConnections.ts
Normal file
26
src/lib/radar/setupConnections.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
5
tests/fixtures/radar-feed-canonical.json
vendored
5
tests/fixtures/radar-feed-canonical.json
vendored
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
45
tests/unit/radar-localized-feed.test.ts
Normal file
45
tests/unit/radar-localized-feed.test.ts
Normal 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.");
|
||||
});
|
||||
@@ -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" });
|
||||
|
||||
28
tests/unit/radar-setup-connections.test.ts
Normal file
28
tests/unit/radar-setup-connections.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user