Compare commits

...

18 Commits

Author SHA1 Message Date
diegosouzapw
1d1b580c2d fix(vertex): route Claude models to native rawPredict and respect targetFormat overrides (#8994)
Root cause: three interconnected issues caused Vertex partner Claude models
to fail with 'Expected input to contain field: messages':
1. resolveChatCoreTargetFormat short-circuited on apiFormat='responses'
   before consulting model-level targetFormat overrides
2. resolveModelOrError had its own ad-hoc resolution that never consulted
   the custom-model targetFormat override
3. Vertex executor routed all partner models (including Claude) to the
   generic OpenAI-compatible endpoint instead of Anthropic's rawPredict

Fix:
- targetFormat.ts: model-level overrides now take precedence over apiFormat
- chatHelpers.ts: use resolveChatCoreTargetFormat instead of inline logic
- chat.ts: forward resolved modelTargetFormat through modelInfo
- vertex.ts: route Claude models to rawPredict with Anthropic-format body,
  synthesize real SSE events from rawPredict's JSON response for streaming
- Tests: new #8994 repro test + updated URL assertions

Co-authored-by: Will Gordon <wgordon@redhat.com>
2026-08-07 10:57:02 -03:00
Diego Rodrigues de Sa e Souza
1e15583f29 fix(radar): close audit gaps (auth, feed fields, opt-in state, sidebar gate, size cap) + daily sync scheduler (#9686)
* fix(radar): preserve extended feed fields and honor local enable override

applyFeed()'s MergedEntry shape omitted contextWindow/capabilities/limits/
setup even though FeedModel always carries them, so the dashboard's setup
link, Context column, and capability badges never rendered and the setup
page's provider lookup always failed. Both merge paths (mergeOne and
feedModelToMerged) now copy the four fields through, respecting rule 1
(local override wins) same as every other field.

feedModelToMerged() also unconditionally forced enabled:false when the feed
disabled a feed-only entry, even when the operator had locally overridden
enabled:true — mergeOne() already applies overrides after the disable rule
and got this right. feedModelToMerged() now only force-disables when there
is no local `enabled` override, matching mergeOne()'s semantics.

* fix(radar): cap feed sync response body at 10MB

syncRadar() buffered the entire feed response via
Buffer.from(await res.arrayBuffer()) with no size limit, so a
misconfigured or hostile RADAR_FEED_URL (or an upstream serving garbage)
could force an unbounded in-memory buffer. Enforcement is two-layered: a
Content-Length preflight skips reading an already-oversized body entirely,
and a running-total check while reading the stream enforces the cap even
when Content-Length is absent or understates the real size — concatenating
the accumulated chunks preserves the exact bytes the signature check needs.

Exceeding the cap returns a new { status: "too_large" } SyncStatus and
leaves the cache untouched, following the same non-destructive pattern as
every other sync failure (invalid_signature/invalid_schema/stale).

* fix(radar): gate the sidebar radar item behind RADAR_ENABLED

The "radar" sidebar item was registered unconditionally in
sidebarVisibility/sections.ts, but Sidebar.tsx has no feature-flag
awareness (it's a client component), so the link stayed visible and
clickable with RADAR_ENABLED off, landing on a 404 dashboard page.

Sidebar items gain an opt-in `featureFlagKey` field plus a pure
isSidebarItemVisibleForFlags() filter (fails open when a flag isn't in the
map, so a missing/not-yet-loaded key never hides an unrelated item). The
resolved flag value piggy-backs on the /api/settings response the sidebar
already fetches on mount (new `radarEnabled` field) rather than adding a
dedicated round trip.

* fix(radar): require auth on management routes, add GET settings

GET /api/radar/catalog, POST /api/radar/sync, and POST /api/radar/settings
had zero authentication — any client that could reach the local server
could read the merged catalog, trigger a sync, or flip the opt-in/set the
supporter key. All three (plus the new GET below) now call
isAuthenticated() from the shared apiAuth guard, same gate as the rest of
/api/settings/*. The RADAR_ENABLED flag-off 404 check keeps running FIRST
so flag-off inertia stays byte-identical (no auth prompt just to learn the
surface doesn't exist); auth runs after it, before any DB access.

Adds GET /api/radar/settings, returning { optIn, hasSupporterKey,
supporterKeyMasked } — the raw key never leaves the server on either verb.
The dashboard page's fetchSettings() now calls this endpoint instead of
inferring opt-in state from the catalog response (which always defaulted
to unknown/null), so an already-activated operator no longer sees the
activation screen on every reload. handleSync() also handles the new
too_large sync status introduced by the response-cap fix, reusing the
existing generic sync-failed copy (no new UI strings).

* docs(radar): fix stale feed URL, document tier header/auth/size cap

- RADAR_FEED_URL default was documented as radar.omniroute.dev in
  ENVIRONMENT.md; the actual default (src/lib/radar/sync.ts) and every
  other reference use radar.omniroute.online — fix the one stale spot.
- Correct the FREE_MODEL_BUDGETS source path: it's declared in
  freeModelCatalog.data.ts, not freeModelCatalog.ts (which only
  re-exports it).
- Document that the signed feed body's `tier` is always "live" (one
  signed artifact per version) and the actually-served tier comes from
  the `x-omniroute-feed-tier` response header, resolved with a Zod parse
  + fallback to the body field.
- Document that all four /api/radar/* routes now require auth
  (isAuthenticated(), same gate as /api/settings/*), the new
  GET /api/radar/settings route, and the new too_large sync status from
  the 10MB response cap.

* feat(radar): daily sync scheduler + auto-sync on page open

Spec asks for a 1x/day sync while opted in and fresh data on every page
open. The scheduler only arms itself when RADAR_ENABLED AND the opt-in are
already on (boot) or right after the user opts in (settings route) — a
flag-off install never creates the timer, preserving the inertia contract.
The page auto-syncs once per mount when the cached feed is older than 6h.

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-07 08:26:18 -03:00
Diego Rodrigues de Sa e Souza
9995bc4893 fix(security): anchor hostname comparison in Adobe Firefly login (#778)
Parse and compare hostname with dot-anchored endsWith instead of substring includes. Closes code-scanning #778.
2026-08-06 23:12:54 -03:00
Diego Rodrigues de Sa e Souza
c9a3361e5a fix(translator): add case-insensitive fallback for upstream tool call name lookups (#9575)
Closes #9575
2026-08-06 22:58:51 -03:00
Diego Rodrigues de Sa e Souza
c9debe92bd fix(translator): restore original tool name casing in Gemini response translators (#9568)
Closes #9568
2026-08-06 22:55:50 -03:00
Diego Rodrigues de Sa e Souza
fad3539a69 fix(sse): replace timer-based waits with polling to fix flaky chatCore/SSE tests under CI load (#9567)
Closes #9567
2026-08-06 22:55:44 -03:00
Diego Rodrigues de Sa e Souza
919f9acd80 fix(build): lazy-resolve module-level fs paths to avoid Turbopack NFT whole-source trace (#9560)
Closes #9560
2026-08-06 22:55:38 -03:00
Diego Rodrigues de Sa e Souza
8a573c56e3 fix(proxy): NO_PROXY now bypasses context-level proxy in resolveProxyForRequest (#9551)
Closes #9551
2026-08-06 22:55:33 -03:00
Diego Rodrigues de Sa e Souza
f338363cd3 fix(model): add aq alias for amazon-q provider so parseModel resolves it instead of falling back to OpenAI (#9550)
Closes #9550
2026-08-06 22:55:27 -03:00
Diego Rodrigues de Sa e Souza
28dc5af7ba fix(providers): strip provider prefix in getModelTargetFormat to route GPT-5.6 models to /v1/responses (#9545)
Closes #9545
2026-08-06 22:55:21 -03:00
Diego Rodrigues de Sa e Souza
616175a93e fix(search): mark searxng-search as fallbackOnly to prevent auto-select without instance (#9543)
Closes #9543
2026-08-06 22:55:16 -03:00
Diego Rodrigues de Sa e Souza
e4e0c254ea fix(db): add transient-error retry to corruption probe to prevent data loss under concurrent load (#9541)
Closes #9541
2026-08-06 22:55:10 -03:00
Diego Rodrigues de Sa e Souza
2e9abab944 fix(backend): map cache tokens in OpenAI-to-Claude non-streaming usage translation (#9536)
Closes #9536
2026-08-06 22:55:05 -03:00
Diego Rodrigues de Sa e Souza
4cc9cf8123 fix(test): prevent flaky modelsDevSync timer assertions by serializing test execution within the file (#9534)
Closes #9534
2026-08-06 22:54:59 -03:00
Diego Rodrigues de Sa e Souza
6c3aea6ba6 fix(ci): include combo-matrix tests in test-integration job (#9531)
Closes #9531
2026-08-06 22:54:53 -03:00
Diego Rodrigues de Sa e Souza
9edefd4572 fix(oauth): Kiro import token endpoint no longer overwrites existing connection when using shared cached OIDC clientId (#9435)
Closes #9435
2026-08-06 22:54:48 -03:00
Diego Rodrigues de Sa e Souza
8fbd331567 fix(compression): drop orphan custom_tool_call/local_shell_call/apply_patch_call on compaction restore (#8946)
Closes #8946
2026-08-06 22:54:42 -03:00
diegosouzapw
535c75b60a fix(security): parse and compare hostname instead of substring match in Adobe Firefly login
Replace request.url.includes(FIREFLY_3P_HOST_SUFFIX) with parsed-hostname
comparison (anchored endsWith), closing CodeQL alert #778.

The old substring check could be bypassed by an attacker-controlled page
visited during the browser login window — a URL like
'https://evil.com/firefly-3p.ff.adobe.io' would pass the gate and its
Bearer token would be captured as the Adobe credential.

Practical severity is low (only during operator-initiated, time-boxed
login on a temp-profile browser), but the fix is one line and matches
the dot-anchored idiom used in docker/devin-bridge/network-guard/.

Closes code-scanning #778.
2026-08-06 19:09:49 -03:00
89 changed files with 3239 additions and 631 deletions

View File

@@ -1213,8 +1213,10 @@ jobs:
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm run check:node-runtime
# (tsx/esm = QW-b; o alinhamento de ESCOPO do integration com o npm script fica p/ follow-up)
- run: node --import tsx/esm --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/integration/*.test.ts
- name: Integration tests (shard ${{ matrix.shard }}/2)
env:
TEST_SHARD: ${{ matrix.shard }}/2
run: npm run test:integration:ci
test-security:
name: Security Tests

View File

@@ -0,0 +1 @@
- fix(compression): drop orphan custom_tool_call/local_shell_call/apply_patch_call on compaction restore (#8946)

View File

@@ -0,0 +1 @@
- fix(vertex): route Claude models to native rawPredict endpoint and respect custom targetFormat overrides (#8994)

View File

@@ -0,0 +1 @@
- fix(oauth): Kiro import token endpoint no longer overwrites existing connection when using shared cached OIDC clientId (#9435)

View File

@@ -0,0 +1 @@
- fix(ci): include combo-matrix tests in test-integration job (#9531)

View File

@@ -0,0 +1 @@
- fix(test): prevent flaky modelsDevSync timer assertions by serializing test execution within the file (#9534)

View File

@@ -0,0 +1 @@
- fix(backend): map cache tokens in OpenAI-to-Claude non-streaming usage translation (#9536)

View File

@@ -0,0 +1 @@
- fix(db): add transient-error retry to corruption probe to prevent data loss under concurrent load (#9541)

View File

@@ -0,0 +1 @@
- fix(search): mark searxng-search as fallbackOnly to prevent auto-select without instance (#9543)

View File

@@ -0,0 +1 @@
- fix(providers): strip provider prefix in getModelTargetFormat to route GPT-5.6 models to /v1/responses (#9545)

View File

@@ -0,0 +1 @@
- fix(model): add "aq" alias for amazon-q provider so parseModel resolves it instead of falling back to OpenAI (#9550)

View File

@@ -0,0 +1 @@
- fix(proxy): NO_PROXY now bypasses context-level proxy in resolveProxyForRequest (#9551)

View File

@@ -0,0 +1 @@
- fix(build): lazy-resolve module-level fs paths to avoid Turbopack NFT whole-source trace (#9560)

View File

@@ -0,0 +1 @@
- fix(sse): replace timer-based waits with polling to fix flaky chatCore/SSE tests under CI load (#9567)

View File

@@ -0,0 +1 @@
- **fix(translator):** restore original tool name casing in Gemini/Antigravity response translators ([#9568](https://github.com/diegosouzapw/OmniRoute/issues/9568))

View File

@@ -0,0 +1 @@
- fix(translator): add case-insensitive fallback for upstream tool call name lookups (#9575)

View File

@@ -1,17 +1,17 @@
---
title: "Radar Free-Model Catalog"
version: 3.8.50
lastUpdated: 2026-08-05
lastUpdated: 2026-08-07
---
# Radar Free-Model Catalog
> **Source of truth:** `src/lib/radar/`, `src/lib/db/radar.ts`, `src/app/api/radar/`
> **Last updated:** 2026-08-05 — v3.8.50
> **Last updated:** 2026-08-07 — 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
`open-sse/config/freeModelCatalog.ts`). It exists because the free-tier landscape moves
`open-sse/config/freeModelCatalog.data.ts`). It exists because the free-tier landscape moves
faster than release cadence — providers add, shrink, or discontinue free quotas between
releases, and the baseline catalog can only be refreshed when a new version ships.
@@ -127,6 +127,24 @@ untouched. The cached payload is defensively re-validated again on every read
(`getRadarCatalog()`) — a corrupted or hand-edited cache row falls back to the
baseline rather than being served.
### Response size cap (10 MB)
`syncRadar()` enforces a **10 MB hard cap** on the feed response body — the signed
feed is a KB-scale JSON document, so anything past this points at a misconfigured or
hostile `RADAR_FEED_URL` (or an upstream serving garbage), not a legitimate catalog.
Enforcement is two-layered:
1. A `Content-Length` preflight check skips reading the body entirely when the
header already declares a value over the cap.
2. A running-total check while reading the body enforces the cap even when
`Content-Length` is absent or understates the real size — the header is never
trusted on its own. Concatenating the accumulated chunks preserves the exact
bytes needed for the Ed25519 signature check afterward.
Exceeding the cap returns `{ status: "too_large" }` and leaves the cache untouched,
following the same non-destructive pattern as every other sync failure
(`invalid_signature`, `invalid_schema`, `stale`).
---
## Tiers: `community` and `live`
@@ -146,6 +164,28 @@ recoverable, all non-fatal to the cached state) from a successful `{ status:
"updated", version, tier }`. There is no tier-specific error path a client needs to
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.
`syncRadar()` (`src/lib/radar/sync.ts::parseServedTierHeader()`) is the single place
that resolves the tier a client should trust:
1. Parse `x-omniroute-feed-tier` with `RadarTierSchema` (Zod) — an absent header, or
a value that isn't exactly `"community"` or `"live"`, is treated as **not
present** (never trusted into the cache/UI as-is; this also covers older feed
servers that predate the header).
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
field.
---
## Read-time overlay merge rules
@@ -183,13 +223,14 @@ Every merged entry carries an `origin` field the UI renders as a badge:
## Local surfaces — never a feed proxy
Three local routes back the UI, all under `src/app/api/radar/`:
Four 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` | POST | Sets opt-in and/or the (encrypted) supporter key. |
| 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. |
**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
@@ -197,11 +238,22 @@ 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
out of client-facing network traffic entirely.
All three routes return `404` when `RADAR_ENABLED` is off (see
All four routes return `404` when `RADAR_ENABLED` is off (see
[Flag](#flag-radar_enabled-default-off) above), and route error responses through
`buildErrorBody()`/`sanitizeErrorMessage()` per the repo-wide error-sanitization rule
(`docs/security/ERROR_SANITIZATION.md`).
### Authentication
All four routes require authentication via `isAuthenticated()`
(`src/shared/utils/apiAuth.ts`) — a dashboard session cookie or a management-scoped
API key, the same gate that protects the rest of `/api/settings/*`. The flag-off
`404` check always runs **before** the auth check, so an install with `RADAR_ENABLED`
off stays byte-identical (no auth prompt just to learn the surface doesn't exist);
once the flag is on, an unauthenticated request gets `401` before any DB read or
write. `GET /api/radar/settings` never returns the raw supporter key regardless of
auth state — only the masked form and a `hasSupporterKey` boolean.
---
## How to self-host a feed

View File

@@ -1281,7 +1281,7 @@ self-hosted or forked feed instead of the default OmniRoute Radar feed. See
| Variable | Default | Source File | Description |
| -------------------- | ------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------ |
| `RADAR_FEED_URL` | `https://radar.omniroute.dev` | `src/lib/radar/sync.ts` | Base URL of the Radar feed service. Override to point at a self-hosted or forked feed. |
| `RADAR_FEED_URL` | `https://radar.omniroute.online` | `src/lib/radar/sync.ts` | Base URL of the Radar feed service. Override to point at a self-hosted or forked feed. |
| `RADAR_FEED_PUBKEY` | _(pinned default key)_ | `src/lib/radar/pinnedKeys.ts` | Ed25519 public key (base64-DER SPKI or PEM) used to verify feed signatures from a custom feed. |
---

View File

@@ -12,27 +12,27 @@ export const PROVIDER_MODELS: Record<string, RegistryModel[]> = new Proxy(
{} as Record<string, RegistryModel[]>,
{
get(_, prop) {
if (typeof prop === 'symbol') return undefined;
if (typeof prop === "symbol") return undefined;
return Reflect.get(initModels(), prop, _models);
},
has(_, prop) {
if (typeof prop === 'symbol') return false;
if (typeof prop === "symbol") return false;
return Reflect.has(initModels(), prop);
},
ownKeys() {
return Reflect.ownKeys(initModels());
},
getOwnPropertyDescriptor(_, prop) {
if (typeof prop === 'symbol') return undefined;
if (typeof prop === "symbol") return undefined;
return Object.getOwnPropertyDescriptor(initModels(), prop);
},
set(_, prop, value) {
if (typeof prop === 'symbol') return false;
if (typeof prop === "symbol") return false;
(initModels() as Record<string, RegistryModel[]>)[prop] = value;
return true;
},
deleteProperty(_, prop) {
if (typeof prop === 'symbol') return false;
if (typeof prop === "symbol") return false;
return Reflect.deleteProperty(initModels(), prop);
},
}
@@ -41,27 +41,27 @@ export const PROVIDER_ID_TO_ALIAS: Record<string, string> = new Proxy(
{} as Record<string, string>,
{
get(_, prop) {
if (typeof prop === 'symbol') return undefined;
if (typeof prop === "symbol") return undefined;
return Reflect.get(initAliases(), prop, _aliases);
},
has(_, prop) {
if (typeof prop === 'symbol') return false;
if (typeof prop === "symbol") return false;
return Reflect.has(initAliases(), prop);
},
ownKeys() {
return Reflect.ownKeys(initAliases());
},
getOwnPropertyDescriptor(_, prop) {
if (typeof prop === 'symbol') return undefined;
if (typeof prop === "symbol") return undefined;
return Object.getOwnPropertyDescriptor(initAliases(), prop);
},
set(_, prop, value) {
if (typeof prop === 'symbol') return false;
if (typeof prop === "symbol") return false;
(initAliases() as Record<string, string>)[prop] = value;
return true;
},
deleteProperty(_, prop) {
if (typeof prop === 'symbol') return false;
if (typeof prop === "symbol") return false;
return Reflect.deleteProperty(initAliases(), prop);
},
}
@@ -116,7 +116,13 @@ export function findModelName(aliasOrId: string, modelId: string): string {
export function getModelTargetFormat(aliasOrId: string, modelId: string): string | null {
const models = PROVIDER_MODELS[aliasOrId];
const found = models?.find((m) => m.id === modelId);
// Strip provider prefix if present: "openai/gpt-5.6-luna" → "gpt-5.6-luna"
const prefix = aliasOrId + "/";
const bareModelId =
typeof modelId === "string" && modelId.startsWith(prefix)
? modelId.slice(prefix.length)
: modelId;
const found = models?.find((m) => m.id === bareModelId);
if (found?.targetFormat) return found.targetFormat;
// #5842: OpenAI "*-pro" reasoning models (o1-pro, gpt-5.x-pro) are only served by
// the native /v1/responses endpoint — /v1/chat/completions 404s ("only supported
@@ -124,7 +130,7 @@ export function getModelTargetFormat(aliasOrId: string, modelId: string): string
// covers dynamically-synced ids that post-date the catalog (same spirit as the gh
// executor's /codex/i routing, 9router#102). Scoped to the openai alias so other
// providers shipping *-pro ids keep their own endpoint semantics.
if (aliasOrId === "openai" && /-pro$/i.test(modelId)) return "openai-responses";
if (aliasOrId === "openai" && /-pro$/i.test(bareModelId)) return "openai-responses";
return null;
}

View File

@@ -207,6 +207,7 @@ export const SEARCH_PROVIDERS: Record<string, SearchProviderConfig> = {
maxMaxResults: 50,
timeoutMs: 10_000,
cacheTTLMs: 3 * 60 * 1000,
fallbackOnly: true,
},
"ollama-search": {

View File

@@ -138,13 +138,149 @@ function isPartnerModel(model: string) {
return [...PARTNER_MODELS].some((prefix) => normalizedModel.startsWith(prefix));
}
// Anthropic models need their own branch: they use Vertex's native Anthropic Messages API
// (publishers/anthropic/.../rawPredict), not the generic OpenAI-compatible partner endpoint the
// other PARTNER_MODELS entries (DeepSeek, Qwen, Llama, Mistral, GLM) go through — the OpenAI-shaped
// endpoint 404s/"malformed argument"s for Claude models on at least some projects.
function isClaudeModel(model: string) {
return model.toLowerCase().startsWith("claude-");
}
// Defensive normalizer: target-format resolution for manually-added custom Claude models under
// "vertex"/"vertex-partner" was observed sending a Gemini-shaped body (contents/parts) to the
// Anthropic rawPredict endpoint instead of the configured "claude" format, causing a hard
// "messages: Field required" error upstream regardless of the stored per-model targetFormat. This
// converts a Gemini-shaped body to Anthropic Messages shape so the executor works either way,
// independent of that unresolved upstream resolution gap.
function toAnthropicBody(body: Record<string, unknown>): Record<string, unknown> {
const contents = body.contents as Array<{ role?: string; parts?: Array<{ text?: string }> }> | undefined;
if (!Array.isArray(contents)) return body;
const messages = contents.map((c) => ({
role: c.role === "model" ? "assistant" : "user",
content: (c.parts || []).map((p) => p.text || "").join(""),
}));
const generationConfig = body.generationConfig as { maxOutputTokens?: number } | undefined;
const systemInstruction = body.systemInstruction as { parts?: Array<{ text?: string }> } | undefined;
const converted: Record<string, unknown> = {
messages,
max_tokens: generationConfig?.maxOutputTokens || 4096,
};
if (systemInstruction?.parts?.length) {
converted.system = systemInstruction.parts.map((p) => p.text || "").join("");
}
return converted;
}
// rawPredict always returns a single complete JSON body, never real SSE framing (see buildUrl).
// When the caller actually requested a stream, synthesize a genuine Anthropic-native event
// sequence from that JSON so the existing claude-to-openai.ts (and sibling) response translators
// — which already parse real message_start/content_block_*/message_delta/message_stop events —
// can consume it correctly, instead of relying on the OpenAI-`choices`-only JSON→SSE fallback
// (open-sse/utils/jsonToSse.ts) which cannot represent Anthropic's native response shape at all.
function synthesizeClaudeSse(response: Record<string, unknown>): string {
const messageId = typeof response.id === "string" ? response.id : `msg_${Date.now()}`;
const model = typeof response.model === "string" ? response.model : "";
const usage = (response.usage as Record<string, unknown>) || {};
const stopReason = typeof response.stop_reason === "string" ? response.stop_reason : "end_turn";
const stopSequence = (response.stop_sequence as string | null | undefined) ?? null;
const content = Array.isArray(response.content) ? response.content : [];
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
events.push({
event: "message_start",
data: {
type: "message_start",
message: {
id: messageId,
type: "message",
role: "assistant",
content: [],
model,
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: usage.input_tokens || 0, output_tokens: 0 },
},
},
});
content.forEach((block: Record<string, unknown>, index: number) => {
if (block.type === "text") {
events.push({
event: "content_block_start",
data: { type: "content_block_start", index, content_block: { type: "text", text: "" } },
});
if (block.text) {
events.push({
event: "content_block_delta",
data: {
type: "content_block_delta",
index,
delta: { type: "text_delta", text: block.text },
},
});
}
events.push({ event: "content_block_stop", data: { type: "content_block_stop", index } });
} else if (block.type === "tool_use") {
events.push({
event: "content_block_start",
data: {
type: "content_block_start",
index,
content_block: { type: "tool_use", id: block.id, name: block.name, input: {} },
},
});
events.push({
event: "content_block_delta",
data: {
type: "content_block_delta",
index,
delta: { type: "input_json_delta", partial_json: JSON.stringify(block.input ?? {}) },
},
});
events.push({ event: "content_block_stop", data: { type: "content_block_stop", index } });
} else if (block.type === "thinking") {
events.push({
event: "content_block_start",
data: { type: "content_block_start", index, content_block: { type: "thinking", thinking: "" } },
});
if (block.thinking) {
events.push({
event: "content_block_delta",
data: {
type: "content_block_delta",
index,
delta: { type: "thinking_delta", thinking: block.thinking },
},
});
}
events.push({ event: "content_block_stop", data: { type: "content_block_stop", index } });
}
});
events.push({
event: "message_delta",
data: {
type: "message_delta",
delta: { stop_reason: stopReason, stop_sequence: stopSequence },
usage: { output_tokens: usage.output_tokens || 0 },
},
});
events.push({ event: "message_stop", data: { type: "message_stop" } });
return events.map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`).join("");
}
export class VertexExecutor extends BaseExecutor {
constructor() {
super("vertex", PROVIDERS.vertex);
}
async execute(input: ExecuteInput) {
const { credentials, log } = input;
const { credentials, log, model, stream } = input;
// Defensive: trim stray surrounding whitespace from a pasted credential.
if (typeof credentials.apiKey === "string") {
credentials.apiKey = credentials.apiKey.trim();
@@ -160,7 +296,53 @@ export class VertexExecutor extends BaseExecutor {
throw err;
}
}
return super.execute(input);
if (isClaudeModel(model) && input.body && typeof input.body === "object") {
let body = input.body as Record<string, unknown>;
if (!Array.isArray(body.messages)) {
body = toAnthropicBody(body);
input.body = body;
}
// The rawPredict endpoint requires "anthropic_version" in the body (Vertex's substitute
// for the "anthropic-version" header used by Anthropic's direct API).
body.anthropic_version ??= "vertex-2023-10-16";
// Unlike Anthropic's direct API (which reads the model from the body), Vertex's
// rawPredict endpoint already encodes project/region/model in the URL and 400s with
// "model: Extra inputs are not permitted" if the translated request body still carries
// one (the openai→claude request translator copies the client's model field over).
delete body.model;
}
const result = await super.execute(input);
if (isClaudeModel(model) && stream) {
const response = result instanceof Response ? result : result?.response;
if (response?.ok) {
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json") && !contentType.includes("text/event-stream")) {
const jsonText = await response.text();
let newBody = jsonText;
let newContentType = contentType;
try {
newBody = synthesizeClaudeSse(JSON.parse(jsonText));
newContentType = "text/event-stream";
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log?.warn?.("VERTEX", `Failed to synthesize Claude SSE stream: ${message}`);
}
const newHeaders = new Headers(response.headers);
newHeaders.set("content-type", newContentType);
newHeaders.delete("content-length");
const newResponse = new Response(newBody, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
return result instanceof Response ? newResponse : { ...result, response: newResponse };
}
}
}
return result;
}
buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: any = null) {
@@ -189,6 +371,13 @@ export class VertexExecutor extends BaseExecutor {
}
}
if (isClaudeModel(model)) {
// streamRawPredict?alt=sse was verified to return a single plain JSON body (not real SSE
// framing) rather than actual chunked events, which breaks the SSE parser upstream
// ("stream ended before producing a non-ping SSE event"). rawPredict is confirmed reliable
// for both streaming and non-streaming requests; always use it here.
return `https://aiplatform.googleapis.com/v1/projects/${project}/locations/${region}/publishers/anthropic/models/${model}:rawPredict`;
}
if (isPartnerModel(model)) {
return `https://aiplatform.googleapis.com/v1/projects/${project}/locations/global/endpoints/openapi/chat/completions`;
}

View File

@@ -2306,10 +2306,24 @@ export async function handleChatCore({
const nativeClaudeToolNameMap = isClaudePassthrough
? buildClaudePassthroughToolNameMap(body)
: null;
const toolNameMap =
let toolNameMap: Map<string, string> | null =
translatedToolNameMap instanceof Map && translatedToolNameMap.size > 0
? translatedToolNameMap
: nativeClaudeToolNameMap;
// For providers whose _toolNameMap was extracted as requestToolIdentityMap
// before the Kiro merge block (Gemini/Antigravity), merge it into the
// response toolNameMap so the response translator can restore tool names
// from their lowercased form (#9568). Only merge string-valued entries
// (tool name aliases), not object-valued namespace identities (#7936).
if (!toolNameMap && requestToolIdentityMap instanceof Map && requestToolIdentityMap.size > 0) {
const hasStringValues = [...requestToolIdentityMap.values()].every(
(v: unknown) => typeof v === "string"
);
if (hasStringValues) {
toolNameMap = requestToolIdentityMap;
}
}
delete translatedBody._toolNameMap;
delete translatedBody._disableToolPrefix;

View File

@@ -46,13 +46,15 @@ export function resolveChatCoreTargetFormat(opts: {
sourceFormat === FORMATS.CLAUDE)
? sourceFormat
: undefined;
// #8994: model-level targetFormat overrides (from registry or custom-model DB override)
// take precedence over apiFormat="responses" — otherwise Vertex Claude models with
// targetFormat="claude" get wrongly routed to OpenAI Responses format.
let targetFormat =
apiFormat === "responses"
modelTargetFormat ||
customModelTargetFormat ||
(apiFormat === "responses"
? FORMATS.OPENAI_RESPONSES
: modelTargetFormat ||
customModelTargetFormat ||
inferredAgentRouterTargetFormat ||
getTargetFormat(provider, providerSpecificData);
: inferredAgentRouterTargetFormat || getTargetFormat(provider, providerSpecificData));
if (nativeXaiResponsesPassthrough) targetFormat = FORMATS.OPENAI_RESPONSES;
return { alias, targetFormat };
}

View File

@@ -6,7 +6,10 @@ import {
import { normalizeOpenAICompatibleFinishReasonString } from "../utils/finishReason.ts";
import { containsTextualToolCallMarker } from "../utils/textualToolCall.ts";
import { getAnyReasoningValue } from "../utils/reasoningFields.ts";
import { restoreOpenAIToolNames } from "../translator/helpers/toolCallHelper.ts";
import {
caseInsensitiveToolNameLookup,
restoreOpenAIToolNames,
} from "../translator/helpers/toolCallHelper.ts";
type JsonRecord = Record<string, unknown>;
@@ -206,7 +209,7 @@ export function translateNonStreamingResponse(
typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit || {});
const rawName = toString(itemObj.name);
// Strip Claude OAuth proxy_ prefix using toolNameMap
const resolvedName = toolNameMap?.get(rawName) ?? rawName;
const resolvedName = caseInsensitiveToolNameLookup(rawName, toolNameMap) ?? rawName;
toolCalls.push({
id: callId,
type: "function",
@@ -388,7 +391,8 @@ export function translateNonStreamingResponse(
if (partObj.functionCall) {
const fn = toRecord(partObj.functionCall);
const rawName = toString(fn.name);
const restoredName = toolNameMap?.get(rawName) ?? rawName;
const restoredName =
caseInsensitiveToolNameLookup(rawName, toolNameMap) ?? rawName;
const nativeId = toString(fn.id);
const toolCallId =
nativeId.length > 0
@@ -507,7 +511,7 @@ export function translateNonStreamingResponse(
thinkingContent += toString(blockObj.thinking);
} else if (blockObj.type === "tool_use") {
const rawName = toString(blockObj.name);
const strippedName = toolNameMap?.get(rawName) ?? rawName;
const strippedName = caseInsensitiveToolNameLookup(rawName, toolNameMap) ?? rawName;
toolCalls.push({
id: toString(blockObj.id, `call_${Date.now()}_${toolCalls.length}`),
type: "function",
@@ -687,6 +691,35 @@ function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonReco
if (stopReason === "tool_calls") stopReason = "tool_use";
const usageSrc = toRecord(openaiResponse.usage);
const promptTokens = toNumber(usageSrc.prompt_tokens, 0);
const outputTokens = toNumber(usageSrc.completion_tokens, 0);
// Extract cache tokens from prompt_tokens_details (mirrors the streaming
// translator in open-sse/translator/response/openai-to-claude.ts lines 119-148).
const promptDetails = toRecord(usageSrc.prompt_tokens_details);
const cachedTokens = toNumber(promptDetails.cached_tokens, 0);
const cacheCreationTokens = toNumber(promptDetails.cache_creation_tokens, 0);
// OpenAI's prompt_tokens includes all prompt-side tokens (cached + non-cached).
// Claude expects input_tokens to be only non-cached tokens, with cached tokens
// exposed separately as cache_read_input_tokens.
const inputTokens = promptTokens - cachedTokens - cacheCreationTokens;
const usage: JsonRecord = {
input_tokens: inputTokens,
output_tokens: outputTokens,
};
// Add cache_read_input_tokens if present
if (cachedTokens > 0) {
usage.cache_read_input_tokens = cachedTokens;
}
// Add cache_creation_input_tokens if present
if (cacheCreationTokens > 0) {
usage.cache_creation_input_tokens = cacheCreationTokens;
}
const claudeResponse: JsonRecord = {
id: toString(openaiResponse.id, `msg_${Date.now()}`),
type: "message",
@@ -695,10 +728,7 @@ function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonReco
content,
stop_reason: stopReason,
stop_sequence: null,
usage: {
input_tokens: toNumber(usageSrc.prompt_tokens, 0),
output_tokens: toNumber(usageSrc.completion_tokens, 0),
},
usage,
};
return claudeResponse;

View File

@@ -259,7 +259,14 @@ async function captureViaCdp(opts: {
if (capturedAccessToken) return;
const request = params.request as
{ url?: string; headers?: Record<string, string> } | undefined;
if (!request?.url || !request.url.includes(FIREFLY_3P_HOST_SUFFIX)) return;
if (!request?.url) return;
let host: string;
try {
host = new URL(request.url).hostname.toLowerCase();
} catch {
return;
}
if (host !== FIREFLY_3P_HOST_SUFFIX && !host.endsWith(`.${FIREFLY_3P_HOST_SUFFIX}`)) return;
const headers = request.headers || {};
const auth = headers.Authorization || headers.authorization || headers.AUTHORIZATION || "";
const token = extractAdobeBearerTokenFromAuthorization(auth);

View File

@@ -618,12 +618,6 @@ export type CompatFilterOptions = {
failOpen?: boolean;
};
const HARD_COMPAT_REASONS = new Set(["tools", "vision", "structured_output"]);
function hasHardCapabilityFailure(reasons: string[]): boolean {
return reasons.some((reason) => HARD_COMPAT_REASONS.has(reason));
}
/**
* Summarize a capability-filter exhaustion for a 400-class combo error (#8488).
* Returns null when the empty pool is not attributable to hard requirements.
@@ -727,7 +721,9 @@ export function filterTargetsByRequestCompatibility(
if (compatible.length === targets.length) return targets;
if (compatible.length === 0) {
const hardRejected = rejected.some((entry) => hasHardCapabilityFailure(entry.reasons));
const hardRejected = rejected.some((entry) =>
entry.reasons.some((r) => HARD_COMPAT_REASONS.has(r))
);
const failOpen = options?.failOpen === true;
log.debug?.(

View File

@@ -400,13 +400,24 @@ export function adaptBodyForCompression(
});
const cleanedInput = nextInput.filter((item) => {
if (!isRecord(item) || item.type !== "function_call") return true;
if (!isRecord(item)) return true;
const t = item.type;
if (
t !== "function_call" &&
t !== "custom_tool_call" &&
t !== "local_shell_call" &&
t !== "apply_patch_call"
) {
return true;
}
if (typeof item.call_id !== "string" || item.call_id.length === 0) return true;
const hadMappedOutput = mappings.some((mapping) => {
const original = mapping.item;
return (
(original.type === "function_call_output" ||
original.type === "custom_tool_call_output") &&
original.type === "custom_tool_call_output" ||
original.type === "local_shell_call_output" ||
original.type === "apply_patch_call_output") &&
original.call_id === item.call_id
);
});

View File

@@ -54,6 +54,10 @@ ALIAS_TO_PROVIDER_ID["xiaomi"] = "xiaomi-mimo";
ALIAS_TO_PROVIDER_ID["llamacpp"] = "llama-cpp";
// agy/ is the short alias for antigravity provider.
ALIAS_TO_PROVIDER_ID["agy"] = "antigravity";
// aq/ is the user-visible prefix for the Amazon Q (AWS Builder ID) provider.
// The canonical provider ID is "amazon-q". Register it so parseModel("aq/<model>")
// resolves provider = "amazon-q" instead of falling through to the identity fallback.
ALIAS_TO_PROVIDER_ID["aq"] = "amazon-q";
// Provider-scoped legacy model aliases. Used to normalize provider/model inputs
// and keep backward compatibility when upstream IDs change.

View File

@@ -96,6 +96,37 @@ export function normalizeOpenAIToolNames(body: unknown, maxLength: number): Tool
return aliases;
}
/**
* Case-insensitive fallback for tool name lookups from upstream responses.
*
* Many upstream providers/models return tool call names in lowercase (e.g., "bash")
* even when the tool definition used PascalCase ("Bash"). This helper tries an exact
* match first (fast path for well-behaved providers), then falls back to a
* case-insensitive scan over the map entries.
*
* Returns the mapped value on match, or `undefined` when no entry matches.
*/
export function caseInsensitiveToolNameLookup(
name: string,
map: Map<string, string> | null | undefined
): string | undefined {
if (!map || !name) return undefined;
// Fast path: exact match (PascalCase-preserving providers)
const exact = map.get(name);
if (exact !== undefined) return exact;
// Fallback: case-insensitive scan
const lowerName = name.toLowerCase();
for (const [key, value] of map) {
if (key.toLowerCase() === lowerName) {
return value;
}
}
return undefined;
}
/** Restore normalized function names in OpenAI Chat Completions responses. */
export function restoreOpenAIToolNames(body: unknown, aliases: unknown): boolean {
if (!(aliases instanceof Map) || aliases.size === 0) return false;
@@ -108,7 +139,7 @@ export function restoreOpenAIToolNames(body: unknown, aliases: unknown): boolean
for (const toolCall of calls) {
const fn = toRecord(toRecord(toolCall)?.function);
if (!fn || typeof fn.name !== "string") continue;
const original = aliases.get(fn.name);
const original = caseInsensitiveToolNameLookup(fn.name, aliases);
if (typeof original !== "string" || original === fn.name) continue;
fn.name = original;
changed = true;

View File

@@ -37,10 +37,22 @@ type OpenAIToolCallLike = {
export function buildChangedToolNameMap(
toolNameMap: Map<string, string>
): Map<string, string> | null {
const changedEntries = [...toolNameMap.entries()].filter(
([sanitizedName, originalName]) => sanitizedName !== originalName
);
return changedEntries.length > 0 ? new Map(changedEntries) : null;
if (toolNameMap.size === 0) return null;
const result = new Map<string, string>();
for (const [sanitizedName, originalName] of toolNameMap.entries()) {
result.set(sanitizedName, originalName);
// Add lowercase-keyed alias so Gemini's lowercased tool names find the original.
// Gemini always lowercases tool names in functionCall responses, so even identity
// entries (Bash → Bash) need a lowercase key ("bash" → "Bash") for the response
// translator to look them up (#9568).
const lower = sanitizedName.toLowerCase();
if (lower !== sanitizedName && !result.has(lower)) {
result.set(lower, originalName);
}
}
return result;
}
export function extractClientThoughtSignature(toolCall: unknown): string | null {

View File

@@ -108,9 +108,11 @@ export function geminiToClaudeResponse(chunk, state) {
}
const fc = part.functionCall;
const rawToolName = fc.name;
const restoredToolName = normalizeToolName(
state.toolNameMap?.get(rawToolName) || rawToolName
);
const mappedName = state.toolNameMap?.get(rawToolName);
// When the toolNameMap provides a match (e.g., lowercase "bash" → "Bash"),
// use it directly without passing through normalizeToolName(), which would
// reverse TitleCase back to lowercase via REVERSE_MAP (#9568).
const restoredToolName = mappedName || normalizeToolName(rawToolName);
const idx = state.contentBlockIndex++;
const toolId = fc.id || `toolu_${Date.now()}_${idx}`;

View File

@@ -4,6 +4,7 @@ import {
buildGeminiThoughtSignatureKey,
storeGeminiThoughtSignature,
} from "../../services/geminiThoughtSignatureStore.ts";
import { caseInsensitiveToolNameLookup } from "../helpers/toolCallHelper.ts";
import {
parseTextualToolCallCandidate,
containsTextualToolCallMarker,
@@ -256,7 +257,7 @@ function emitFunctionCallPart(
results: Array<Record<string, unknown>>
) {
const rawToolName = part.functionCall.name;
const fcName = state.toolNameMap?.get(rawToolName) || rawToolName;
const fcName = caseInsensitiveToolNameLookup(rawToolName, state.toolNameMap) ?? rawToolName;
const fcArgs = normalizeToolCallArgs(part.functionCall.args || {});
const toolCallIndex = state.functionIndex++;
const toolCall = {

View File

@@ -1,6 +1,7 @@
import { register } from "../registry.ts";
import { FORMATS } from "../formats.ts";
import { CLAUDE_OAUTH_TOOL_PREFIX } from "../request/openai-to-claude.ts";
import { caseInsensitiveToolNameLookup } from "../helpers/toolCallHelper.ts";
import { hasToolCallShim, applyToolCallShimToBuffer } from "../helpers/toolCallShim.ts";
import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts";
import { isAbortFinishReason } from "../../utils/finishReason.ts";
@@ -284,7 +285,7 @@ export function openaiToClaudeResponse(chunk, state) {
// Strip the Claude OAuth prefix from an incoming tool name (if any).
const incomingName = (() => {
let n = tc.function?.name || "";
n = state.toolNameMap?.get(n) || n;
n = caseInsensitiveToolNameLookup(n, state.toolNameMap) ?? n;
if (n.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) n = n.slice(CLAUDE_OAUTH_TOOL_PREFIX.length);
return n;
})();

View File

@@ -382,6 +382,10 @@ export function resolveProxyForRequest(targetUrl) {
const contextProxy = proxyContext.getStore();
if (contextProxy) {
// #9551: NO_PROXY must bypass context-proxy too
if (target && noProxyMatch(targetUrl)) {
return { source: "direct", proxyUrl: null };
}
return { source: "context", proxyUrl: proxyConfigToUrl(contextProxy) };
}

View File

@@ -70,7 +70,10 @@ import {
hasUnsupportedReasoningSignal,
} from "./reasoningFields.ts";
import { applyThinkTag, flushThink, initThinkState } from "./thinkTagParser.ts";
import { restoreOpenAIToolNames } from "../translator/helpers/toolCallHelper.ts";
import {
caseInsensitiveToolNameLookup,
restoreOpenAIToolNames,
} from "../translator/helpers/toolCallHelper.ts";
import { normalizeFinalOpenAIStreamChunk } from "./openAIStreamChunk.ts";
/**
@@ -578,7 +581,7 @@ function restoreClaudePassthroughToolUseName(parsed: JsonRecord, toolNameMap: un
: null;
if (!block || block.type !== "tool_use" || typeof block.name !== "string") return false;
const restoredName = toolNameMap.get(block.name) ?? block.name;
const restoredName = caseInsensitiveToolNameLookup(block.name, toolNameMap) ?? block.name;
if (restoredName === block.name) return false;
block.name = restoredName;
return true;

View File

@@ -210,6 +210,7 @@
"backfill-aggregation": "node --import tsx src/scripts/backfillAggregation.ts",
"env:sync": "node scripts/dev/sync-env.mjs",
"test:integration": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"",
"test:integration:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=$TEST_SHARD tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"",
"test:combo:matrix": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/integration/combo-matrix/*.test.ts\"",
"test:combo:live": "cross-env RUN_COMBO_LIVE=1 DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/integration/combo-live/*.live.test.ts\"",
"test:combo:live:vps": "node scripts/test/combo-live-vps.mjs",

View File

@@ -1,10 +1,11 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { notFound } from "next/navigation";
import Link from "next/link";
import { Card } from "@/shared/components";
import { shouldAutoSyncOnOpen } from "@/lib/radar/autoSync";
// ---------------------------------------------------------------------------
// Types
@@ -126,32 +127,32 @@ export default function RadarPage() {
}
}, [t]);
// Fetch settings to determine opt-in state
// Fetch settings to determine opt-in state (GET /api/radar/settings — FIX 3:
// previously there was no settings GET, so an already-opted-in operator saw
// the activation screen on every reload).
const fetchSettings = useCallback(async () => {
try {
// We don't have a GET /api/radar/settings — infer from catalog response:
// If catalog returns meta=null and entries are baseline-only, user hasn't opted in.
// A 404 means flag is off.
const res = await fetch("/api/radar/catalog");
if (res.status === 404) {
const settingsRes = await fetch("/api/radar/settings");
if (settingsRes.status === 404) {
// Flag off
setOptIn(false);
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setEntries(data.entries || []);
setMeta(data.meta || null);
// If meta is null, the user hasn't synced yet (or hasn't opted in).
// We need to check opt-in state. Since there's no GET endpoint for settings,
// we infer: if flag is on and we got baseline, user may or may not be opted in.
// The activation flow handles this — we show the activation screen if meta is null.
setOptIn(null); // unknown — will determine from user action
if (!settingsRes.ok) throw new Error(`HTTP ${settingsRes.status}`);
const settingsData = await settingsRes.json();
setOptIn(settingsData.optIn === true);
if (settingsData.optIn === true) {
// Already opted in — load the catalog now so the populated/empty
// state renders immediately instead of waiting for a manual sync.
await fetchCatalog();
}
} catch {
setOptIn(null);
} finally {
setLoading(false);
}
}, []);
}, [fetchCatalog]);
useEffect(() => {
fetchSettings();
@@ -167,7 +168,10 @@ export default function RadarPage() {
const data = await res.json();
if (data.status === "updated" || data.status === "stale") {
await fetchCatalog();
} else if (data.status === "error") {
} else if (data.status === "error" || data.status === "too_large") {
// "too_large" reuses the generic sync-failed copy — the feed exceeded the
// client-side size cap (10MB), which is operationally the same as any
// other sync failure from the operator's point of view.
setError(data.reason || t("syncFailed"));
} else if (data.status === "disabled") {
setError(t("flagDisabled"));
@@ -181,6 +185,19 @@ export default function RadarPage() {
}
}, [t, fetchCatalog]);
// Auto-sync on open: when the operator is already opted in and the cached
// feed is stale (or absent), refresh it automatically once per mount so the
// page always shows current data without requiring the manual Sync button
// (spec: dados atualizados a cada abrir da página). The ref guards against
// re-firing when `meta` updates after the sync itself.
const autoSyncFiredRef = useRef(false);
useEffect(() => {
if (loading || syncing || optIn !== true || autoSyncFiredRef.current) return;
if (!shouldAutoSyncOnOpen(meta?.fetchedAt ?? null, Date.now())) return;
autoSyncFiredRef.current = true;
void handleSync();
}, [loading, syncing, optIn, meta, handleSync]);
// Activate opt-in
const handleActivate = useCallback(async () => {
setActivating(true);

View File

@@ -217,11 +217,15 @@ export async function POST(request: Request) {
testStatus: "active",
isActive: true,
};
const connection: any = await upsertImportedKiroConnection(targetProvider, record, {
profileArn: resolvedProfileArn,
clientId: providerSpecificData.clientId,
email,
});
// Only include clientId in the identity for IDC imports where it is genuinely
// unique per account (#2059). For Builder ID / social imports the OIDC clientId
// comes from a machine-wide cached OIDC registration (shared across all accounts
// on the same machine), so using it for identity matching would cause different
// accounts to overwrite each other (#9435). Without clientId, the identity
// matching falls through to the email field, which correctly distinguishes imports.
const identity: Record<string, unknown> = { profileArn: resolvedProfileArn, email };
if (isIdc) identity.clientId = providerSpecificData.clientId;
const connection: any = await upsertImportedKiroConnection(targetProvider, record, identity);
// Auto sync to Cloud if enabled
await syncToCloudIfEnabled();

View File

@@ -4,12 +4,16 @@
* NEVER proxies the private feed server. The browser talks only to this
* local endpoint; sync happens server-side via POST /api/radar/sync.
*
* Flag off => 404 (the surface doesn't exist when disabled).
* Flag off => 404 (the surface doesn't exist when disabled), checked BEFORE
* auth so flag-off inertia stays byte-identical (no auth required to learn
* the surface doesn't exist). Unauthenticated access once the flag is on
* => 401 (management route — dashboard session or a management-scoped key).
*/
import { NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { getRadarCatalog } from "@/lib/radar";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
@@ -20,8 +24,8 @@ export async function OPTIONS() {
return handleCorsOptions();
}
export async function GET() {
// Flag gate — surface doesn't exist when disabled
export async function GET(request: Request) {
// Flag gate — surface doesn't exist when disabled. MUST run before auth.
if (!isFeatureFlagEnabled("RADAR_ENABLED")) {
return NextResponse.json(
buildErrorBody(404, "Not found"),
@@ -29,6 +33,13 @@ export async function GET() {
);
}
if (!(await isAuthenticated(request))) {
return NextResponse.json(
buildErrorBody(401, "Unauthorized"),
{ status: 401, headers: CORS_HEADERS },
);
}
try {
const result = getRadarCatalog();
return NextResponse.json(

View File

@@ -1,18 +1,27 @@
/**
* GET /api/radar/settings — read the current Radar opt-in + supporter-key
* snapshot. Powers the dashboard page's "am I already opted in?" check so
* a reload doesn't re-show the activation screen (see FIX 3).
*
* POST /api/radar/settings — set Radar opt-in and/or supporter key.
*
* Zod-validated body: { optIn?: boolean, supporterKey?: string|null }
* Key shape: "omr_" + 40 hex chars.
*
* NEVER echoes the key back returns a masked form instead.
* Flag off => 404.
* NEVER echoes the raw key back on either verb — GET returns a masked form
* ("omr_****" + last 4 hex chars) and a `hasSupporterKey` boolean; POST
* returns the same masked form.
*
* Flag off => 404, checked BEFORE auth (byte-identical flag-off inertia).
* Unauthenticated access once the flag is on => 401.
*/
import { NextResponse } from "next/server";
import { z } from "zod";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
import { setRadarOptIn, setRadarKey } from "@/lib/db/radar";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { setRadarOptIn, setRadarKey, getRadarSettings } from "@/lib/db/radar";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
export const dynamic = "force-dynamic";
@@ -43,8 +52,8 @@ export async function OPTIONS() {
return handleCorsOptions();
}
export async function POST(request: Request) {
// Flag gate
export async function GET(request: Request) {
// Flag gate — MUST run before auth (byte-identical flag-off inertia).
if (!isFeatureFlagEnabled("RADAR_ENABLED")) {
return NextResponse.json(
buildErrorBody(404, "Not found"),
@@ -52,6 +61,48 @@ export async function POST(request: Request) {
);
}
if (!(await isAuthenticated(request))) {
return NextResponse.json(
buildErrorBody(401, "Unauthorized"),
{ status: 401, headers: CORS_HEADERS },
);
}
try {
const settings = getRadarSettings();
return NextResponse.json(
{
optIn: settings.optIn,
hasSupporterKey: settings.supporterKey !== null,
supporterKeyMasked: maskKey(settings.supporterKey),
},
{ headers: { ...CORS_HEADERS, "Cache-Control": "no-store" } },
);
} catch (err: unknown) {
const { sanitizeErrorMessage } = await import("@omniroute/open-sse/utils/error");
return NextResponse.json(
buildErrorBody(500, sanitizeErrorMessage(err) || "Failed to load Radar settings"),
{ status: 500, headers: CORS_HEADERS },
);
}
}
export async function POST(request: Request) {
// Flag gate — MUST run before auth (byte-identical flag-off inertia).
if (!isFeatureFlagEnabled("RADAR_ENABLED")) {
return NextResponse.json(
buildErrorBody(404, "Not found"),
{ status: 404, headers: CORS_HEADERS },
);
}
if (!(await isAuthenticated(request))) {
return NextResponse.json(
buildErrorBody(401, "Unauthorized"),
{ status: 401, headers: CORS_HEADERS },
);
}
let body: unknown;
try {
body = await request.json();
@@ -83,6 +134,17 @@ export async function POST(request: Request) {
try {
if (optIn !== undefined) {
setRadarOptIn(optIn);
if (optIn) {
// Opting in is the moment the daily background sync becomes wanted —
// arm the scheduler lazily so a flag-off/opt-out install never even
// creates the timer (Radar inertia contract). Never fatal.
try {
const { ensureRadarSyncScheduler } = await import("@/lib/radar/scheduler");
ensureRadarSyncScheduler();
} catch {
// Scheduler is best-effort; manual sync keeps working without it.
}
}
}
if (supporterKey !== undefined) {
setRadarKey(supporterKey);

View File

@@ -5,13 +5,15 @@
* verification, schema validation, version floor). Returns the status
* object. Never proxies the feed URL to the client.
*
* Flag off => 404.
* Flag off => 404, checked BEFORE auth (byte-identical flag-off inertia).
* Unauthenticated access once the flag is on => 401.
*/
import { NextResponse } from "next/server";
import { z } from "zod";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { syncRadar } from "@/lib/radar/sync";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
@@ -26,7 +28,7 @@ export async function OPTIONS() {
}
export async function POST(request: Request) {
// Flag gate
// Flag gate — MUST run before auth (byte-identical flag-off inertia).
if (!isFeatureFlagEnabled("RADAR_ENABLED")) {
return NextResponse.json(
buildErrorBody(404, "Not found"),
@@ -34,6 +36,13 @@ export async function POST(request: Request) {
);
}
if (!(await isAuthenticated(request))) {
return NextResponse.json(
buildErrorBody(401, "Unauthorized"),
{ status: 401, headers: CORS_HEADERS },
);
}
// Validate body (must be empty or absent)
let body: unknown;
try {

View File

@@ -5,6 +5,7 @@ import { getRuntimePorts } from "@/lib/runtime/ports";
import { updateSettingsSchema } from "@/shared/validation/settingsSchemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
import { resolveModelLockoutSettings } from "@/lib/resilience/modelLockoutSettings";
import {
validateProxyUrl,
@@ -220,6 +221,12 @@ export async function GET(request: Request) {
cloudConfigured: Boolean(cloudUrl),
cloudUrl,
machineId,
// Sidebar.tsx has no server-side feature-flag access (client component);
// this piggy-backs the RADAR_ENABLED gate onto the settings payload the
// sidebar already fetches on mount, so the "radar" item can hide itself
// without a dedicated round trip. See sidebarVisibility.ts's
// `isSidebarItemVisibleForFlags()`.
radarEnabled: isFeatureFlagEnabled("RADAR_ENABLED"),
...(cliproxyapiModelMapping !== null
? { cliproxyapi_model_mapping: cliproxyapiModelMapping }
: {}),

View File

@@ -543,6 +543,19 @@ export async function registerNodejs(): Promise<void> {
console.warn("[STARTUP] Arena ELO sync failed to start (non-fatal):", msg);
}),
// Radar daily feed sync: only arms itself when RADAR_ENABLED AND the user
// opt-in are already on (flag-off boot stays timer-free — Radar inertia
// contract). Non-blocking, never fatal.
import("@/lib/radar/scheduler")
.then((m) => {
const started = m.initRadarSyncScheduler();
if (started) console.log("[STARTUP] Radar sync scheduler initialized");
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
console.warn("[STARTUP] Radar sync scheduler failed to start (non-fatal):", msg);
}),
// Pricing sync: opt-in external pricing data (self-gated by PRICING_SYNC_ENABLED inside
// initPricingSync). Non-blocking, never fatal.
import("@/lib/pricingSync")

View File

@@ -13,6 +13,7 @@ import {
openDatabaseAsync,
} from "./adapters/driverFactory";
import path from "path";
import { retryProbeIfTransient } from "./probeUtils";
import fs from "fs";
import { resolveWritableDataDir, getLegacyDotDataDir } from "../dataPaths";
import { runMigrations } from "./migrationRunner";
@@ -1142,18 +1143,19 @@ export function getDbInstance(): SqliteDatabase {
`Original error: ${message}`
);
}
preservedCriticalState = captureCriticalDbState(sqliteFile);
// SAFETY: Never delete the database — rename to backup so data can be recovered.
// The old code would silently destroy all user data on any probe failure.
const failedPath = sqliteFile + `.probe-failed-${Date.now()}`;
try {
fs.renameSync(sqliteFile, failedPath);
console.warn(`[DB] Renamed corrupt DB to ${path.basename(failedPath)}`);
failedProbePath = failedPath;
failedProbeMessage = message;
} catch {
/* ok */
if (!retryProbeIfTransient(sqliteFile, e, openSqliteDatabase, closeProbeIfSafe)) {
preservedCriticalState = captureCriticalDbState(sqliteFile);
// SAFETY: Never delete the database — rename to backup so data can be recovered.
// The old code would silently destroy all user data on any probe failure.
const failedPath = sqliteFile + `.probe-failed-${Date.now()}`;
try {
fs.renameSync(sqliteFile, failedPath);
console.warn(`[DB] Renamed corrupt DB to ${path.basename(failedPath)}`);
failedProbePath = failedPath;
failedProbeMessage = message;
} catch {
/* ok */
}
}
}
}

96
src/lib/db/probeUtils.ts Normal file
View File

@@ -0,0 +1,96 @@
/**
* Probe-retry utilities for the SQLite corruption-probe path in getDbInstance().
*
* Transient probe errors (SQLITE_BUSY, ENOENT, SQLITE_PROTOCOL, SQLITE_IOERR)
* should be retried with backoff instead of immediately renaming the DB away
* and creating an empty one (data loss under concurrent load, #9541).
*/
import fs from "node:fs";
import path from "node:path";
/**
* Identifies transient SQLite/OS probe errors that should be retried instead of
* triggering the corruption-rename path.
*
* Transient errors are conditions that can self-resolve within milliseconds:
* - SQLITE_BUSY: database is locked by another connection
* - SQLITE_PROTOCOL: locking protocol violation
* - SQLITE_IOERR: disk I/O error (can be transient under load)
* - ENOENT: file disappeared (race with another process/worker deleting it)
*
* Fatal errors (native load failures, OOM, module-not-found) are NOT transient.
*/
export function isTransientProbeError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /SQLITE_BUSY|SQLITE_PROTOCOL|SQLITE_IOERR|ENOENT/i.test(message);
}
/**
* Synchronous sleep that blocks the event loop for `ms` milliseconds.
* Only used in the transient-probe-error retry path where we are already in
* a synchronous context (better-sqlite3). Uses `Atomics.wait` which yields to
* the OS scheduler during the wait, falling back to a busy-wait on runtimes
* where Atomics.wait is restricted.
*/
function syncSleep(ms: number): void {
if (typeof SharedArrayBuffer !== "undefined" && typeof Atomics !== "undefined") {
try {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
return;
} catch {
// Atomics.wait may throw on restricted runtimes — fall through to busy-wait
}
}
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
/* busy-wait */
}
}
/**
* Type for openSqliteDatabase callback — avoids importing the full SQLite adapter type.
*/
type OpenDbFn = (
filePath: string,
options?: Record<string, unknown>
) => {
driver: string;
open: boolean;
close(): void;
};
/**
* Retries opening a SQLite database probe when the initial attempt fails with
* a transient error. Uses exponential backoff (500ms, 1000ms, 2000ms).
*
* @param sqliteFile - Path to the SQLite database file
* @param openDb - Function to open the database (normally openSqliteDatabase)
* @param closeDb - Function to safely close the probe adapter
* @returns true if the retry succeeded (transient condition resolved)
* false if all retries were exhausted or error is non-transient
*/
export function retryProbeIfTransient(
sqliteFile: string,
probeError: unknown,
openDb: OpenDbFn,
closeDb: (adapter: { driver: string; open: boolean; close(): void } | null | undefined) => void
): boolean {
if (!isTransientProbeError(probeError)) return false;
const retryDelays = [500, 1000, 2000];
for (let i = 0; i < retryDelays.length; i++) {
syncSleep(retryDelays[i]);
try {
const retryAdapter = openDb(sqliteFile, { readonly: true });
closeDb(retryAdapter);
return true;
} catch {
// Retry failed, try next delay
}
}
console.warn(
`[DB] All ${retryDelays.length} transient probe retries exhausted — declaring corruption`
);
return false;
}

View File

@@ -51,6 +51,30 @@ export interface MergedEntry {
* Absent for entries disabled by other means or still enabled.
*/
disabledBy?: "radar";
/**
* Context window size in tokens. Only present on entries that carry feed
* data (origin "radar"/"local" merged from a feed entry); undefined for
* baseline-only entries.
*/
contextWindow?: number | null;
/** Capability flags reported by the feed. Undefined for baseline-only entries. */
capabilities?: {
tools: boolean;
vision: boolean;
thinking: boolean;
};
/** Rate/quota limits reported by the feed. Undefined for baseline-only entries. */
limits?: {
rpm: number | null;
rpd: number | null;
tpm: number | null;
tpd: number | null;
};
/** Setup guide (key URL + steps) reported by the feed. Undefined for baseline-only entries. */
setup?: {
keyUrl: string | null;
steps: string[];
} | null;
}
/**
@@ -254,6 +278,18 @@ function mergeOne(
if (!overriddenKeys.has("creditTokens")) {
// Feed doesn't have creditTokens; keep baseline
}
if (!overriddenKeys.has("contextWindow")) {
result.contextWindow = feed.contextWindow;
}
if (!overriddenKeys.has("capabilities")) {
result.capabilities = feed.capabilities;
}
if (!overriddenKeys.has("limits")) {
result.limits = feed.limits;
}
if (!overriddenKeys.has("setup")) {
result.setup = feed.setup;
}
// Apply local overrides (rule 1: they win)
if (overrides) {
@@ -265,6 +301,10 @@ function mergeOne(
if (overrides.tos !== undefined) result.tos = overrides.tos;
if (overrides.trainsOnPrompts !== undefined) result.trainsOnPrompts = overrides.trainsOnPrompts;
if (overrides.enabled !== undefined) result.enabled = overrides.enabled;
if (overrides.contextWindow !== undefined) result.contextWindow = overrides.contextWindow;
if (overrides.capabilities !== undefined) result.capabilities = overrides.capabilities;
if (overrides.limits !== undefined) result.limits = overrides.limits;
if (overrides.setup !== undefined) result.setup = overrides.setup;
}
// Origin: "local" if user has overrides, else "radar" (feed updated it)
@@ -292,9 +332,16 @@ function feedModelToMerged(
trainsOnPrompts: overrides?.trainsOnPrompts ?? (feed.trainsOnPrompts ?? undefined),
enabled: overrides?.enabled ?? feed.enabled,
origin: overrides ? "local" : "radar",
contextWindow: overrides?.contextWindow ?? feed.contextWindow,
capabilities: overrides?.capabilities ?? feed.capabilities,
limits: overrides?.limits ?? feed.limits,
setup: overrides?.setup ?? feed.setup,
};
if (!feed.enabled) {
// 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) {
entry.enabled = false;
entry.disabledBy = "radar";
}

27
src/lib/radar/autoSync.ts Normal file
View File

@@ -0,0 +1,27 @@
/**
* autoSync.ts — pure staleness rule shared by the Radar page (client) and the
* background scheduler (server).
*
* Kept free of any server-only import (db, sync) so the "use client" Radar
* page can consume it directly.
*/
/** Cache older than this triggers an automatic sync when the page opens. */
export const AUTO_SYNC_STALE_MS = 6 * 60 * 60 * 1000; // 6h
/**
* Whether an automatic sync should fire for a cache fetched at `fetchedAt`.
*
* Missing or unparseable timestamps count as stale — the only way to get a
* fresh verdict is a real, recent fetch.
*/
export function shouldAutoSyncOnOpen(
fetchedAt: string | null | undefined,
nowMs: number,
staleMs: number = AUTO_SYNC_STALE_MS
): boolean {
if (!fetchedAt) return true;
const fetchedMs = Date.parse(fetchedAt);
if (!Number.isFinite(fetchedMs)) return true;
return nowMs - fetchedMs >= staleMs;
}

114
src/lib/radar/scheduler.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* scheduler.ts — daily background sync for the Radar feed (spec: "GET 1×/dia,
* só quando opt-in").
*
* Inertia contract: a flag-off boot NEVER creates a timer. The scheduler only
* starts from (a) `initRadarSyncScheduler()` at boot when the flag AND the
* user opt-in are already on, or (b) the settings route right after the user
* opts in. If the flag is later turned off, the next tick stops the timer —
* returning the process to the zero-timer state.
*
* The tick itself is cheap (one flag lookup + one DB row) and only performs a
* network sync when the cache is older than the daily window computed by
* `nextSyncTime()`. `syncRadar()` re-checks flag/opt-in internally, so a
* mid-flight settings change degrades to a no-op instead of an errant fetch.
*/
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
import { getRadarCache, getRadarSettings } from "@/lib/db/radar";
import { nextSyncTime, syncRadar, type SyncStatus } from "./sync";
/** How often the scheduler re-evaluates staleness (NOT the sync cadence). */
export const RADAR_SCHEDULER_TICK_MS = 60 * 60 * 1000; // hourly
export type RadarTickResult =
| { action: "stopped"; reason: "flag_off" }
| { action: "skipped"; reason: "opt_out" | "not_due" }
| { action: "synced"; result: SyncStatus };
export interface RadarSchedulerDeps {
getFlag?: (key: string) => boolean;
getSettings?: () => { optIn: boolean };
getCache?: () => { fetchedAt: string } | null;
sync?: () => Promise<SyncStatus>;
now?: () => number;
setIntervalFn?: typeof setInterval;
clearIntervalFn?: typeof clearInterval;
}
let timer: ReturnType<typeof setInterval> | null = null;
/**
* One scheduler evaluation. Exported for tests and for the immediate
* post-start tick.
*/
export async function radarSchedulerTick(deps: RadarSchedulerDeps = {}): Promise<RadarTickResult> {
const getFlag = deps.getFlag ?? isFeatureFlagEnabled;
if (!getFlag("RADAR_ENABLED")) {
stopRadarSyncScheduler(deps);
return { action: "stopped", reason: "flag_off" };
}
const settings = (deps.getSettings ?? getRadarSettings)();
if (!settings.optIn) return { action: "skipped", reason: "opt_out" };
const cache = (deps.getCache ?? getRadarCache)();
const nowMs = (deps.now ?? Date.now)();
if (nowMs < nextSyncTime(cache?.fetchedAt ?? null).getTime()) {
return { action: "skipped", reason: "not_due" };
}
const result = await (deps.sync ?? syncRadar)();
return { action: "synced", result };
}
/**
* Start the hourly staleness timer (idempotent). Fires one immediate,
* non-blocking tick so a due sync happens right away instead of waiting a
* full tick interval. Returns whether a new timer was created.
*/
export function ensureRadarSyncScheduler(deps: RadarSchedulerDeps = {}): boolean {
if (timer) return false;
const tick = () => {
radarSchedulerTick(deps).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
console.warn("[RADAR_SYNC] Scheduled sync tick failed (non-fatal):", msg);
});
};
timer = (deps.setIntervalFn ?? setInterval)(tick, RADAR_SCHEDULER_TICK_MS);
// Never keep the process alive just for this timer.
if (timer && typeof timer === "object" && "unref" in timer) {
(timer as { unref?: () => void }).unref?.();
}
tick();
return true;
}
/** Stop the timer (used by the flag-off self-heal and by tests). */
export function stopRadarSyncScheduler(deps: RadarSchedulerDeps = {}): void {
if (timer) {
(deps.clearIntervalFn ?? clearInterval)(timer);
timer = null;
}
}
/**
* Boot-time init: only arms the scheduler when the flag AND the opt-in are
* already on (a flag-off install stays byte-identical — no timer, no DB
* polling loop). Never throws.
*/
export function initRadarSyncScheduler(deps: RadarSchedulerDeps = {}): boolean {
try {
const getFlag = deps.getFlag ?? isFeatureFlagEnabled;
if (!getFlag("RADAR_ENABLED")) return false;
const settings = (deps.getSettings ?? getRadarSettings)();
if (!settings.optIn) return false;
return ensureRadarSyncScheduler(deps);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn("[RADAR_SYNC] Scheduler init failed (non-fatal):", msg);
return false;
}
}

View File

@@ -28,6 +28,17 @@ const DEFAULT_FEED_BASE_URL = "https://radar.omniroute.online";
const SYNC_TIMEOUT_MS = 30_000;
/**
* Hard cap on the Radar feed response body. The signed feed is a small JSON
* document (KB-scale) — anything past this is either a misconfigured/hostile
* `RADAR_FEED_URL` or an upstream serving garbage. Enforced both via a
* `Content-Length` preflight (skip reading the body entirely when the
* server already declares an oversized payload) AND a running-total check
* while reading the body (an absent/lying Content-Length must not bypass
* the cap).
*/
const MAX_FEED_BYTES = 10 * 1024 * 1024; // 10 MB
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -38,6 +49,7 @@ export type SyncStatus =
| { status: "invalid_signature" }
| { status: "invalid_schema" }
| { status: "stale" }
| { status: "too_large" }
| { status: "updated"; version: string; tier: string }
| { status: "error"; reason: string };
@@ -189,8 +201,54 @@ export async function syncRadar(deps: SyncDeps = {}): Promise<SyncStatus> {
return { status: "error", reason: `Feed request failed with status ${res.status}` };
}
// Step 4: Read exact bytes + signature header
const rawBytes = Buffer.from(await res.arrayBuffer());
// Step 3b: Content-Length preflight — skip reading an already-oversized
// body entirely. The header is untrusted (may be absent or wrong), so
// this is a fast-path only; the real enforcement is the running-total
// check below.
const contentLengthHeader = res.headers.get("content-length");
if (contentLengthHeader !== null) {
const declaredLength = Number(contentLengthHeader);
if (Number.isFinite(declaredLength) && declaredLength > MAX_FEED_BYTES) {
return { status: "too_large" };
}
}
// Step 4: Read exact bytes + signature header, enforcing MAX_FEED_BYTES
// while reading so an absent/lying Content-Length cannot bypass the cap.
// Concatenating the accumulated chunks preserves the exact bytes needed
// for signature verification below.
let rawBytes: Buffer;
const body = res.body as ReadableStream<Uint8Array> | null | undefined;
if (body && typeof body.getReader === "function") {
const reader = body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
let tooLarge = false;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
total += value.byteLength;
if (total > MAX_FEED_BYTES) {
tooLarge = true;
await reader.cancel().catch(() => {});
break;
}
chunks.push(value);
}
}
if (tooLarge) {
return { status: "too_large" };
}
rawBytes = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
} else {
const buffered = Buffer.from(await res.arrayBuffer());
if (buffered.byteLength > MAX_FEED_BYTES) {
return { status: "too_large" };
}
rawBytes = buffered;
}
const signature = res.headers.get("x-omniroute-feed-signature") ?? "";
// Step 5: Verify signature

View File

@@ -234,8 +234,12 @@ const urlPath =
? decodeURIComponent(MITM_SERVER_URL.pathname.slice(1))
: decodeURIComponent(MITM_SERVER_URL.pathname);
const cwdPath = path.join(process.cwd(), "src", "mitm", "server.cjs");
const MITM_SERVER_PATH = fs.existsSync(cwdPath) ? cwdPath : urlPath;
// Lazy-resolve to avoid module-level fs.existsSync + process.cwd() at module scope,
// which causes Turbopack's NFT tracer to follow the path into the entire src/ tree.
function resolveMitmServerPath(): string {
const cwdPath = path.join(/* turbopackIgnore: true */ process.cwd(), "src", "mitm", "server.cjs");
return fs.existsSync(cwdPath) ? cwdPath : urlPath;
}
// Check if a PID is alive
function isProcessAlive(pid: number): boolean {
@@ -607,7 +611,7 @@ async function startMitmInternal(
}
}
serverProcess = spawn(process.execPath, [MITM_SERVER_PATH], {
serverProcess = spawn(process.execPath, [resolveMitmServerPath()], {
windowsHide: true,
env: {
...process.env,

View File

@@ -151,6 +151,16 @@ async function startServer() {
} catch (err) {
startupLog.warn({ error: getErrorMessage(err) }, "Arena ELO sync could not initialize");
}
// Radar daily feed sync: only arms itself when RADAR_ENABLED AND the user
// opt-in are already on (a flag-off boot stays timer-free — Radar inertia
// contract). Non-blocking, never fatal.
try {
const { initRadarSyncScheduler } = await import("./lib/radar/scheduler");
initRadarSyncScheduler();
} catch (err) {
startupLog.warn({ error: getErrorMessage(err) }, "Radar sync scheduler could not initialize");
}
}
// Start the server initialization

View File

@@ -32,6 +32,7 @@ import {
applySectionOrder,
applyItemOrder,
getSidebarIconAccent,
isSidebarItemVisibleForFlags,
type SidebarSectionId,
type SidebarItemDefinition,
type SidebarItemGroup,
@@ -99,6 +100,10 @@ export default function Sidebar({
const [showDebug, setShowDebug] = useState(false);
const [hiddenSidebarItems, setHiddenSidebarItems] = useState<string[]>([]);
const [hiddenSidebarGroupLabels, setHiddenSidebarGroupLabels] = useState<string[]>([]);
// Feature-flag map for flag-gated items (e.g. "radar" -> RADAR_ENABLED).
// Fails open (see isSidebarItemVisibleForFlags) so a missing key never
// hides an unrelated item — only set once /api/settings resolves.
const [featureFlags, setFeatureFlags] = useState<Record<string, boolean>>({});
const [sidebarSectionOrder, setSidebarSectionOrder] = useState<SidebarSectionId[]>([]);
const [sidebarItemOrder, setSidebarItemOrder] = useState<SidebarItemOrder>({});
const [customAppName, setCustomAppName] = useState<string | null>(null);
@@ -147,6 +152,9 @@ export default function Sidebar({
);
setCustomAppName(data?.instanceName || null);
setCustomLogo(data?.customLogoBase64 || data?.customLogoUrl || null);
if (typeof data?.radarEnabled === "boolean") {
setFeatureFlags((prev) => ({ ...prev, RADAR_ENABLED: data.radarEnabled }));
}
};
fetch("/api/settings")
@@ -206,6 +214,7 @@ export default function Sidebar({
const resolveItem = (item: SidebarItemDefinition, hidden: Set<string>) => {
if (hidden.has(item.id)) return null;
if (!isSidebarItemVisibleForFlags(item, featureFlags)) return null;
const subtitle = item.subtitleKey
? getSidebarLabel(item.subtitleKey, item.subtitleFallback ?? "")
: item.subtitleFallback;

View File

@@ -126,6 +126,21 @@ export function getSidebarIconAccent(id: string): string {
);
}
/**
* Decide whether a sidebar item should be shown given a resolved feature-flag
* map. Items without `featureFlagKey` are always visible. Fails OPEN when the
* flag isn't present in the map (e.g. `/api/settings` hasn't returned yet, or
* an older server response predates the flag) — a missing entry must never
* hide an unrelated item.
*/
export function isSidebarItemVisibleForFlags(
item: Pick<SidebarItemDefinition, "featureFlagKey">,
flags: Record<string, boolean>
): boolean {
if (!item.featureFlagKey) return true;
return flags[item.featureFlagKey] !== false;
}
export function getSectionItems(
section: SidebarSectionDefinition | { children: readonly SidebarSectionChild[] }
): readonly SidebarItemDefinition[] {

View File

@@ -469,6 +469,7 @@ const COSTS_ITEMS: readonly SidebarItemDefinition[] = [
i18nKey: "radar",
subtitleKey: "radarSubtitle",
icon: "radar",
featureFlagKey: "RADAR_ENABLED",
},
];

View File

@@ -138,6 +138,15 @@ export interface SidebarItemDefinition {
icon: string;
exact?: boolean;
external?: boolean;
/**
* Opt-in feature-flag gate. When present, the item is only shown while the
* named flag resolves to `true` server-side. Sidebar.tsx has no built-in
* feature-flag awareness — the flag's resolved value is fetched once
* (piggy-backed on the existing `/api/settings` call) and passed through
* `isSidebarItemVisibleForFlags()` alongside the existing hidden-items
* filter. Add new flag keys to this union as new flag-gated items appear.
*/
featureFlagKey?: "RADAR_ENABLED";
}
export interface SidebarItemGroup {

View File

@@ -528,9 +528,6 @@ const getExpectedParentPaths = (): string[] => {
].filter(Boolean);
};
// Cache expected parent paths at module startup (avoid recalculation on every checkKnownPath call)
const EXPECTED_PARENT_PATHS = getExpectedParentPaths();
const getExtraPaths = () =>
String(process.env.CLI_EXTRA_PATHS || "")
.split(path.delimiter)
@@ -820,7 +817,7 @@ export const checkKnownPath = async (commandPath: string) => {
const isWithinExpected = await isLocationTrusted(
commandPath,
realPath,
EXPECTED_PARENT_PATHS,
getExpectedParentPaths(),
isPathWithin,
fs.realpath
);

View File

@@ -1454,6 +1454,7 @@ async function handleSingleModelChat(
comboExecutionKey: runtimeOptions.comboExecutionKey ?? runtimeOptions.comboStepId ?? null,
extendedContext,
modelApiFormat: apiFormat,
modelTargetFormat: targetFormat,
providerProfile,
cachedSettings: runtimeOptions.cachedSettings,
skipUpstreamRetry: runtimeOptions.skipUpstreamRetry ?? false,

View File

@@ -4,14 +4,8 @@ import { connectionHasExtraKeys } from "@omniroute/open-sse/services/apiKeyRotat
import { createBuiltinAutoCombo } from "@omniroute/open-sse/services/autoCombo/builtinCatalog.ts";
import * as log from "../utils/logger";
import { updateProviderCredentials } from "../services/tokenRefresh";
import {
detectFormatFromEndpoint,
getTargetFormat,
} from "@omniroute/open-sse/services/provider.ts";
import {
getModelTargetFormat,
PROVIDER_ID_TO_ALIAS,
} from "@omniroute/open-sse/config/providerModels.ts";
import { detectFormatFromEndpoint } from "@omniroute/open-sse/services/provider.ts";
import { resolveChatCoreTargetFormat } from "@omniroute/open-sse/handlers/chatCore/targetFormat.ts";
import { handleChatCore } from "@omniroute/open-sse/handlers/chatCore.ts";
import {
checkResourcePressureGuard,
@@ -305,12 +299,25 @@ export async function resolveModelOrError(
? ((modelInfo as { apiFormat?: string }).apiFormat as string)
: undefined
: undefined;
const providerAlias = PROVIDER_ID_TO_ALIAS[provider] || provider;
let targetFormat = getModelTargetFormat(providerAlias, model) || getTargetFormat(provider);
if (apiFormat === "responses") {
targetFormat = "openai-responses";
log.info("ROUTING", `Custom model apiFormat=responses → targetFormat=openai-responses`);
}
// customModelTargetFormat: #2905 per-model wire-format override for custom models,
// injected by getModelInfo. Must be threaded into the same resolution formula
// chatCore.ts uses (static registry > custom-model DB override > provider default) —
// a model that's ALSO a static registry entry (e.g. a Vertex Claude model with no
// per-model registry targetFormat) otherwise silently drops the DB override and
// falls through to the provider default, breaking response translation.
const customModelTargetFormat: string | undefined =
modelInfo && typeof modelInfo === "object" && "targetFormat" in modelInfo
? typeof (modelInfo as { targetFormat?: unknown }).targetFormat === "string"
? ((modelInfo as { targetFormat?: string }).targetFormat as string)
: undefined
: undefined;
const { alias: providerAlias, targetFormat } = resolveChatCoreTargetFormat({
provider,
resolvedModel: model,
apiFormat,
customModelTargetFormat,
providerSpecificData: undefined,
});
const ctxTag = extendedContext && providerAlias === "claude" ? " [1m]" : "";
if (modelStr !== `${provider}/${model}`) {
@@ -405,6 +412,7 @@ export async function executeChatWithBreaker({
comboExecutionKey,
extendedContext,
modelApiFormat,
modelTargetFormat,
providerProfile,
cachedSettings,
skipUpstreamRetry = false,
@@ -437,7 +445,13 @@ export async function executeChatWithBreaker({
runWithProxyContext(proxyInfo?.proxy || null, () =>
(handleChatCore as any)({
body: { ...body, model: `${provider}/${model}` },
modelInfo: { provider, model, extendedContext, apiFormat: modelApiFormat },
modelInfo: {
provider,
model,
extendedContext,
apiFormat: modelApiFormat,
targetFormat: modelTargetFormat,
},
credentials: refreshedCredentials,
log: handlerLog,
clientRawRequest,

View File

@@ -0,0 +1,160 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts";
import { FORMATS } from "../../open-sse/translator/formats.ts";
/**
* Test for #9536: Usage misreported on OpenAI-shaped upstreams when translating
* to Claude format (non-streaming path).
*
* Two defects:
* 1. cache_read_input_tokens is always 0 (missing mapping)
* 2. input_tokens is inflated by cached tokens (not subtracting prompt_tokens_details.cached_tokens)
*
* Plus regression guard for #8331 (buffer isolation via context_budget_* fields).
*/
const DEEPSEEK_OPENAI_RESPONSE = {
id: "chatcmpl-deepseek-abc123",
object: "chat.completion",
model: "deepseek/deepseek-v4-flash",
choices: [
{
index: 0,
message: { role: "assistant", content: "I am an AI assistant." },
finish_reason: "stop",
},
],
usage: {
prompt_tokens: 4364,
prompt_tokens_details: { cached_tokens: 4352 },
prompt_cache_hit_tokens: 4352,
prompt_cache_miss_tokens: 12,
completion_tokens: 27,
total_tokens: 4391,
},
};
const DEEPSEEK_OPENAI_RESPONSE_NO_CACHE = {
id: "chatcmpl-deepseek-no-cache",
object: "chat.completion",
model: "deepseek/deepseek-v4-flash",
choices: [
{
index: 0,
message: { role: "assistant", content: "Hello." },
finish_reason: "stop",
},
],
usage: {
prompt_tokens: 125,
prompt_tokens_details: {},
completion_tokens: 5,
total_tokens: 130,
},
};
/**
* OpenAI response that (before #8331's context_budget_* fix) would have had
* input_tokens += buffer. After #8331, the buffer values go into
* context_budget_* fields that filterUsageForFormat strips.
*/
const RESPONSE_WITH_BUFFER = {
id: "chatcmpl-buffer-test",
object: "chat.completion",
model: "gpt-4o",
choices: [
{
index: 0,
message: { role: "assistant", content: "Hello." },
finish_reason: "stop",
},
],
usage: {
prompt_tokens: 50,
completion_tokens: 10,
total_tokens: 60,
},
};
describe("9536 - usage misreporting OpenAI->Claude (non-streaming)", () => {
it("Defect 1: cache_read_input_tokens should be present when cached_tokens > 0", () => {
const result = translateNonStreamingResponse(
DEEPSEEK_OPENAI_RESPONSE,
FORMATS.OPENAI,
FORMATS.CLAUDE
);
const usage = (result as Record<string, unknown>).usage as Record<string, unknown>;
// cache_read_input_tokens should be mapped from prompt_tokens_details.cached_tokens
assert.equal(
usage.cache_read_input_tokens,
4352,
`cache_read_input_tokens = ${usage.cache_read_input_tokens} (expected 4352)`
);
});
it("Defect 2: input_tokens should be prompt_tokens minus cached tokens", () => {
const result = translateNonStreamingResponse(
DEEPSEEK_OPENAI_RESPONSE,
FORMATS.OPENAI,
FORMATS.CLAUDE
);
const usage = (result as Record<string, unknown>).usage as Record<string, unknown>;
// input_tokens = prompt_tokens(4364) - cached_tokens(4352) = 12
assert.equal(usage.input_tokens, 12, `input_tokens = ${usage.input_tokens} (expected 12)`);
});
it("Regression guard #8331: buffer should NOT inflate input_tokens", () => {
const result = translateNonStreamingResponse(
RESPONSE_WITH_BUFFER,
FORMATS.OPENAI,
FORMATS.CLAUDE
);
const usage = (result as Record<string, unknown>).usage as Record<string, unknown>;
// input_tokens should be exactly prompt_tokens (50), no buffer added
assert.equal(usage.input_tokens, 50, `input_tokens = ${usage.input_tokens} (expected 50)`);
// No context_budget_* fields should leak into the translated response
assert.equal(usage.context_budget_remaining, undefined);
assert.equal(usage.context_budget_consume, undefined);
assert.equal(usage.context_budget_add, undefined);
});
it("No cache data: input_tokens unchanged, no cache_read_input_tokens", () => {
const result = translateNonStreamingResponse(
DEEPSEEK_OPENAI_RESPONSE_NO_CACHE,
FORMATS.OPENAI,
FORMATS.CLAUDE
);
const usage = (result as Record<string, unknown>).usage as Record<string, unknown>;
// Without cached_tokens, input_tokens = prompt_tokens = 125
assert.equal(usage.input_tokens, 125, `input_tokens = ${usage.input_tokens} (expected 125)`);
// cache_read_input_tokens should NOT be present when there's no caching
assert.equal(usage.cache_read_input_tokens, undefined);
});
it("Pass-through: same format returns usage unchanged", () => {
// When source === target, the function returns the response as-is
const result = translateNonStreamingResponse(
DEEPSEEK_OPENAI_RESPONSE,
FORMATS.OPENAI,
FORMATS.OPENAI
);
const usage = (result as Record<string, unknown>).usage as Record<string, unknown>;
// OpenAI format should preserve all fields, including cached_tokens
assert.equal(usage.prompt_tokens, 4364);
assert.equal(usage.completion_tokens, 27);
assert.ok(usage.prompt_tokens_details, "prompt_tokens_details should be preserved");
});
});

View File

@@ -0,0 +1,29 @@
import { describe, it } from "node:test";
import assert from "node:assert";
describe("Issue #9545 — GPT-5.6 URL routing + reasoning_effort with tools", () => {
it("getModelTargetFormat should resolve gpt-5.6-luna with and without provider prefix", async () => {
const { getModelTargetFormat } = await import("../../open-sse/config/providerModels.ts");
assert.strictEqual(getModelTargetFormat("openai", "gpt-5.6-luna"), "openai-responses");
assert.strictEqual(getModelTargetFormat("openai", "openai/gpt-5.6-luna"), "openai-responses");
});
it("getModelTargetFormat should resolve non-prefixed models unchanged", async () => {
const { getModelTargetFormat } = await import("../../open-sse/config/providerModels.ts");
assert.strictEqual(getModelTargetFormat("openai", "gpt-4o"), null);
assert.strictEqual(getModelTargetFormat("openai", "gpt-5.5-pro"), "openai-responses");
assert.strictEqual(getModelTargetFormat("openai", "openai/gpt-5.5-pro"), "openai-responses");
});
it("stripGpt5ReasoningWhenTools should not strip when targetFormat=openai-responses", async () => {
const { stripGpt5ReasoningWhenTools } =
await import("../../open-sse/services/gpt5SamplingGuard.ts");
const body = {
model: "gpt-5.6-luna",
tools: [{ type: "function", function: { name: "test" } }],
reasoning_effort: "high",
};
const r = stripGpt5ReasoningWhenTools(body, "openai", "gpt-5.6-luna", "openai-responses", null);
assert.strictEqual(r.reasoning_effort, "high");
});
});

View File

@@ -0,0 +1,61 @@
import test from "node:test";
import assert from "node:assert/strict";
import { runWithProxyContext, resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts";
async function withEnv(
overrides: Record<string, string | undefined>,
fn: () => unknown
): Promise<unknown> {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(overrides)) {
previous.set(key, process.env[key]);
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
try {
return await fn();
} finally {
for (const [key, value] of previous.entries()) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
test("[9551] BUG: context-proxy ignores NO_PROXY for non-local domains", async () => {
await withEnv(
{
NO_PROXY: "ark.cn-beijing.volces.com",
HTTP_PROXY: undefined,
},
async () => {
await runWithProxyContext({ type: "http", host: "127.0.0.1", port: 7897 }, () => {
const resolved = resolveProxyForRequest("https://ark.cn-beijing.volces.com/api/v3/models");
assert.equal(resolved.source, "direct", "NO_PROXY should bypass context proxy");
});
}
);
});
test("[9551] resolveProxyForRequest: context-proxy respects NO_PROXY=*", async () => {
await withEnv(
{
NO_PROXY: "*",
HTTP_PROXY: undefined,
},
async () => {
await runWithProxyContext({ type: "http", host: "127.0.0.1", port: 7897 }, () => {
const resolved = resolveProxyForRequest("https://api.openai.com/v1/chat/completions");
assert.equal(resolved.source, "direct", "NO_PROXY=* should bypass context proxy");
});
}
);
});

View File

@@ -0,0 +1,52 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
// The bug: module-level fs.existsSync(path.join(process.cwd(), ...)) calls cause
// Turbopack's NFT tracer to follow paths into the entire src/ tree, producing
// "Encountered unexpected file in NFT list" warnings during build.
//
// Fix: Move module-level fs/process.cwd calls to lazy functions so they are
// invoked from route handlers (not at module scope), letting the NFT tracer
// skip them during build.
describe("#9560 — Turbopack NFT guard: lazy module-level fs resolution", () => {
it("MITM lazy resolver returns a non-empty string path", async () => {
// resolveMitmServerPath() is not exported — test through the module's
// startMitm-like path by exercising the lazy resolution indirectly.
// Import the MITM module to verify it loads without module-level fs calls.
const mitm = await import("../../src/mitm/manager.ts");
// The module should export functions; just confirm it loaded cleanly.
assert.ok(typeof mitm.startMitm === "function");
assert.ok(typeof mitm.getMitmStatus === "function");
});
it("cliRuntime exports known path check function", async () => {
// Verify cliRuntime imports without module-level getExpectedParentPaths call.
const cliRuntime = await import("../../src/shared/services/cliRuntime.ts");
assert.ok(typeof cliRuntime.checkKnownPath === "function");
});
it("known path check produces deterministic result for a known-bad input", async () => {
const { checkKnownPath } = await import("../../src/shared/services/cliRuntime.ts");
// A relative path is rejected without hitting any expected-parent-paths logic.
const result = await checkKnownPath("../evil");
assert.equal(result.installed, false);
assert.equal(result.reason, "not_absolute");
});
it("known path check rejects path with dangerous characters", async () => {
const { checkKnownPath } = await import("../../src/shared/services/cliRuntime.ts");
const result = await checkKnownPath("/tmp/foo;$PATH");
assert.equal(result.installed, false);
assert.equal(result.reason, "unsafe_path");
});
it("getExpectedParentPathsCached returns same shape as direct call", async () => {
// getExpectedParentPaths is module-internal, but we can indirectly verify
// that cliRuntime's known-path logic reaches it by checking that absolute
// paths to known-locations like /usr/bin/env resolve correctly.
const { checkKnownPath } = await import("../../src/shared/services/cliRuntime.ts");
await assert.doesNotReject(checkKnownPath("/usr/bin/env"));
});
});

View File

@@ -0,0 +1,138 @@
import test from "node:test";
import assert from "node:assert/strict";
const { geminiToOpenAIResponse } =
await import("../../open-sse/translator/response/gemini-to-openai.ts");
const { geminiToClaudeResponse } =
await import("../../open-sse/translator/response/gemini-to-claude.ts");
function flatten(items) {
return items.flatMap((item) => item || []);
}
// ── Gemini -> OpenAI tool name casing fix (#9568) ──────────────────────
test("gemini-to-openai: no toolNameMap — Gemini returns lowercase 'bash', translator outputs 'bash' (bug)", () => {
const state = { toolCalls: new Map(), toolNameMap: null };
const result = geminiToOpenAIResponse(
{
responseId: "resp-9568-1",
modelVersion: "gemini-2.5-pro",
candidates: [
{
content: {
parts: [
{
functionCall: { name: "bash", args: { code: "echo hi" } },
},
],
},
finishReason: "STOP",
},
],
},
state
);
const toolCall = result.find((c) => c.choices?.[0]?.delta?.tool_calls);
const name = toolCall?.choices?.[0]?.delta?.tool_calls?.[0]?.function?.name;
assert.equal(name, "bash", "Without toolNameMap, lowercase tool name should pass through as-is");
});
test("gemini-to-openai: toolNameMap has lowercase alias — Gemini returns 'bash', translator outputs 'Bash' (fix)", () => {
const state = {
toolCalls: new Map(),
toolNameMap: new Map([["bash", "Bash"]]),
};
const result = geminiToOpenAIResponse(
{
responseId: "resp-9568-2",
modelVersion: "gemini-2.5-pro",
candidates: [
{
content: {
parts: [
{
functionCall: { name: "bash", args: { code: "echo hi" } },
},
],
},
finishReason: "STOP",
},
],
},
state
);
const toolCall = result.find((c) => c.choices?.[0]?.delta?.tool_calls);
const name = toolCall?.choices?.[0]?.delta?.tool_calls?.[0]?.function?.name;
assert.equal(
name,
"Bash",
"With toolNameMap={{'bash','Bash'}}, lowercase tool name should be restored to TitleCase"
);
});
// ── Gemini -> Claude tool name casing fix (#9568) ──────────────────────
test("gemini-to-claude: no toolNameMap — Gemini returns 'bash', translator outputs 'bash' (bug)", () => {
const state = {};
const result = geminiToClaudeResponse(
{
responseId: "resp-9568-3",
modelVersion: "gemini-2.5-pro",
candidates: [
{
content: {
parts: [
{
functionCall: { name: "bash", args: { code: "echo hi" } },
},
],
},
finishReason: "STOP",
},
],
},
state
);
const toolUse = result.find((c) => c.type === "content_block_start");
assert.equal(
toolUse?.content_block?.name,
"bash",
"Without toolNameMap, lowercase tool name should pass through as-is (gemini-to-claude)"
);
});
test("gemini-to-claude: toolNameMap has lowercase alias — Gemini returns 'bash', translator outputs 'Bash' (fix)", () => {
const state = {
toolNameMap: new Map([["bash", "Bash"]]),
};
const result = geminiToClaudeResponse(
{
responseId: "resp-9568-4",
modelVersion: "gemini-2.5-pro",
candidates: [
{
content: {
parts: [
{
functionCall: { name: "bash", args: { code: "echo hi" } },
},
],
},
finishReason: "STOP",
},
],
},
state
);
const toolUse = result.find((c) => c.type === "content_block_start");
assert.equal(
toolUse?.content_block?.name,
"Bash",
"With toolNameMap={{'bash','Bash'}}, lowercase tool name should be restored to TitleCase without normalizeToolName reversing it"
);
});

View File

@@ -29,14 +29,14 @@ function createSseResponse(events: string[]) {
});
}
async function waitForAsyncSideEffects() {
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setTimeout(resolve, 20));
async function flushAsyncSideEffects() {
// setImmediate rounds drain the event loop more reliably than setTimeout under CI load.
for (let i = 0; i < 5; i++) await new Promise((resolve) => setImmediate(resolve));
}
test.afterEach(async () => {
globalThis.fetch = originalFetch;
await waitForAsyncSideEffects();
await flushAsyncSideEffects();
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });

View File

@@ -10,8 +10,14 @@ import os from "node:os";
import path from "node:path";
import { NextRequest } from "next/server";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-9033-repro-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const TEST_DATA_DIR = path.join(process.env.DATA_DIR!, "probe-9033-repro");
// NOTE: Not reassigning process.env.DATA_DIR at module scope because
// node --test spawns test files as worker threads sharing process.env.
// A module-level DATA_DIR override would leak to ALL concurrently running
// workers, causing them to share the same SQLite file and race on it (#9541).
// isolateDataDir.ts (--import) already set DATA_DIR to a unique temp dir per
// process; we use a subdirectory within it instead.
process.env.JWT_SECRET = "test-secret-9033";
const core = await import("../../../src/lib/db/core.ts");

View File

@@ -250,7 +250,7 @@ test("chat completions route emits early keepalive while waiting for stream read
await seedHealthyConnection();
globalThis.fetch = async () => {
await new Promise((resolve) => setTimeout(resolve, 2200));
await new Promise((resolve) => setTimeout(resolve, 100));
return new Response(
[
`data: ${JSON.stringify({
@@ -274,10 +274,7 @@ test("chat completions route emits early keepalive while waiting for stream read
assert.match(response.headers.get("content-type") || "", /text\/event-stream/);
const body = await readAll(response);
assert.match(
body,
/data: \{"id":"chatcmpl-keepalive","object":"chat\.completion\.chunk"/
);
assert.match(body, /data: \{"id":"chatcmpl-keepalive","object":"chat\.completion\.chunk"/);
assert.match(body, /OK/);
assert.match(body, /\[DONE\]/);
});
@@ -286,7 +283,7 @@ test("chat completions route returns JSON without early SSE framing when stream
await seedHealthyConnection();
globalThis.fetch = async () => {
await new Promise((resolve) => setTimeout(resolve, 2200));
await new Promise((resolve) => setTimeout(resolve, 100));
return Response.json({
id: "chatcmpl-slow-json",
choices: [

View File

@@ -84,9 +84,9 @@ function ensureLegacyMemoryTable() {
`);
}
async function waitForAsyncMemoryFlush() {
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setTimeout(resolve, 10));
async function flushAsyncSideEffects() {
// setImmediate rounds drain the event loop more reliably than setTimeout under CI load.
for (let i = 0; i < 5; i++) await new Promise((resolve) => setImmediate(resolve));
}
async function invokeChatCore({
@@ -647,7 +647,7 @@ test("chatCore does not share or persist memories when apiKeyInfo is missing", a
},
});
await waitForAsyncMemoryFlush();
await flushAsyncSideEffects();
const localMemoriesResult = await listMemories({ apiKeyId: "local" });
const localMemories = Array.isArray(localMemoriesResult)
@@ -751,7 +751,7 @@ test("chatCore extracts memories from Claude content arrays and Responses output
assert.equal(responsesResult.result.success, true);
await waitForAsyncMemoryFlush();
await flushAsyncSideEffects();
const claudeMemoriesResult = await listMemories({ apiKeyId: claudeKeyId });
const responsesMemoriesResult = await listMemories({ apiKeyId: responsesKeyId });
@@ -819,7 +819,7 @@ test("chatCore request memory extraction for responses input ignores assistant i
assert.equal(responsesResult.result.success, true);
await waitForAsyncMemoryFlush();
await flushAsyncSideEffects();
const memoriesResult = await listMemories({ apiKeyId: responsesKeyId });
const memories = Array.isArray(memoriesResult) ? memoriesResult : (memoriesResult.data ?? []);

View File

@@ -98,6 +98,23 @@ test("AgentRouter explicit connection protocol overrides the inferred inbound pr
assert.equal(r.targetFormat, FORMATS.CLAUDE);
});
test("#8994: customModelTargetFormat takes precedence over apiFormat='responses'", () => {
// When a Vertex Claude model has customModelTargetFormat="claude" and the
// handler also receives apiFormat="responses", the model-level override
// must win — otherwise the request body is translated to OpenAI Responses
// format (which Vertex's Claude endpoint cannot parse).
const r = resolveChatCoreTargetFormat({
provider: "vertex",
resolvedModel: "claude-sonnet-4-6",
apiFormat: "responses",
sourceFormat: FORMATS.OPENAI,
customModelTargetFormat: "claude",
providerSpecificData: undefined,
});
// BUG: apiFormat short-circuits before customModelTargetFormat is checked
assert.equal(r.targetFormat, "claude", "model-level targetFormat must win over apiFormat");
});
test("unmapped provider → alias falls back to the provider id", () => {
const r = resolveChatCoreTargetFormat({
provider: "some-unmapped-provider",

View File

@@ -287,9 +287,9 @@ async function waitFor(fn, timeoutMs = 30000) {
return null;
}
async function waitForAsyncSideEffects() {
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setTimeout(resolve, 10));
async function flushAsyncSideEffects() {
// setImmediate rounds drain the event loop more reliably than setTimeout under CI load.
for (let i = 0; i < 5; i++) await new Promise((resolve) => setImmediate(resolve));
}
async function getLatestCallLog() {
@@ -363,7 +363,7 @@ async function invokeChatCore({
onCredentialsRefreshed,
onRequestSuccess,
} as any);
await waitForAsyncSideEffects();
await flushAsyncSideEffects();
return { result, calls, call: calls.at(-1) };
} finally {
@@ -376,7 +376,7 @@ test.afterEach(async () => {
restorePipelineCaptureEnv();
clearPendingRequests();
resetAccountSemaphores();
await waitForAsyncSideEffects();
await flushAsyncSideEffects();
await resetStorage();
});
@@ -385,7 +385,7 @@ test.after(async () => {
restorePipelineCaptureEnv();
clearPendingRequests();
resetAccountSemaphores();
await waitForAsyncSideEffects();
await flushAsyncSideEffects();
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
@@ -443,7 +443,7 @@ test("chatCore times out upstream execution before provider response headers", a
assert.equal(pendingDetail?.providerRequest?.model, "gpt-4o-mini");
assert.deepEqual(pendingDetail?.providerRequest?.messages, body.messages);
const result = await invocation;
await waitForAsyncSideEffects();
await flushAsyncSideEffects();
assert.equal(upstreamBodies[0]?.model, "gpt-4o-mini");
assert.deepEqual(upstreamBodies[0]?.messages, body.messages);
@@ -472,7 +472,7 @@ test("chatCore can disable pipeline stream chunk capture through environment", a
assert.equal(result.success, true);
await result.response.text();
await waitForAsyncSideEffects();
await flushAsyncSideEffects();
const detail = await waitFor(getLatestCallLog);
assert.ok(detail, "expected call log detail to be persisted");
@@ -1702,7 +1702,7 @@ test("chatCore returns a semantic cache HIT for repeated deterministic requests"
const payload = (await second.result.response.json()) as any;
assert.equal(payload.choices[0].message.content, "cached-once");
await waitForAsyncSideEffects();
await flushAsyncSideEffects();
const semanticLog = await waitFor(async () => {
const rows = await getCallLogs({ limit: 10 });
const hit = rows.find((row) => row.cacheSource === "semantic");
@@ -2632,7 +2632,7 @@ test("chatCore releases account semaphore slots when upstream execution throws",
},
});
await waitForAsyncSideEffects();
await flushAsyncSideEffects();
assert.equal(result.success, false);
assert.equal(result.status, 502);
@@ -2709,7 +2709,7 @@ test("chatCore caches streaming response and serves cache HIT on repeat", async
assert.equal(first.result.success, true);
// Consume the stream to trigger onStreamComplete and cache write
await first.result.response.text();
await waitForAsyncSideEffects();
await flushAsyncSideEffects();
// Second request with same body should get cache HIT (JSON, not SSE)
const second = await invokeChatCore({
@@ -2762,7 +2762,7 @@ test("chatCore does not cache streaming response when temperature > 0", async ()
});
await first.result.response.text();
await waitForAsyncSideEffects();
await flushAsyncSideEffects();
const second = await invokeChatCore({
provider: "openai",
@@ -2804,7 +2804,7 @@ test("chatCore skips streaming cache when X-OmniRoute-No-Cache header is set", a
});
await first.result.response.text();
await waitForAsyncSideEffects();
await flushAsyncSideEffects();
// Verify nothing was cached
const sig = generateSignature("gpt-4o-mini", sharedBody.messages, 0, 1);

View File

@@ -143,9 +143,9 @@ async function resetStorage() {
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
async function waitForAsyncSideEffects() {
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setTimeout(resolve, 10));
async function flushAsyncSideEffects() {
// setImmediate rounds drain the event loop more reliably than setTimeout under CI load.
for (let i = 0; i < 5; i++) await new Promise((resolve) => setImmediate(resolve));
}
async function invokeChatCore({
@@ -192,7 +192,7 @@ async function invokeChatCore({
},
userAgent: "unit-test",
});
await waitForAsyncSideEffects();
await flushAsyncSideEffects();
return { result, calls, call: calls.at(-1) };
} finally {
globalThis.fetch = originalFetch;

View File

@@ -0,0 +1,144 @@
/**
* #8946 — "No tool output found" for custom tool calls (Codex desktop)
*
* Compaction Layer-3 purify_history drops oldest messages. The restore path's
* orphan-call cleanup only removed function_call items whose outputs vanished.
* custom_tool_call / local_shell_call / apply_patch_call were left orphaned,
* causing a 400 from the upstream Responses API.
*
* These tests pin the fix: the compaction restore co-drops any tool-call item
* whose output was removed, mirroring the existing function_call logic.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { adaptBodyForCompression } from "../../../open-sse/services/compression/bodyAdapter.ts";
import { compressContext, estimateTokens } from "../../../open-sse/services/contextManager.ts";
function isRecord(v: unknown): v is Record<string, unknown> {
return !!v && typeof v === "object" && !Array.isArray(v);
}
const TOOL_CALL_TYPES = new Set([
"function_call",
"custom_tool_call",
"local_shell_call",
"apply_patch_call",
]);
const OUTPUT_TYPES = new Set([
"function_call_output",
"custom_tool_call_output",
"local_shell_call_output",
"apply_patch_call_output",
]);
/**
* Scan restored input for orphaned tool calls (a call item whose matching
* output is absent). Returns an array of descriptive strings, empty = clean.
*/
function findOrphanToolCalls(input: unknown[]): string[] {
const orphans: string[] = [];
for (const item of input) {
if (!isRecord(item)) continue;
if (!TOOL_CALL_TYPES.has(String(item.type))) continue;
const callId = typeof item.call_id === "string" && item.call_id.length > 0 ? item.call_id : "";
if (!callId) continue;
const hasMatchingOutput = input.some(
(other) => isRecord(other) && OUTPUT_TYPES.has(String(other.type)) && other.call_id === callId
);
if (!hasMatchingOutput) {
orphans.push(`${String(item.type)} ${callId}`);
}
}
return orphans.sort();
}
/**
* Build a Responses body with N tool-using turns.
* Each turn: tool_call + tool_call_output + assistant message + user message.
* The user messages carry substantial text to survive Layer-1 trim_tools and
* force Layer-3 purify_history to engage.
*/
function buildToolTurnBody(
numTurns: number,
outputText: string,
userText: string
): { input: Record<string, unknown>[] } {
const input: Record<string, unknown>[] = [];
for (let i = 0; i < numTurns; i++) {
// Each turn has one of each tool call type, cycling through them
const toolTypes: Array<{
callType: string;
outputType: string;
name: string;
}> = [
{ callType: "custom_tool_call", outputType: "custom_tool_call_output", name: "my_tool" },
{ callType: "function_call", outputType: "function_call_output", name: "run_command" },
{ callType: "local_shell_call", outputType: "local_shell_call_output", name: "run_shell" },
{ callType: "apply_patch_call", outputType: "apply_patch_call_output", name: "apply_diff" },
];
const t = toolTypes[i % toolTypes.length];
const callId = `${t.callType}-${i}`;
input.push({ type: t.callType, call_id: callId, name: t.name, arguments: "{}" });
input.push({ type: t.outputType, call_id: callId, output: outputText });
input.push({
type: "message",
role: "assistant",
content: [{ type: "output_text", text: `response ${i}` }],
});
input.push({
type: "message",
role: "user",
content: [{ type: "input_text", text: `${userText} turn ${i}` }],
});
}
return { input };
}
test("#8946: compaction drops orphan custom_tool_call / local_shell_call / apply_patch_call with vanished outputs", () => {
// Build many turns: user messages carry enough text to survive Layer-1
// trim_tools (which only trims role:"tool" content) so the aggregate
// token count still exceeds the compact target after trimming, forcing
// Layer-3 purify_history to engage.
const outputText = "result: ok";
const userText = "x".repeat(3_000); // ~750 tokens each
const body = buildToolTurnBody(8, outputText, userText);
const adapter = adaptBodyForCompression(body);
assert.equal(adapter.adapted, true);
// Calculate before so we can set a target that forces Layer-3.
const before = estimateTokens(adapter.body.messages as Record<string, unknown>[]);
const target = Math.max(5_000, Math.floor(before * 0.5));
const result = compressContext(adapter.body as Record<string, unknown>, {
provider: "codex",
model: "gpt-5.6-terra",
maxTokens: target,
reserveTokens: 0,
});
assert.equal(
result.compressed,
true,
`compression should engage (before=${before}, target=${target}, stats=${JSON.stringify(result.stats)})`
);
const restored = adapter.restore(result.body as Record<string, unknown>, {
dropMissingMappedItems: true,
});
const input = Array.isArray(restored.input) ? restored.input : [];
const orphans = findOrphanToolCalls(input);
// Before the fix: custom_tool_call, local_shell_call, apply_patch_call
// orphans survive. After the fix: none survive.
assert.equal(
orphans.length,
0,
`restored input contains orphaned tool calls whose outputs were dropped: ${JSON.stringify(orphans)} ` +
`(restored input length: ${input.length})`
);
});

View File

@@ -382,6 +382,6 @@ test("aborting the client signal stops the keepalive stream (#2544)", async () =
if (done) return true;
}
})();
const timed = new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 500));
const timed = new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 5000));
assert.equal(await Promise.race([drained, timed]), true, "stream should close after abort");
});

View File

@@ -90,11 +90,15 @@ test("VertexExecutor.buildUrl routes partner and org-prefixed models to the glob
);
});
test("VertexExecutor.buildUrl routes current-generation Claude models to the global partner endpoint (#1985)", () => {
test("VertexExecutor.buildUrl routes current-generation Claude models to the native Anthropic rawPredict endpoint (#1985, #8994)", () => {
const executor = new VertexExecutor();
// These model IDs post-date the old pinned "claude-3-5-sonnet" / "claude-3-opus" /
// "claude-3-haiku" prefixes and were previously misrouted to the Google-publisher path.
// "claude-3-haiku" prefixes and were previously misrouted to the Google-publisher path,
// then (once generalized to a "claude-" prefix, #1985) to the generic OpenAI-compatible
// partner endpoint. Claude models use Vertex's native Anthropic Messages API
// (publishers/anthropic/.../rawPredict) instead — the partner endpoint 404s/"malformed
// argument"s for Claude on at least some projects.
const claude4Sonnet = executor.buildUrl("claude-sonnet-4-6", false, 0, {
apiKey: createServiceAccountJson({ projectId: "proj-claude" }),
});
@@ -104,11 +108,11 @@ test("VertexExecutor.buildUrl routes current-generation Claude models to the glo
assert.equal(
claude4Sonnet,
"https://aiplatform.googleapis.com/v1/projects/proj-claude/locations/global/endpoints/openapi/chat/completions"
"https://aiplatform.googleapis.com/v1/projects/proj-claude/locations/us-central1/publishers/anthropic/models/claude-sonnet-4-6:rawPredict"
);
assert.equal(
claude4Haiku,
"https://aiplatform.googleapis.com/v1/projects/proj-claude/locations/global/endpoints/openapi/chat/completions"
"https://aiplatform.googleapis.com/v1/projects/proj-claude/locations/us-central1/publishers/anthropic/models/claude-haiku-4-5@20251001:rawPredict"
);
});

View File

@@ -0,0 +1,87 @@
/**
* TDD for #9435 — Kiro import token endpoint overwrites existing connection
* instead of creating new one for Builder ID / social imports.
*
* Root cause: `findKiroConnectionByIdentity` matches by cached OIDC `clientId`
* before `email`. When importing a second Builder ID token, the shared machine-wide
* cached `clientId` matches the FIRST connection instead of creating a new one.
*
* The fix: do NOT pass `clientId` in the identity object for Non-IDC (Builder ID /
* social) imports at the route level, so the fallback to email-based matching works.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { findKiroConnectionByIdentity } from "../../src/lib/oauth/kiroConnectionIdentity.js";
// ── Unit-level repro: the function matches by shared clientId before email ──────
// These two connections simulate two distinct Kiro Builder ID accounts on the same
// machine. They share a cached OIDC clientId but have different emails.
const aliceAndBob = [
{
id: "conn-alice",
authType: "oauth",
email: "alice@example.com",
providerSpecificData: { clientId: "shared-cached-cid" },
},
{
id: "conn-bob",
authType: "oauth",
email: "bob@example.com",
providerSpecificData: { clientId: "shared-cached-cid" },
},
];
test("#9435 findKiroConnectionByIdentity with shared clientId + distinct emails: when BOTH clientId and email are passed, clientId match wins (the bug)", () => {
// Searching with the shared cached clientId + Bob's email.
// The function checks clientId FIRST so it returns conn-alice (first match by
// shared clientId), even though conn-bob is the correct one (email match).
const match = findKiroConnectionByIdentity(aliceAndBob, {
clientId: "shared-cached-cid",
email: "bob@example.com",
});
assert.equal(
match?.id,
"conn-alice",
`BUG: expected conn-alice (first match by shared clientId), got: ${match?.id}`
);
});
test("#9435 findKiroConnectionByIdentity with ONLY email (no clientId): correctly finds Bob by email", () => {
// When clientId is NOT in the identity (as the fix does for non-IDC imports),
// the function falls through to email matching and finds the right connection.
const match = findKiroConnectionByIdentity(aliceAndBob, {
email: "bob@example.com",
});
assert.equal(match?.id, "conn-bob", `expected conn-bob (email match), got: ${match?.id}`);
});
test("#9435 findKiroConnectionByIdentity with ONLY email for Alice: correctly finds Alice by email", () => {
const match = findKiroConnectionByIdentity(aliceAndBob, {
email: "alice@example.com",
});
assert.equal(match?.id, "conn-alice", `expected conn-alice (email match), got: ${match?.id}`);
});
test("#9435 findKiroConnectionByIdentity with shared clientId + new email (no match): returns null", () => {
// A third user with no existing connection should get null (create new connection)
const match = findKiroConnectionByIdentity(aliceAndBob, {
clientId: "shared-cached-cid",
email: "charlie@example.com",
});
// With clientId in the identity, it matches conn-alice (by shared clientId)
// instead of returning null — this IS the bug.
assert.equal(
match?.id,
"conn-alice",
`BUG: expected conn-alice (first match by shared clientId), got: ${match?.id} — charlie is new, should not match any`
);
});
test("#9435 findKiroConnectionByIdentity with ONLY email (no clientId) for new user: correctly returns null (create new)", () => {
// Without clientId, the function checks email and finds no match → null = create new
const match = findKiroConnectionByIdentity(aliceAndBob, {
email: "charlie@example.com",
});
assert.equal(match, null, `expected null for new user when no clientId, got: ${match?.id}`);
});

View File

@@ -145,422 +145,424 @@ test.after(async () => {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("fetchModelsDev caches successful responses and rejects invalid JSON or non-ok responses", async () => {
const modelsDev = await importFresh("fetch-cache");
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return new Response(JSON.stringify(MOCK_MODELS_DEV_DATA), {
status: 200,
headers: { "content-type": "application/json" },
});
};
test.describe("modelsDevSync-extended", { concurrency: 1 }, async () => {
test("fetchModelsDev caches successful responses and rejects invalid JSON or non-ok responses", async () => {
const modelsDev = await importFresh("fetch-cache");
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return new Response(JSON.stringify(MOCK_MODELS_DEV_DATA), {
status: 200,
headers: { "content-type": "application/json" },
});
};
const first = await modelsDev.fetchModelsDev();
const second = await modelsDev.fetchModelsDev();
const first = await modelsDev.fetchModelsDev();
const second = await modelsDev.fetchModelsDev();
assert.strictEqual(first, second);
assert.equal(calls, 1);
assert.strictEqual(first, second);
assert.equal(calls, 1);
const invalid = await importFresh("fetch-invalid-json");
mockFetchWith("not-json");
await assert.rejects(() => invalid.fetchModelsDev(), /invalid JSON/);
const invalid = await importFresh("fetch-invalid-json");
mockFetchWith("not-json");
await assert.rejects(() => invalid.fetchModelsDev(), /invalid JSON/);
const nonOk = await importFresh("fetch-non-ok");
mockFetchWith({ error: "boom" }, 503, "Service Unavailable");
await assert.rejects(() => nonOk.fetchModelsDev(), /models\.dev fetch failed \[503\]/);
});
test("modelsDev interval falls back to the default when env values are invalid or non-positive", async () => {
process.env.MODELS_DEV_SYNC_INTERVAL = "0";
const zeroInterval = await importFresh("interval-zero");
zeroInterval.startPeriodicSync();
assert.equal(zeroInterval.getSyncStatus().intervalMs, 86400 * 1000);
zeroInterval.stopPeriodicSync();
process.env.MODELS_DEV_SYNC_INTERVAL = "not-a-number";
const invalidInterval = await importFresh("interval-invalid");
invalidInterval.startPeriodicSync();
assert.equal(invalidInterval.getSyncStatus().intervalMs, 86400 * 1000);
invalidInterval.stopPeriodicSync();
});
test("transform helpers skip incomplete pricing entries and preserve partial capability defaults", async () => {
const modelsDev = await importFresh("transform-edge-cases");
const raw = {
sparse: {
id: "sparse",
models: {
"missing-cost": {
id: "missing-cost",
name: "Missing Cost",
},
"missing-input": {
id: "missing-input",
name: "Missing Input",
cost: { output: 4.2 },
},
complete: {
id: "complete",
name: "Complete",
cost: { input: 1.5 },
interleaved: { field: "" },
},
},
},
nomodels: {
id: "nomodels",
},
};
const pricing = modelsDev.transformModelsDevToPricing(raw);
assert.deepEqual(pricing.sparse, {
complete: {
input: 1.5,
output: 0,
},
const nonOk = await importFresh("fetch-non-ok");
mockFetchWith({ error: "boom" }, 503, "Service Unavailable");
await assert.rejects(() => nonOk.fetchModelsDev(), /models\.dev fetch failed \[503\]/);
});
assert.equal(pricing.nomodels, undefined);
const capabilities = modelsDev.transformModelsDevToCapabilities(raw);
assert.equal(capabilities.sparse.complete.tool_call, null);
assert.equal(capabilities.sparse.complete.reasoning, null);
assert.equal(capabilities.sparse.complete.attachment, null);
assert.equal(capabilities.sparse.complete.structured_output, null);
assert.equal(capabilities.sparse.complete.temperature, null);
assert.equal(capabilities.sparse.complete.modalities_input, "[]");
assert.equal(capabilities.sparse.complete.modalities_output, "[]");
assert.equal(capabilities.sparse.complete.limit_context, null);
assert.equal(capabilities.sparse.complete.limit_input, null);
assert.equal(capabilities.sparse.complete.limit_output, null);
assert.equal(capabilities.sparse.complete.interleaved_field, null);
assert.equal(capabilities.nomodels, undefined);
});
test("modelsDev interval falls back to the default when env values are invalid or non-positive", async () => {
process.env.MODELS_DEV_SYNC_INTERVAL = "0";
const zeroInterval = await importFresh("interval-zero");
zeroInterval.startPeriodicSync();
assert.equal(zeroInterval.getSyncStatus().intervalMs, 86400 * 1000);
zeroInterval.stopPeriodicSync();
test("modelsDev pricing helpers persist records, skip corrupted rows, and clear the namespace", async () => {
const modelsDev = await importFresh("pricing-storage");
const pricing = modelsDev.transformModelsDevToPricing(MOCK_MODELS_DEV_DATA);
process.env.MODELS_DEV_SYNC_INTERVAL = "not-a-number";
const invalidInterval = await importFresh("interval-invalid");
invalidInterval.startPeriodicSync();
assert.equal(invalidInterval.getSyncStatus().intervalMs, 86400 * 1000);
invalidInterval.stopPeriodicSync();
});
modelsDev.saveModelsDevPricing(pricing);
const saved = modelsDev.getModelsDevPricing();
assert.equal(saved.openai["gpt-4o"].input, 2.5);
assert.equal(saved.cx["gpt-4o"].cache_creation, 2.5);
const db = core.getDbInstance();
db.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
"models_dev_pricing",
"corrupted",
"{oops"
);
const withCorruption = modelsDev.getModelsDevPricing();
assert.equal(withCorruption.corrupted, undefined);
modelsDev.clearModelsDevPricing();
assert.deepEqual(modelsDev.getModelsDevPricing(), {});
});
test("modelsDev capabilities helpers create the table, persist rows, filter by provider/model, and expose context limits", async () => {
const modelsDev = await importFresh("capabilities-storage");
const capabilities = modelsDev.transformModelsDevToCapabilities(MOCK_MODELS_DEV_DATA);
modelsDev.ensureCapabilitiesTable();
modelsDev.saveModelsDevCapabilities(capabilities);
const allCaps = modelsDev.getSyncedCapabilities();
const openaiOnly = modelsDev.getSyncedCapabilities("openai", "gpt-4o");
assert.equal(allCaps.openai["gpt-4o"].tool_call, true);
assert.equal(allCaps.anthropic["claude-sonnet-4-20250514"].attachment, true);
assert.deepEqual(Object.keys(openaiOnly), ["openai"]);
assert.equal(openaiOnly.openai["gpt-4o"].limit_context, 128000);
assert.equal("getModelContextLimit" in modelsDev, false);
modelsDev.clearModelsDevCapabilities();
assert.deepEqual(modelsDev.getSyncedCapabilities(), {});
});
test("modelsDev capability helpers coerce false/null values and ignore malformed rows", async () => {
const modelsDev = await importFresh("capabilities-malformed");
const db = core.getDbInstance();
const originalPrepare = db.prepare.bind(db);
db.prepare = (sql) => {
if (String(sql).includes("SELECT * FROM model_capabilities")) {
return {
all: () => [
123,
{ provider: null, model_id: "missing-provider" },
{
provider: "openai",
model_id: "coerced-model",
tool_call: 0,
reasoning: null,
attachment: 0,
structured_output: 0,
temperature: 0,
modalities_input: null,
modalities_output: 42,
knowledge_cutoff: 77,
release_date: 88,
last_updated: 99,
status: 123,
family: 456,
open_weights: null,
limit_context: "bad",
limit_input: 4096,
limit_output: "nope",
interleaved_field: 321,
test("transform helpers skip incomplete pricing entries and preserve partial capability defaults", async () => {
const modelsDev = await importFresh("transform-edge-cases");
const raw = {
sparse: {
id: "sparse",
models: {
"missing-cost": {
id: "missing-cost",
name: "Missing Cost",
},
],
};
}
return originalPrepare(sql);
};
"missing-input": {
id: "missing-input",
name: "Missing Input",
cost: { output: 4.2 },
},
complete: {
id: "complete",
name: "Complete",
cost: { input: 1.5 },
interleaved: { field: "" },
},
},
},
nomodels: {
id: "nomodels",
},
};
try {
const openai = modelsDev.getSyncedCapabilities("openai");
assert.deepEqual(openai.openai["coerced-model"], {
tool_call: false,
reasoning: null,
attachment: false,
structured_output: false,
temperature: false,
modalities_input: "[]",
modalities_output: "[]",
knowledge_cutoff: null,
release_date: null,
last_updated: null,
status: null,
family: null,
open_weights: null,
limit_context: null,
limit_input: 4096,
limit_output: null,
interleaved_field: null,
const pricing = modelsDev.transformModelsDevToPricing(raw);
assert.deepEqual(pricing.sparse, {
complete: {
input: 1.5,
output: 0,
},
});
assert.equal(pricing.nomodels, undefined);
const all = modelsDev.getSyncedCapabilities();
assert.equal(all["7"], undefined);
assert.equal(all.openai["missing-provider"], undefined);
} finally {
db.prepare = originalPrepare;
}
});
const capabilities = modelsDev.transformModelsDevToCapabilities(raw);
assert.equal(capabilities.sparse.complete.tool_call, null);
assert.equal(capabilities.sparse.complete.reasoning, null);
assert.equal(capabilities.sparse.complete.attachment, null);
assert.equal(capabilities.sparse.complete.structured_output, null);
assert.equal(capabilities.sparse.complete.temperature, null);
assert.equal(capabilities.sparse.complete.modalities_input, "[]");
assert.equal(capabilities.sparse.complete.modalities_output, "[]");
assert.equal(capabilities.sparse.complete.limit_context, null);
assert.equal(capabilities.sparse.complete.limit_input, null);
assert.equal(capabilities.sparse.complete.limit_output, null);
assert.equal(capabilities.sparse.complete.interleaved_field, null);
assert.equal(capabilities.nomodels, undefined);
});
test("modelsDev pricing helpers ignore malformed sqlite rows without crashing", async () => {
const modelsDev = await importFresh("pricing-malformed");
const db = core.getDbInstance();
const originalPrepare = db.prepare.bind(db);
db.prepare = (sql) => {
if (String(sql).includes("SELECT key, value FROM key_value")) {
return {
all: () => [
123,
{ key: 123, value: JSON.stringify({ ignored: true }) },
{ key: "missing-value", value: 456 },
{ key: "broken", value: "{oops" },
{ key: "openai", value: JSON.stringify({ "gpt-4o": { input: 2.5, output: 10 } }) },
],
};
test("modelsDev pricing helpers persist records, skip corrupted rows, and clear the namespace", async () => {
const modelsDev = await importFresh("pricing-storage");
const pricing = modelsDev.transformModelsDevToPricing(MOCK_MODELS_DEV_DATA);
modelsDev.saveModelsDevPricing(pricing);
const saved = modelsDev.getModelsDevPricing();
assert.equal(saved.openai["gpt-4o"].input, 2.5);
assert.equal(saved.cx["gpt-4o"].cache_creation, 2.5);
const db = core.getDbInstance();
db.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
"models_dev_pricing",
"corrupted",
"{oops"
);
const withCorruption = modelsDev.getModelsDevPricing();
assert.equal(withCorruption.corrupted, undefined);
modelsDev.clearModelsDevPricing();
assert.deepEqual(modelsDev.getModelsDevPricing(), {});
});
test("modelsDev capabilities helpers create the table, persist rows, filter by provider/model, and expose context limits", async () => {
const modelsDev = await importFresh("capabilities-storage");
const capabilities = modelsDev.transformModelsDevToCapabilities(MOCK_MODELS_DEV_DATA);
modelsDev.ensureCapabilitiesTable();
modelsDev.saveModelsDevCapabilities(capabilities);
const allCaps = modelsDev.getSyncedCapabilities();
const openaiOnly = modelsDev.getSyncedCapabilities("openai", "gpt-4o");
assert.equal(allCaps.openai["gpt-4o"].tool_call, true);
assert.equal(allCaps.anthropic["claude-sonnet-4-20250514"].attachment, true);
assert.deepEqual(Object.keys(openaiOnly), ["openai"]);
assert.equal(openaiOnly.openai["gpt-4o"].limit_context, 128000);
assert.equal("getModelContextLimit" in modelsDev, false);
modelsDev.clearModelsDevCapabilities();
assert.deepEqual(modelsDev.getSyncedCapabilities(), {});
});
test("modelsDev capability helpers coerce false/null values and ignore malformed rows", async () => {
const modelsDev = await importFresh("capabilities-malformed");
const db = core.getDbInstance();
const originalPrepare = db.prepare.bind(db);
db.prepare = (sql) => {
if (String(sql).includes("SELECT * FROM model_capabilities")) {
return {
all: () => [
123,
{ provider: null, model_id: "missing-provider" },
{
provider: "openai",
model_id: "coerced-model",
tool_call: 0,
reasoning: null,
attachment: 0,
structured_output: 0,
temperature: 0,
modalities_input: null,
modalities_output: 42,
knowledge_cutoff: 77,
release_date: 88,
last_updated: 99,
status: 123,
family: 456,
open_weights: null,
limit_context: "bad",
limit_input: 4096,
limit_output: "nope",
interleaved_field: 321,
},
],
};
}
return originalPrepare(sql);
};
try {
const openai = modelsDev.getSyncedCapabilities("openai");
assert.deepEqual(openai.openai["coerced-model"], {
tool_call: false,
reasoning: null,
attachment: false,
structured_output: false,
temperature: false,
modalities_input: "[]",
modalities_output: "[]",
knowledge_cutoff: null,
release_date: null,
last_updated: null,
status: null,
family: null,
open_weights: null,
limit_context: null,
limit_input: 4096,
limit_output: null,
interleaved_field: null,
});
const all = modelsDev.getSyncedCapabilities();
assert.equal(all["7"], undefined);
assert.equal(all.openai["missing-provider"], undefined);
} finally {
db.prepare = originalPrepare;
}
return originalPrepare(sql);
};
});
try {
assert.deepEqual(modelsDev.getModelsDevPricing(), {
test("modelsDev pricing helpers ignore malformed sqlite rows without crashing", async () => {
const modelsDev = await importFresh("pricing-malformed");
const db = core.getDbInstance();
const originalPrepare = db.prepare.bind(db);
db.prepare = (sql) => {
if (String(sql).includes("SELECT key, value FROM key_value")) {
return {
all: () => [
123,
{ key: 123, value: JSON.stringify({ ignored: true }) },
{ key: "missing-value", value: 456 },
{ key: "broken", value: "{oops" },
{ key: "openai", value: JSON.stringify({ "gpt-4o": { input: 2.5, output: 10 } }) },
],
};
}
return originalPrepare(sql);
};
try {
assert.deepEqual(modelsDev.getModelsDevPricing(), {
openai: {
"gpt-4o": {
input: 2.5,
output: 10,
},
},
});
} finally {
db.prepare = originalPrepare;
}
});
test("saveModelsDevCapabilities round-trips false and null booleans", async () => {
const modelsDev = await importFresh("capabilities-roundtrip-falsey");
modelsDev.saveModelsDevCapabilities({
openai: {
"gpt-4o": {
input: 2.5,
output: 10,
"gpt-falsey": {
tool_call: false,
reasoning: null,
attachment: false,
structured_output: false,
temperature: null,
modalities_input: "[]",
modalities_output: '["text"]',
knowledge_cutoff: null,
release_date: null,
last_updated: null,
status: null,
family: null,
open_weights: false,
limit_context: null,
limit_input: null,
limit_output: 1024,
interleaved_field: null,
},
},
});
} finally {
db.prepare = originalPrepare;
}
});
test("saveModelsDevCapabilities round-trips false and null booleans", async () => {
const modelsDev = await importFresh("capabilities-roundtrip-falsey");
modelsDev.saveModelsDevCapabilities({
openai: {
"gpt-falsey": {
tool_call: false,
reasoning: null,
attachment: false,
structured_output: false,
temperature: null,
modalities_input: "[]",
modalities_output: '["text"]',
knowledge_cutoff: null,
release_date: null,
last_updated: null,
status: null,
family: null,
open_weights: false,
limit_context: null,
limit_input: null,
limit_output: 1024,
interleaved_field: null,
assert.deepEqual(modelsDev.getSyncedCapabilities("openai", "gpt-falsey"), {
openai: {
"gpt-falsey": {
tool_call: false,
reasoning: null,
attachment: false,
structured_output: false,
temperature: null,
modalities_input: "[]",
modalities_output: '["text"]',
knowledge_cutoff: null,
release_date: null,
last_updated: null,
status: null,
family: null,
open_weights: false,
limit_context: null,
limit_input: null,
limit_output: 1024,
interleaved_field: null,
},
},
},
});
assert.deepEqual(modelsDev.getSyncedCapabilities("openai", "gpt-falsey"), {
openai: {
"gpt-falsey": {
tool_call: false,
reasoning: null,
attachment: false,
structured_output: false,
temperature: null,
modalities_input: "[]",
modalities_output: '["text"]',
knowledge_cutoff: null,
release_date: null,
last_updated: null,
status: null,
family: null,
open_weights: false,
limit_context: null,
limit_input: null,
limit_output: 1024,
interleaved_field: null,
},
},
});
});
test("syncModelsDev supports dry-run mode, persistence, capability toggles, and failure reporting", async () => {
const modelsDev = await importFresh("sync-main");
mockFetchWith(MOCK_MODELS_DEV_DATA);
const dryRun = await modelsDev.syncModelsDev({ dryRun: true, syncCapabilities: false });
assert.equal(dryRun.success, true);
assert.equal(dryRun.dryRun, true);
assert.equal(dryRun.capabilityCount, 0);
assert.ok(dryRun.data.pricing.openai["gpt-4o"]);
assert.deepEqual(modelsDev.getModelsDevPricing(), {});
const persisted = await modelsDev.syncModelsDev();
assert.equal(persisted.success, true);
assert.equal(persisted.dryRun, false);
assert.ok(persisted.modelCount > 0);
assert.ok(modelsDev.getModelsDevPricing().anthropic["claude-sonnet-4-20250514"]);
assert.ok(modelsDev.getSyncedCapabilities().openai["gpt-4o"]);
assert.ok(modelsDev.getSyncStatus().lastSync);
assert.ok(modelsDev.getSyncStatus().lastSyncModelCount > 0);
const failing = await importFresh("sync-failure");
globalThis.fetch = async () => {
throw new Error("network down");
};
const failed = await failing.syncModelsDev();
assert.equal(failed.success, false);
assert.match(failed.error, /network down/);
});
test("syncModelsDev string failures are normalized into an error payload", async () => {
const modelsDev = await importFresh("sync-string-error");
globalThis.fetch = async () => {
throw "hard fail";
};
const failed = await modelsDev.syncModelsDev({ dryRun: true });
assert.equal(failed.success, false);
assert.equal(failed.error, "hard fail");
assert.equal(failed.dryRun, true);
});
test("syncModelsDev honors abort signals during retry backoff", async () => {
const modelsDev = await importFresh("sync-abort");
const warnings = [];
const originalWarn = console.warn;
console.warn = (...args) => warnings.push(args.map((arg) => String(arg)).join(" "));
globalThis.fetch = async () => {
throw new Error("network down");
};
try {
const controller = new AbortController();
const pending = modelsDev.syncModelsDev({ signal: controller.signal, maxRetries: 3 });
const warned = await waitFor(() => warnings.length > 0, 100);
assert.ok(warned, "expected the first retry warning before aborting");
controller.abort();
const aborted = await pending;
assert.equal(aborted.success, false);
assert.equal(aborted.error, "aborted");
assert.equal(warnings.length, 1);
} finally {
console.warn = originalWarn;
}
});
test("startPeriodicSync, stopPeriodicSync, getSyncStatus, and initModelsDevSync honor settings and avoid duplicate timers", async () => {
const modelsDev = await importFresh("periodic-sync");
mockFetchWith(MOCK_MODELS_DEV_DATA);
modelsDev.startPeriodicSync(25);
const started = modelsDev.getSyncStatus();
assert.equal(started.enabled, true);
assert.equal(started.intervalMs, 25);
modelsDev.startPeriodicSync(99);
assert.equal(modelsDev.getSyncStatus().intervalMs, 25);
const syncedAt = await waitFor(() => modelsDev.getSyncStatus().lastSync, 2000);
assert.ok(syncedAt, "expected initial periodic sync to complete");
assert.ok(modelsDev.getSyncStatus().nextSync);
modelsDev.stopPeriodicSync();
const stopped = modelsDev.getSyncStatus();
assert.equal(stopped.enabled, false);
assert.equal(stopped.nextSync, null);
await settingsDb.updateSettings({
modelsDevSyncEnabled: false,
modelsDevSyncInterval: 15,
});
const disabled = await importFresh("init-disabled");
await disabled.initModelsDevSync();
assert.equal(disabled.getSyncStatus().enabled, false);
await settingsDb.updateSettings({
modelsDevSyncEnabled: true,
modelsDevSyncInterval: 15,
});
const enabled = await importFresh("init-enabled");
mockFetchWith(MOCK_MODELS_DEV_DATA);
await enabled.initModelsDevSync();
assert.equal(enabled.getSyncStatus().enabled, true);
assert.equal(enabled.getSyncStatus().intervalMs, 15);
await waitFor(() => enabled.getSyncStatus().lastSync, 2000);
});
test("stopPeriodicSync aborts the in-flight initial sync", async () => {
const modelsDev = await importFresh("periodic-stop-abort");
let aborted = false;
globalThis.fetch = async (_url, init) =>
await new Promise((_resolve, reject) => {
const signal = init?.signal;
const onAbort = () => {
aborted = true;
const error = new Error("aborted");
error.name = "AbortError";
reject(error);
};
signal?.addEventListener("abort", onAbort, { once: true });
});
});
modelsDev.startPeriodicSync(25);
await waitFor(() => modelsDev.getSyncStatus().enabled, 50);
modelsDev.stopPeriodicSync();
test("syncModelsDev supports dry-run mode, persistence, capability toggles, and failure reporting", async () => {
const modelsDev = await importFresh("sync-main");
mockFetchWith(MOCK_MODELS_DEV_DATA);
const stopped = await waitFor(() => aborted, 200);
assert.equal(stopped, true);
assert.equal(modelsDev.getSyncStatus().enabled, false);
assert.equal(modelsDev.getSyncStatus().lastSync, null);
const dryRun = await modelsDev.syncModelsDev({ dryRun: true, syncCapabilities: false });
assert.equal(dryRun.success, true);
assert.equal(dryRun.dryRun, true);
assert.equal(dryRun.capabilityCount, 0);
assert.ok(dryRun.data.pricing.openai["gpt-4o"]);
assert.deepEqual(modelsDev.getModelsDevPricing(), {});
const persisted = await modelsDev.syncModelsDev();
assert.equal(persisted.success, true);
assert.equal(persisted.dryRun, false);
assert.ok(persisted.modelCount > 0);
assert.ok(modelsDev.getModelsDevPricing().anthropic["claude-sonnet-4-20250514"]);
assert.ok(modelsDev.getSyncedCapabilities().openai["gpt-4o"]);
assert.ok(modelsDev.getSyncStatus().lastSync);
assert.ok(modelsDev.getSyncStatus().lastSyncModelCount > 0);
const failing = await importFresh("sync-failure");
globalThis.fetch = async () => {
throw new Error("network down");
};
const failed = await failing.syncModelsDev();
assert.equal(failed.success, false);
assert.match(failed.error, /network down/);
});
test("syncModelsDev string failures are normalized into an error payload", async () => {
const modelsDev = await importFresh("sync-string-error");
globalThis.fetch = async () => {
throw "hard fail";
};
const failed = await modelsDev.syncModelsDev({ dryRun: true });
assert.equal(failed.success, false);
assert.equal(failed.error, "hard fail");
assert.equal(failed.dryRun, true);
});
test("syncModelsDev honors abort signals during retry backoff", async () => {
const modelsDev = await importFresh("sync-abort");
const warnings = [];
const originalWarn = console.warn;
console.warn = (...args) => warnings.push(args.map((arg) => String(arg)).join(" "));
globalThis.fetch = async () => {
throw new Error("network down");
};
try {
const controller = new AbortController();
const pending = modelsDev.syncModelsDev({ signal: controller.signal, maxRetries: 3 });
const warned = await waitFor(() => warnings.length > 0, 100);
assert.ok(warned, "expected the first retry warning before aborting");
controller.abort();
const aborted = await pending;
assert.equal(aborted.success, false);
assert.equal(aborted.error, "aborted");
assert.equal(warnings.length, 1);
} finally {
console.warn = originalWarn;
}
});
test("startPeriodicSync, stopPeriodicSync, getSyncStatus, and initModelsDevSync honor settings and avoid duplicate timers", async () => {
const modelsDev = await importFresh("periodic-sync");
mockFetchWith(MOCK_MODELS_DEV_DATA);
modelsDev.startPeriodicSync(25);
const started = modelsDev.getSyncStatus();
assert.equal(started.enabled, true);
assert.equal(started.intervalMs, 25);
modelsDev.startPeriodicSync(99);
assert.equal(modelsDev.getSyncStatus().intervalMs, 25);
const syncedAt = await waitFor(() => modelsDev.getSyncStatus().lastSync, 2000);
assert.ok(syncedAt, "expected initial periodic sync to complete");
assert.ok(modelsDev.getSyncStatus().nextSync);
modelsDev.stopPeriodicSync();
const stopped = modelsDev.getSyncStatus();
assert.equal(stopped.enabled, false);
assert.equal(stopped.nextSync, null);
await settingsDb.updateSettings({
modelsDevSyncEnabled: false,
modelsDevSyncInterval: 15,
});
const disabled = await importFresh("init-disabled");
await disabled.initModelsDevSync();
assert.equal(disabled.getSyncStatus().enabled, false);
await settingsDb.updateSettings({
modelsDevSyncEnabled: true,
modelsDevSyncInterval: 15,
});
const enabled = await importFresh("init-enabled");
mockFetchWith(MOCK_MODELS_DEV_DATA);
await enabled.initModelsDevSync();
assert.equal(enabled.getSyncStatus().enabled, true);
assert.equal(enabled.getSyncStatus().intervalMs, 15);
await waitFor(() => enabled.getSyncStatus().lastSync, 2000);
});
test("stopPeriodicSync aborts the in-flight initial sync", async () => {
const modelsDev = await importFresh("periodic-stop-abort");
let aborted = false;
globalThis.fetch = async (_url, init) =>
await new Promise((_resolve, reject) => {
const signal = init?.signal;
const onAbort = () => {
aborted = true;
const error = new Error("aborted");
error.name = "AbortError";
reject(error);
};
signal?.addEventListener("abort", onAbort, { once: true });
});
modelsDev.startPeriodicSync(25);
await waitFor(() => modelsDev.getSyncStatus().enabled, 50);
modelsDev.stopPeriodicSync();
const stopped = await waitFor(() => aborted, 200);
assert.equal(stopped, true);
assert.equal(modelsDev.getSyncStatus().enabled, false);
assert.equal(modelsDev.getSyncStatus().lastSync, null);
});
});

View File

@@ -19,15 +19,21 @@ test("isVertexGeminiProvider matches only the vertex provider ids", () => {
assert.equal(h.isVertexGeminiProvider(undefined), false);
});
test("buildChangedToolNameMap keeps only renamed entries, else null", () => {
test("buildChangedToolNameMap includes all entries with lowercase aliases", () => {
const changed = h.buildChangedToolNameMap(
new Map([
["a", "a"],
["Bash", "Bash"],
["b_sanitized", "b"],
])
);
assert.deepEqual([...(changed ?? new Map()).entries()], [["b_sanitized", "b"]]);
assert.equal(h.buildChangedToolNameMap(new Map([["a", "a"]])), null);
const entries = [...(changed ?? new Map()).entries()];
// Identity entry ("Bash" → "Bash") is included, plus lowercase alias ("bash" → "Bash")
assert.ok(entries.some(([k]) => k === "Bash"));
assert.ok(entries.some(([k, v]) => k === "bash" && v === "Bash"));
// Renamed entry is included as before
assert.ok(entries.some(([k, v]) => k === "b_sanitized" && v === "b"));
// Empty map still returns null
assert.equal(h.buildChangedToolNameMap(new Map()), null);
});
test("extractClientThoughtSignature reads the first non-empty signature field", () => {

View File

@@ -0,0 +1,135 @@
// TDD verification for #9541 — DB corruption probe transient-error retry.
//
// RED: The repro confirms transient errors (BUSY, ENOENT, PROTOCOL, IOERR)
// fall through to the corruption-rename path (data loss confirmed).
// GREEN: After the fix, isTransientProbeError() exists in core.ts and correctly
// classifies transient vs fatal errors, and a retry loop prevents immediate
// corruption declaration.
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// Import the fix function from probeUtils.ts
const probeUtils = await import("../../src/lib/db/probeUtils.ts");
// ── Tests from the original probe that confirmed the bug ──
test("FIX-GREEN: isTransientProbeError is exported and classifies BUSY", () => {
const busy = new Error("SQLITE_BUSY: database is locked");
// The fix must exist
assert.equal(
typeof probeUtils.isTransientProbeError,
"function",
"isTransientProbeError must be exported from core.ts"
);
assert.equal(probeUtils.isTransientProbeError(busy), true, "BUSY is transient");
});
test("FIX-GREEN: isTransientProbeError does NOT classify fatal errors", () => {
const fatalPatterns = [
"out of memory",
"allocation failure",
"Array buffer allocation failed",
"could not be found",
"Module did not self-register",
];
for (const msg of fatalPatterns) {
assert.equal(
probeUtils.isTransientProbeError(new Error(msg)),
false,
`fatal should NOT be transient: ${msg}`
);
}
});
test("FIX-GREEN: isTransientProbeError classifies BUSY/PROTOCOL/IOERR/ENOENT", () => {
const transientPatterns = [
"SQLITE_BUSY: database is locked",
"SQLITE_PROTOCOL: locking protocol",
"SQLITE_IOERR: disk I/O error",
"ENOENT: no such file or directory, open '/tmp/db.sqlite'",
];
for (const msg of transientPatterns) {
assert.equal(probeUtils.isTransientProbeError(new Error(msg)), true, `transient: ${msg}`);
}
});
test("FIX-GREEN: isTransientProbeError handles non-Error input gracefully", () => {
assert.equal(probeUtils.isTransientProbeError("SQLITE_BUSY"), true, "string error works");
assert.equal(
probeUtils.isTransientProbeError("random string"),
false,
"non-matching string returns false"
);
assert.equal(probeUtils.isTransientProbeError(null), false, "null returns false");
assert.equal(probeUtils.isTransientProbeError(undefined), false, "undefined returns false");
assert.equal(probeUtils.isTransientProbeError({}), false, "object without message returns false");
});
test("BUG-CONFIRMED (regression guard): probe failure renames DB and loses persisted config", () => {
// This test confirms the SCENARIO we're preventing — if the probe path is
// reached (all transient retries exhausted or non-transient), data IS lost.
// This is the EXISTING behavior on non-transient errors; the fix only
// ADDED a retry window for transient errors before this path.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-9541-data-loss-"));
const sqliteFile = path.join(dir, "storage.sqlite");
try {
const header = Buffer.alloc(100);
header.write("SQLite format 3\0");
fs.writeFileSync(sqliteFile, header);
fs.writeFileSync(sqliteFile, "DATA_MARKER_PERSISTED_CONFIG", { flag: "a" });
const beforeContent = fs.readFileSync(sqliteFile, "utf-8");
assert.ok(
beforeContent.includes("DATA_MARKER_PERSISTED_CONFIG"),
"data must be present before probe failure"
);
// Simulate probe failure: rename + create new empty DB
const failedPath = sqliteFile + `.probe-failed-${Date.now()}`;
fs.renameSync(sqliteFile, failedPath);
const newHeader = Buffer.alloc(100);
newHeader.write("SQLite format 3\0");
fs.writeFileSync(sqliteFile, newHeader);
const afterContent = fs.readFileSync(sqliteFile, "utf-8");
assert.equal(
afterContent.includes("DATA_MARKER_PERSISTED_CONFIG"),
false,
"data MUST be lost when DB is renamed and recreated (corruption path behavior)"
);
} finally {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
/* ok */
}
}
});
test("FIX-GREEN: DATA_DIR no longer overridden at module scope in probe-9033-repro", async () => {
// Verify the fix in probe-9033-repro.test.ts no longer sets process.env.DATA_DIR
// at module scope. Read-only accesses to process.env.DATA_DIR are fine.
const reproTestSource = fs.readFileSync(
new URL("../../tests/unit/authz/probe-9033-repro.test.ts", import.meta.url),
"utf-8"
);
// Find lines that ASSIGN to process.env.DATA_DIR (not just read it)
const assignLines = reproTestSource
.split("\n")
.filter((line) => /process\.env\.DATA_DIR\s*=/.test(line) && !line.trim().startsWith("//"));
assert.equal(
assignLines.length,
0,
`probe-9033-repro must not assign process.env.DATA_DIR at module scope. Found: ${assignLines.map((l) => l.trim()).join(", ")}`
);
});

View File

@@ -0,0 +1,128 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
// We test the helper that will be added to toolCallHelper.ts.
// For the TDD probe, we directly test the scenario: case-sensitive Map.get
// fails for lowercase names, and the fix (case-insensitive fallback) resolves it.
// After the fix is implemented, the actual functions being tested here are
// restoreOpenAIToolNames (already exported) and the new caseInsensitiveToolNameLookup.
describe("9575 - tool call name case sensitivity", () => {
const toolNameMap = new Map<string, string>([
["Bash", "Bash"],
["Read", "Read"],
["Write", "Write"],
["Glob", "Glob"],
["Skill", "Skill"],
["Edit", "Edit"],
]);
it("case-sensitive Map.get fails for lowercase tool names (THE BUG)", () => {
// Simulate upstream returning lowercase "bash" when tool is "Bash"
const upstreamName = "bash";
const result = toolNameMap.get(upstreamName);
// Case-sensitive lookup returns undefined - this IS the bug
assert.equal(result, undefined, "case-sensitive get should fail for lowercase 'bash'");
// The fallback expression: get() || name — passes through unchanged
const passthrough = toolNameMap.get(upstreamName) ?? upstreamName;
assert.equal(passthrough, "bash", "lowercase 'bash' passes through unchanged (THE BUG)");
});
it("case-insensitive fallback resolves lowercase to PascalCase (THE FIX)", () => {
const upstreamName = "bash";
// Simulate the fix: iteration-based case-insensitive lookup
const lowerName = upstreamName.toLowerCase();
let found: string | undefined;
for (const [key, value] of toolNameMap) {
if (key.toLowerCase() === lowerName) {
found = value;
break;
}
}
assert.equal(found, "Bash", "case-insensitive lookup finds 'Bash' from 'bash'");
});
it("exact match still works for already-correct PascalCase names", () => {
// When upstream returns correct PascalCase, exact Match.get should work
const result = toolNameMap.get("Bash");
assert.equal(result, "Bash", "exact match works for PascalCase 'Bash'");
});
it("restoreOpenAIToolNames: lowercase in aliases map", async () => {
// Test restoreOpenAIToolNames which uses aliases.get(fn.name)
const { restoreOpenAIToolNames } =
await import("../../open-sse/translator/helpers/toolCallHelper.ts");
// Simulate aliases where the key is the shortened lowercase version
const aliases = new Map<string, string>([["bash", "Bash"]]);
const body = {
choices: [
{
message: {
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "bash", arguments: "{}" },
},
],
},
},
],
};
// Before fix: aliases.get("bash") returns "Bash" directly because
// the key IS "bash" — this one actually works with exact match.
// The bug scenario is when aliases key is "Bash" and upstream returns "bash".
const aliasesReversed = new Map<string, string>([["Bash", "bash"]]);
const bodyReversed = {
choices: [
{
message: {
tool_calls: [
{
id: "call_2",
type: "function",
function: { name: "bash", arguments: "{}" },
},
],
},
},
],
};
// Without fix: "bash" is not in map (has "Bash" as key), so lookup fails
const originalGet = aliasesReversed.get("bash");
assert.equal(
originalGet,
undefined,
"case-sensitive get fails when key is 'Bash' but input is 'bash'"
);
});
it("full pipeline: toolNameMap with PascalCase keys, response with lowercase", async () => {
// This simulates the exact bug scenario:
// toolNameMap has PascalCase entries from request translation
// Upstream model returns lowercase function call names
const { caseInsensitiveToolNameLookup } =
await import("../../open-sse/translator/helpers/toolCallHelper.ts");
// Test the fix function
// Exact match case
const exactResult = caseInsensitiveToolNameLookup("Bash", toolNameMap);
assert.equal(exactResult, "Bash", "exact match works");
// Case-insensitive fallback case (THE BUG SCENARIO)
const fallbackResult = caseInsensitiveToolNameLookup("bash", toolNameMap);
assert.equal(fallbackResult, "Bash", "case-insensitive fallback resolves 'bash' to 'Bash'");
// Non-existent tool name
const noResult = caseInsensitiveToolNameLookup("nonexistent", toolNameMap);
assert.equal(noResult, undefined, "non-existent tool returns undefined");
// Null/undefined map
const nullResult = caseInsensitiveToolNameLookup("bash", null);
assert.equal(nullResult, undefined, "null map returns undefined");
});
});

View File

@@ -43,11 +43,6 @@ async function waitFor(fn, timeoutMs = 1500) {
return null;
}
async function waitForAsyncSideEffects() {
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setTimeout(resolve, 10));
}
async function getLatestCallLog() {
const rows = await getCallLogs({ limit: 5 });
if (!Array.isArray(rows) || rows.length === 0) return null;
@@ -85,7 +80,6 @@ test.afterEach(async () => {
globalThis.fetch = originalFetch;
clearPendingRequests();
resetAccountSemaphores();
await waitForAsyncSideEffects();
await resetStorage();
});
@@ -124,8 +118,8 @@ test("network failure persisted call log includes providerRequest in pipeline pa
assert.equal(result.success, false);
assert.equal(result.status, 502);
await waitForAsyncSideEffects();
// waitFor below polls for the exact DB state with 25ms intervals — no
// unreliable fixed-delay timer needed, even under CI load contention.
const detail = await waitFor(getLatestCallLog);
assert.ok(detail, "expected a call log to be persisted");
@@ -188,7 +182,6 @@ test("network timeout persisted call log includes providerRequest in pipeline pa
} as any);
const result = await invocation;
await waitForAsyncSideEffects();
assert.equal(result.success, false);
assert.ok(result.status === 504, `expected 504 timeout, got ${result.status}`);
@@ -244,8 +237,6 @@ test("provider error response (HTTP 502) includes both providerRequest and provi
assert.equal(result.success, false);
assert.equal(result.status, 502);
await waitForAsyncSideEffects();
const detail = await waitFor(getLatestCallLog);
assert.ok(detail, "expected a call log to be persisted");
@@ -312,8 +303,6 @@ test("successful response includes both providerRequest and providerResponse in
assert.equal(result.success, true);
await waitForAsyncSideEffects();
const detail = await waitFor(getLatestCallLog);
assert.ok(detail, "expected a call log to be persisted");
@@ -391,7 +380,6 @@ test("streaming response preserves request headers in providerRequest pipeline p
assert.equal(result.success, true);
await result.response.text();
await waitForAsyncSideEffects();
const detail = await waitFor(getLatestCallLog);
assert.ok(detail, "expected a call log to be persisted");
@@ -475,7 +463,6 @@ test("CC-compatible providerRequest log keeps request beta headers and summarize
assert.equal(result.success, true);
await result.response.json();
await waitForAsyncSideEffects();
const detail = await waitFor(getLatestCallLog);
assert.ok(detail, "expected a call log to be persisted");

View File

@@ -2,9 +2,14 @@
* tests/unit/radar-api-routes.test.ts
*
* TDD regression guard for the Radar API routes:
* - GET /api/radar/catalog: flag off => 404, flag on => shape validated
* - POST /api/radar/sync: flag off => 404, flag on => delegates to syncRadar
* - POST /api/radar/settings: flag off => 404, never echoes clear key
* - GET /api/radar/catalog: flag off => 404, flag on + no auth => 401, flag on + auth => shape validated
* - POST /api/radar/sync: flag off => 404, flag on + no auth => 401, flag on + auth => delegates to syncRadar
* - POST /api/radar/settings: flag off => 404, flag on + no auth => 401, never echoes clear key
* - GET /api/radar/settings: flag off => 404, flag on + no auth => 401, flag on + auth => masked snapshot
*
* Auth wiring (FIX 1 / FIX 3): the flag-off 404 gate must run BEFORE the auth
* check (byte-identical inertia with the flag off, no auth required to learn
* the surface doesn't exist), auth runs AFTER it and before any DB read/write.
*
* Error responses must NOT leak stack traces (Hard Rule #12).
*/
@@ -14,6 +19,7 @@ import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { SignJWT } from "jose";
// ---------------------------------------------------------------------------
// Isolate DB + feature flag state
@@ -22,6 +28,12 @@ import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-radar-api-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.STORAGE_ENCRYPTION_KEY = "test-encryption-key-for-radar-api-tests-32b!";
process.env.JWT_SECRET = "test-jwt-secret-for-radar-api-tests";
// Force isAuthRequired() to always require auth (mirrors tests/unit/api-auth.test.ts):
// without a configured password/OIDC, a loopback bootstrap request would otherwise
// be treated as pre-authenticated. Setting INITIAL_PASSWORD closes that bootstrap
// path so the "no auth => 401" assertions are meaningful.
process.env.INITIAL_PASSWORD = "test-bootstrap-password-for-radar-api-tests";
const core = await import("../../src/lib/db/core.ts");
const radarDb = await import("../../src/lib/db/radar.ts");
@@ -32,15 +44,38 @@ const featureFlags = await import("../../src/shared/utils/featureFlags.ts");
// objects. However, the routes import from @/lib/radar which reads the DB,
// so we need the DB to be set up.
// Helper to create a mock NextRequest-like object
function mockGetRequest(url = "http://localhost:20128/api/radar/catalog"): Request {
return new Request(url, { method: "GET" });
/** Mint a valid dashboard-session JWT cookie header value (see apiAuth.ts::isDashboardSessionAuthenticated). */
async function authCookieHeader(): Promise<string> {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const token = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")
.sign(secret);
return `auth_token=${token}`;
}
function mockPostRequest(url: string, body?: unknown): Request {
/** Headers carrying a valid auth cookie, for the "authenticated" branch of each test. */
async function authHeaders(): Promise<Record<string, string>> {
return { Cookie: await authCookieHeader() };
}
// Helper to create a mock NextRequest-like object
function mockGetRequest(
url = "http://localhost:20128/api/radar/catalog",
headers: Record<string, string> = {},
): Request {
return new Request(url, { method: "GET", headers });
}
function mockPostRequest(
url: string,
body?: unknown,
headers: Record<string, string> = {},
): Request {
return new Request(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...headers },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
@@ -59,7 +94,7 @@ function resetStorage() {
}
// ---------------------------------------------------------------------------
// Tests: flag-off behavior (all routes => 404)
// Tests: flag-off behavior (all routes => 404, no auth required to learn this)
// ---------------------------------------------------------------------------
test("GET /api/radar/catalog: flag off => 404", async () => {
@@ -105,17 +140,88 @@ test("POST /api/radar/settings: flag off => 404", async () => {
assert.ok(!JSON.stringify(body).includes("at /"), "Response must not leak stack traces");
});
test("GET /api/radar/settings: flag off => 404", async () => {
resetStorage();
delete process.env.RADAR_ENABLED;
const { GET } = await import("../../src/app/api/radar/settings/route.ts");
const response = await GET(mockGetRequest("http://localhost:20128/api/radar/settings"));
const body = await response.json();
assert.equal(response.status, 404);
assert.ok(body.error);
assert.ok(!JSON.stringify(body).includes("at /"), "Response must not leak stack traces");
});
// ---------------------------------------------------------------------------
// Tests: flag-on behavior
// FIX 1 — auth required on all 3 (now 4, with GET settings) routes once the
// flag is on. Order: flag-off 404 stays first (byte-identical inertia,
// verified above); auth (401) comes AFTER it, BEFORE any DB access.
// ---------------------------------------------------------------------------
test("GET /api/radar/catalog: flag on, empty cache => baseline entries, meta null", async () => {
test("GET /api/radar/catalog: flag on, no auth => 401", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const { GET } = await import("../../src/app/api/radar/catalog/route.ts");
const response = await GET(mockGetRequest());
const body = await response.json();
assert.equal(response.status, 401);
assert.ok(body.error, "Response should have error field");
assert.ok(!JSON.stringify(body).includes("at /"), "Response must not leak stack traces");
});
test("POST /api/radar/sync: flag on, no auth => 401", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const { POST } = await import("../../src/app/api/radar/sync/route.ts");
const response = await POST(mockPostRequest("http://localhost:20128/api/radar/sync"));
const body = await response.json();
assert.equal(response.status, 401);
assert.ok(body.error);
});
test("POST /api/radar/settings: flag on, no auth => 401", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const { POST } = await import("../../src/app/api/radar/settings/route.ts");
const response = await POST(
mockPostRequest("http://localhost:20128/api/radar/settings", { optIn: true }),
);
const body = await response.json();
assert.equal(response.status, 401);
assert.ok(body.error);
});
test("GET /api/radar/settings: flag on, no auth => 401", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const { GET } = await import("../../src/app/api/radar/settings/route.ts");
const response = await GET(mockGetRequest("http://localhost:20128/api/radar/settings"));
const body = await response.json();
assert.equal(response.status, 401);
assert.ok(body.error);
});
// ---------------------------------------------------------------------------
// Tests: flag-on + authenticated behavior (previous "flag on" tests, now
// wired with a valid session cookie so they exercise the post-auth branch)
// ---------------------------------------------------------------------------
test("GET /api/radar/catalog: flag on, authenticated, empty cache => baseline entries, meta null", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
// Fresh import to pick up the flag
const catalogRoute = await import("../../src/app/api/radar/catalog/route.ts");
const response = await catalogRoute.GET(mockGetRequest());
const response = await catalogRoute.GET(mockGetRequest(undefined, await authHeaders()));
const body = await response.json();
assert.equal(response.status, 200);
@@ -124,16 +230,20 @@ test("GET /api/radar/catalog: flag on, empty cache => baseline entries, meta nul
assert.equal(body.meta, null, "meta should be null when no cache");
});
test("POST /api/radar/settings: flag on, set opt-in => success, no key in response", async () => {
test("POST /api/radar/settings: flag on, authenticated, set opt-in => success, no key in response", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const settingsRoute = await import("../../src/app/api/radar/settings/route.ts");
const response = await settingsRoute.POST(
mockPostRequest("http://localhost:20128/api/radar/settings", {
optIn: true,
supporterKey: "omr_abcdef01234567890abcdef01234567890abcdef",
}),
mockPostRequest(
"http://localhost:20128/api/radar/settings",
{
optIn: true,
supporterKey: "omr_abcdef01234567890abcdef01234567890abcdef",
},
await authHeaders(),
),
);
const body = await response.json();
@@ -150,15 +260,17 @@ test("POST /api/radar/settings: flag on, set opt-in => success, no key in respon
assert.ok(body.supporterKey.length <= 12, "Masked key should be short");
});
test("POST /api/radar/settings: invalid body => 400", async () => {
test("POST /api/radar/settings: authenticated, invalid body => 400", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const settingsRoute = await import("../../src/app/api/radar/settings/route.ts");
const response = await settingsRoute.POST(
mockPostRequest("http://localhost:20128/api/radar/settings", {
supporterKey: "invalid-key-format",
}),
mockPostRequest(
"http://localhost:20128/api/radar/settings",
{ supporterKey: "invalid-key-format" },
await authHeaders(),
),
);
assert.equal(response.status, 400);
@@ -166,13 +278,13 @@ test("POST /api/radar/settings: invalid body => 400", async () => {
assert.ok(body.error);
});
test("POST /api/radar/settings: empty body => 400", async () => {
test("POST /api/radar/settings: authenticated, empty body => 400", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const settingsRoute = await import("../../src/app/api/radar/settings/route.ts");
const response = await settingsRoute.POST(
mockPostRequest("http://localhost:20128/api/radar/settings", {}),
mockPostRequest("http://localhost:20128/api/radar/settings", {}, await authHeaders()),
);
assert.equal(response.status, 400);
@@ -180,24 +292,29 @@ test("POST /api/radar/settings: empty body => 400", async () => {
assert.ok(body.error);
});
test("POST /api/radar/settings: null key clears it", async () => {
test("POST /api/radar/settings: authenticated, null key clears it", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const settingsRoute = await import("../../src/app/api/radar/settings/route.ts");
const headers = await authHeaders();
// First set a key
await settingsRoute.POST(
mockPostRequest("http://localhost:20128/api/radar/settings", {
supporterKey: "omr_abcdef01234567890abcdef01234567890abcdef",
}),
mockPostRequest(
"http://localhost:20128/api/radar/settings",
{ supporterKey: "omr_abcdef01234567890abcdef01234567890abcdef" },
headers,
),
);
// Then clear it
const response = await settingsRoute.POST(
mockPostRequest("http://localhost:20128/api/radar/settings", {
supporterKey: null,
}),
mockPostRequest(
"http://localhost:20128/api/radar/settings",
{ supporterKey: null },
headers,
),
);
const body = await response.json();
@@ -205,14 +322,14 @@ test("POST /api/radar/settings: null key clears it", async () => {
assert.equal(body.supporterKey, null, "Cleared key should return null");
});
test("POST /api/radar/sync: flag on, not opted in => status opt_out", async () => {
test("POST /api/radar/sync: flag on, authenticated, not opted in => status opt_out", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
// Don't set opt-in
const syncRoute = await import("../../src/app/api/radar/sync/route.ts");
const response = await syncRoute.POST(
mockPostRequest("http://localhost:20128/api/radar/sync"),
mockPostRequest("http://localhost:20128/api/radar/sync", undefined, await authHeaders()),
);
const body = await response.json();
@@ -220,23 +337,76 @@ test("POST /api/radar/sync: flag on, not opted in => status opt_out", async () =
assert.equal(body.status, "opt_out");
});
test("POST /api/radar/sync: invalid body => 400", async () => {
test("POST /api/radar/sync: authenticated, invalid body => 400", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const syncRoute = await import("../../src/app/api/radar/sync/route.ts");
const response = await syncRoute.POST(
mockPostRequest("http://localhost:20128/api/radar/sync", { unexpected: true }),
mockPostRequest(
"http://localhost:20128/api/radar/sync",
{ unexpected: true },
await authHeaders(),
),
);
assert.equal(response.status, 400);
});
// ---------------------------------------------------------------------------
// FIX 3 — GET /api/radar/settings: { optIn, hasSupporterKey, supporterKeyMasked }
// ---------------------------------------------------------------------------
test("GET /api/radar/settings: flag on, authenticated, default state => optIn false, no key", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const { GET } = await import("../../src/app/api/radar/settings/route.ts");
const response = await GET(
mockGetRequest("http://localhost:20128/api/radar/settings", await authHeaders()),
);
const body = await response.json();
assert.equal(response.status, 200);
assert.equal(body.optIn, false);
assert.equal(body.hasSupporterKey, false);
assert.equal(body.supporterKeyMasked, null);
});
test("GET /api/radar/settings: flag on, authenticated, after opt-in + key => reflects persisted state, never raw key", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const settingsRoute = await import("../../src/app/api/radar/settings/route.ts");
const headers = await authHeaders();
const RAW_KEY = "omr_abcdef01234567890abcdef01234567890abcdef";
await settingsRoute.POST(
mockPostRequest(
"http://localhost:20128/api/radar/settings",
{ optIn: true, supporterKey: RAW_KEY },
headers,
),
);
const response = await settingsRoute.GET(
mockGetRequest("http://localhost:20128/api/radar/settings", headers),
);
const text = await response.text();
const body = JSON.parse(text);
assert.equal(response.status, 200);
assert.equal(body.optIn, true);
assert.equal(body.hasSupporterKey, true);
assert.equal(body.supporterKeyMasked, "omr_****cdef", "must mask to last 4 hex chars");
assert.ok(!text.includes(RAW_KEY), "raw key must NEVER appear in the serialized response body");
});
// ---------------------------------------------------------------------------
// Tests: error sanitization (Hard Rule #12)
// ---------------------------------------------------------------------------
test("all radar routes: error responses do NOT leak stack traces", async () => {
test("all radar routes: 404 error responses (flag off) do NOT leak stack traces", async () => {
resetStorage();
delete process.env.RADAR_ENABLED;
@@ -267,6 +437,39 @@ test("all radar routes: error responses do NOT leak stack traces", async () => {
}
});
test("all radar routes: 401 error responses (flag on, no auth) do NOT leak stack traces", async () => {
resetStorage();
process.env.RADAR_ENABLED = "true";
const routes = [
{ name: "catalog", GET: (await import("../../src/app/api/radar/catalog/route.ts")).GET },
{ name: "sync", POST: (await import("../../src/app/api/radar/sync/route.ts")).POST },
{ name: "settings-post", POST: (await import("../../src/app/api/radar/settings/route.ts")).POST },
{ name: "settings-get", GET: (await import("../../src/app/api/radar/settings/route.ts")).GET },
];
for (const route of routes) {
let response: Response;
if ("GET" in route && route.GET) {
response = await (route as { GET: (r: Request) => Promise<Response> }).GET(mockGetRequest());
} else {
response = await (route as { POST: (r: Request) => Promise<Response> }).POST(
mockPostRequest(`http://localhost:20128/api/radar/${route.name.replace("-post", "")}`, {}),
);
}
assert.equal(response.status, 401, `${route.name}: expected 401 without auth`);
const text = await response.text();
assert.ok(
!text.includes("at /"),
`${route.name}: response must not contain stack-like paths. Got: ${text.slice(0, 200)}`,
);
assert.ok(
!text.includes(".ts:") && !text.includes(".js:"),
`${route.name}: response must not contain file:line references. Got: ${text.slice(0, 200)}`,
);
}
});
// ---------------------------------------------------------------------------
// Cleanup
// ---------------------------------------------------------------------------
@@ -274,6 +477,8 @@ test("all radar routes: error responses do NOT leak stack traces", async () => {
test.after(() => {
core.resetDbInstance();
delete process.env.RADAR_ENABLED;
delete process.env.JWT_SECRET;
delete process.env.INITIAL_PASSWORD;
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
} catch {

View File

@@ -687,3 +687,133 @@ test("baselineToMergedEntries: converts FreeModelBudget shape to MergedEntry", (
assert.equal(entries[0].origin, "baseline");
assert.equal(entries[0].enabled, true);
});
// ===========================================================================
// FIX 2 — extended feed fields (contextWindow/capabilities/limits/setup)
// must survive the merge on BOTH code paths (mergeOne + feedModelToMerged).
// ===========================================================================
test("FIX2 mergeOne path: contextWindow/capabilities/limits/setup survive merge over a baseline entry", () => {
const baseline = makeBaseline();
const feed: FeedModel[] = [
makeFeedModel({
provider: "groq",
modelId: "llama-3.3-70b-versatile",
contextWindow: 131072,
capabilities: { tools: true, vision: true, thinking: false },
limits: { rpm: 30, rpd: 14400, tpm: 6000, tpd: null },
setup: { keyUrl: "https://console.groq.com/keys", steps: ["Sign up", "Create key"] },
}),
];
const result = applyFeed({
baseline,
feed,
localOverrides: new Map(),
tombstones: new Set(),
});
const groq = result.find(
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
)!;
assert.equal(groq.contextWindow, 131072);
assert.deepEqual(groq.capabilities, { tools: true, vision: true, thinking: false });
assert.deepEqual(groq.limits, { rpm: 30, rpd: 14400, tpm: 6000, tpd: null });
assert.deepEqual(groq.setup, {
keyUrl: "https://console.groq.com/keys",
steps: ["Sign up", "Create key"],
});
});
test("FIX2 feedModelToMerged path: contextWindow/capabilities/limits/setup survive for a feed-only entry", () => {
const baseline = makeBaseline();
const feed: FeedModel[] = [
makeFeedModel({
provider: "new-provider",
modelId: "new-model",
contextWindow: 65536,
capabilities: { tools: false, vision: true, thinking: true },
limits: { rpm: null, rpd: 100, tpm: null, tpd: null },
setup: { keyUrl: "https://new-provider.example/keys", steps: ["Step A"] },
}),
];
const result = applyFeed({
baseline,
feed,
localOverrides: new Map(),
tombstones: new Set(),
});
const added = result.find((e) => e.provider === "new-provider" && e.modelId === "new-model")!;
assert.equal(added.contextWindow, 65536);
assert.deepEqual(added.capabilities, { tools: false, vision: true, thinking: true });
assert.deepEqual(added.limits, { rpm: null, rpd: 100, tpm: null, tpd: null });
assert.deepEqual(added.setup, {
keyUrl: "https://new-provider.example/keys",
steps: ["Step A"],
});
});
// ===========================================================================
// 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.
// ===========================================================================
test("FIX4: feed-only entry with local override enabled:true wins over feed enabled:false", () => {
const baseline = makeBaseline();
const feed: FeedModel[] = [
makeFeedModel({
provider: "new-provider",
modelId: "disabled-model",
enabled: false,
}),
];
const localOverrides = new Map<string, Partial<MergedEntry>>([
["new-provider:disabled-model", { enabled: true }],
]);
const result = applyFeed({
baseline,
feed,
localOverrides,
tombstones: new Set(),
});
const entry = result.find(
(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");
});
test("FIX4: feed-only entry with NO override still gets disabled with disabledBy provenance", () => {
const baseline = makeBaseline();
const feed: FeedModel[] = [
makeFeedModel({
provider: "new-provider",
modelId: "disabled-model-2",
enabled: false,
}),
];
const result = applyFeed({
baseline,
feed,
localOverrides: new Map(),
tombstones: new Set(),
});
const entry = result.find(
(e) => e.provider === "new-provider" && e.modelId === "disabled-model-2",
)!;
assert.equal(entry.enabled, false);
assert.equal(entry.disabledBy, "radar");
});

View File

@@ -0,0 +1,46 @@
import test from "node:test";
import assert from "node:assert/strict";
import { shouldAutoSyncOnOpen, AUTO_SYNC_STALE_MS } from "../../src/lib/radar/autoSync.ts";
// Pure staleness rule that powers the Radar page's sync-on-open behaviour
// (spec: dados atualizados a cada abrir da página).
const NOW = Date.parse("2026-08-06T12:00:00.000Z");
test("shouldAutoSyncOnOpen", async (t) => {
await t.test("no cache at all => sync", () => {
assert.equal(shouldAutoSyncOnOpen(null, NOW), true);
assert.equal(shouldAutoSyncOnOpen(undefined, NOW), true);
assert.equal(shouldAutoSyncOnOpen("", NOW), true);
});
await t.test("unparseable timestamp counts as stale", () => {
assert.equal(shouldAutoSyncOnOpen("not-a-date", NOW), true);
});
await t.test("fresh cache (just fetched) => no sync", () => {
assert.equal(shouldAutoSyncOnOpen(new Date(NOW - 1000).toISOString(), NOW), false);
});
await t.test("cache just inside the stale window => no sync", () => {
const fetchedAt = new Date(NOW - AUTO_SYNC_STALE_MS + 1000).toISOString();
assert.equal(shouldAutoSyncOnOpen(fetchedAt, NOW), false);
});
await t.test("cache exactly at the stale boundary => sync", () => {
const fetchedAt = new Date(NOW - AUTO_SYNC_STALE_MS).toISOString();
assert.equal(shouldAutoSyncOnOpen(fetchedAt, NOW), true);
});
await t.test("cache older than the window => sync", () => {
const fetchedAt = new Date(NOW - 24 * 60 * 60 * 1000).toISOString();
assert.equal(shouldAutoSyncOnOpen(fetchedAt, NOW), true);
});
await t.test("custom threshold is honored", () => {
const fetchedAt = new Date(NOW - 5000).toISOString();
assert.equal(shouldAutoSyncOnOpen(fetchedAt, NOW, 10_000), false);
assert.equal(shouldAutoSyncOnOpen(fetchedAt, NOW, 4000), true);
});
});

View File

@@ -0,0 +1,153 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// Isolate DATA_DIR before any src import — the scheduler module's default deps
// reference the DB layer (never invoked here: every test injects its deps).
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-radar-scheduler-"));
process.env.DATA_DIR = tmpDir;
const {
radarSchedulerTick,
ensureRadarSyncScheduler,
stopRadarSyncScheduler,
initRadarSyncScheduler,
RADAR_SCHEDULER_TICK_MS,
} = await import("../../src/lib/radar/scheduler.ts");
const NOW = Date.parse("2026-08-06T12:00:00.000Z");
const FRESH = new Date(NOW - 60 * 60 * 1000).toISOString(); // 1h ago — inside the daily window
const STALE = new Date(NOW - 25 * 60 * 60 * 1000).toISOString(); // 25h ago — due
/** Fake interval registry so no real timer ever exists in these tests. */
function fakeTimers() {
const registered: Array<{ fn: () => void; ms: number }> = [];
let cleared = 0;
return {
registered,
clearedCount: () => cleared,
setIntervalFn: ((fn: () => void, ms: number) => {
registered.push({ fn, ms });
return registered.length as unknown as ReturnType<typeof setInterval>;
}) as typeof setInterval,
clearIntervalFn: (() => {
cleared += 1;
}) as typeof clearInterval,
};
}
function deps(overrides: Record<string, unknown> = {}) {
const syncCalls: number[] = [];
const timers = fakeTimers();
return {
syncCalls,
timers,
d: {
getFlag: () => true,
getSettings: () => ({ optIn: true }),
getCache: () => ({ fetchedAt: STALE }),
sync: async () => {
syncCalls.push(1);
return { status: "updated", version: "2026.08.06.1", tier: "live" } as const;
},
now: () => NOW,
setIntervalFn: timers.setIntervalFn,
clearIntervalFn: timers.clearIntervalFn,
...overrides,
},
};
}
test("radar sync scheduler", async (t) => {
t.afterEach(() => {
// Module-level timer state must not leak between subtests.
stopRadarSyncScheduler({ clearIntervalFn: (() => {}) as typeof clearInterval });
});
await t.test("tick: flag off => stopped, sync never called", async () => {
const { d, syncCalls } = deps({ getFlag: () => false });
const result = await radarSchedulerTick(d);
assert.deepEqual(result, { action: "stopped", reason: "flag_off" });
assert.equal(syncCalls.length, 0);
});
await t.test("tick: flag off stops a running timer (self-heal to zero-timer state)", async () => {
const { d, timers } = deps();
assert.equal(ensureRadarSyncScheduler(d), true);
assert.equal(timers.registered.length, 1);
const offDeps = { ...d, getFlag: () => false };
await radarSchedulerTick(offDeps);
assert.equal(timers.clearedCount(), 1);
});
await t.test("tick: opt-in off => skipped, no sync", async () => {
const { d, syncCalls } = deps({ getSettings: () => ({ optIn: false }) });
const result = await radarSchedulerTick(d);
assert.deepEqual(result, { action: "skipped", reason: "opt_out" });
assert.equal(syncCalls.length, 0);
});
await t.test("tick: fresh cache => not due, no sync", async () => {
const { d, syncCalls } = deps({ getCache: () => ({ fetchedAt: FRESH }) });
const result = await radarSchedulerTick(d);
assert.deepEqual(result, { action: "skipped", reason: "not_due" });
assert.equal(syncCalls.length, 0);
});
await t.test("tick: no cache at all => syncs immediately", async () => {
const { d, syncCalls } = deps({ getCache: () => null });
const result = await radarSchedulerTick(d);
assert.equal(result.action, "synced");
assert.equal(syncCalls.length, 1);
});
await t.test("tick: stale cache (>24h) => syncs", async () => {
const { d, syncCalls } = deps();
const result = await radarSchedulerTick(d);
assert.equal(result.action, "synced");
assert.equal(syncCalls.length, 1);
});
await t.test("ensure: registers one hourly timer, fires an immediate tick, idempotent", async () => {
const { d, timers, syncCalls } = deps();
assert.equal(ensureRadarSyncScheduler(d), true);
assert.equal(timers.registered.length, 1);
assert.equal(timers.registered[0].ms, RADAR_SCHEDULER_TICK_MS);
// The immediate tick is fire-and-forget; give the microtask queue a turn.
await new Promise((resolve) => setImmediate(resolve));
assert.equal(syncCalls.length, 1, "immediate tick should have synced the stale cache");
// Second ensure is a no-op — no second timer.
assert.equal(ensureRadarSyncScheduler(d), false);
assert.equal(timers.registered.length, 1);
});
await t.test("init: flag off => never arms (flag-off boot stays timer-free)", () => {
const { d, timers } = deps({ getFlag: () => false });
assert.equal(initRadarSyncScheduler(d), false);
assert.equal(timers.registered.length, 0);
});
await t.test("init: opt-in off => never arms", () => {
const { d, timers } = deps({ getSettings: () => ({ optIn: false }) });
assert.equal(initRadarSyncScheduler(d), false);
assert.equal(timers.registered.length, 0);
});
await t.test("init: flag + opt-in on => arms the timer", () => {
const { d, timers } = deps();
assert.equal(initRadarSyncScheduler(d), true);
assert.equal(timers.registered.length, 1);
});
await t.test("init: settings reader throwing => false, never throws out", () => {
const { d, timers } = deps({
getSettings: () => {
throw new Error("db unavailable");
},
});
assert.equal(initRadarSyncScheduler(d), false);
assert.equal(timers.registered.length, 0);
});
});

View File

@@ -809,3 +809,75 @@ test("syncRadar: first sync (no cache) with valid data => updated", async () =>
assert.equal(result.status, "updated");
assert.equal(cacheStore.length, 1);
});
// ===========================================================================
// FIX 6 — 10 MB response cap (unbounded `Buffer.from(await res.arrayBuffer())`)
// ===========================================================================
test("FIX6: Content-Length header exceeding the 10MB cap => too_large, cache untouched, body never read", async () => {
let arrayBufferCalled = false;
const oversizedContentLength = String(10 * 1024 * 1024 + 1);
const response = mockResponse(Buffer.from("irrelevant"), {
"content-length": oversizedContentLength,
});
const originalArrayBuffer = response.arrayBuffer.bind(response);
(response as unknown as { arrayBuffer: () => Promise<ArrayBuffer> }).arrayBuffer = () => {
arrayBufferCalled = true;
return originalArrayBuffer();
};
let setCacheCalled = false;
const result = await syncMod.syncRadar({
getFlag: () => true,
getSettings: () => ({ optIn: true, supporterKey: null }),
getCache: () => null,
setCache: () => {
setCacheCalled = true;
},
fetch: (() => Promise.resolve(response)) as unknown as typeof globalThis.fetch,
});
assert.deepEqual(result, { status: "too_large" });
assert.equal(setCacheCalled, false, "cache must not be touched");
assert.equal(
arrayBufferCalled,
false,
"body must not be read once Content-Length already exceeds the cap"
);
});
test("FIX6: oversized body without a trustworthy Content-Length header => too_large, cache untouched", async () => {
const oversized = Buffer.alloc(10 * 1024 * 1024 + 1, 0x41);
let setCacheCalled = false;
const result = await syncMod.syncRadar({
getFlag: () => true,
getSettings: () => ({ optIn: true, supporterKey: null }),
getCache: () => null,
setCache: () => {
setCacheCalled = true;
},
fetch: (() =>
Promise.resolve(mockResponse(oversized, {}))) as unknown as typeof globalThis.fetch,
});
assert.deepEqual(result, { status: "too_large" });
assert.equal(setCacheCalled, false, "cache must not be touched");
});
test("FIX6: body within the 10MB cap proceeds normally (never returns too_large)", async () => {
const sig = signBytes(FIXTURE_BYTES);
const result = await syncMod.syncRadar({
getFlag: () => true,
getSettings: () => ({ optIn: true, supporterKey: null }),
getCache: () => null,
setCache: () => {},
fetch: (() =>
Promise.resolve(
mockResponse(FIXTURE_BYTES, { "x-omniroute-feed-signature": sig })
)) as unknown as typeof globalThis.fetch,
});
assert.notEqual(result.status, "too_large");
});

View File

@@ -0,0 +1,39 @@
// repro-9550-amazon-q-alias-resolution.test.ts
// Issue #9550: amazon-q provider silently falls back to OpenAI's endpoint
// because the "aq" alias is never resolved to "amazon-q".
import { describe, it } from "node:test";
import { strict as assert } from "node:assert";
import { resolveProviderAlias, parseModel } from "../../open-sse/services/model.ts";
import { getExecutor } from "../../open-sse/executors/index.ts";
describe("Issue #9550 - amazon-q alias resolution", () => {
it("resolveProviderAlias('aq') should return 'amazon-q'", () => {
const provider = resolveProviderAlias("aq");
assert.equal(
provider,
"amazon-q",
`Expected "amazon-q" but got "${provider}" — ALIAS_TO_PROVIDER_ID["aq"] is missing`
);
});
it('parseModel("aq/amazon-q") should resolve provider to "amazon-q"', () => {
const parsed = parseModel("aq/amazon-q");
assert.equal(
parsed.provider,
"amazon-q",
`parseModel("aq/amazon-q") provider should be "amazon-q" but got "${parsed.provider}"`
);
assert.equal(parsed.model, "amazon-q");
});
it('getExecutor("amazon-q") should exist and be a KiroExecutor', () => {
const executor = getExecutor("amazon-q");
assert.ok(executor, "getExecutor('amazon-q') should return an executor");
assert.equal(
executor.constructor.name,
"KiroExecutor",
"amazon-q executor should be a KiroExecutor"
);
});
});

View File

@@ -201,7 +201,7 @@ test("selectProvider with unknown provider returns null", () => {
test("selectProvider without argument returns cheapest provider", () => {
const config = selectProvider();
assert.ok(config);
assert.equal(config.id, "searxng-search");
assert.notEqual(config.id, "searxng-search");
});
test("selectProvider auto-selection never returns a fallbackOnly provider", () => {
@@ -223,7 +223,7 @@ test("selectProvider still honors an explicit fallbackOnly provider", () => {
test("selectProvider filters by search type support", () => {
const config = selectProvider(undefined, "news");
assert.ok(config);
assert.equal(config.id, "searxng-search");
assert.equal(config.id, "serper-search");
assert.equal(selectProvider("linkup-search", "news"), null);
});

View File

@@ -417,7 +417,7 @@ test("v1 search POST preserves stored SearXNG baseUrl for authless providers", a
}
});
test("v1 search POST auto-select uses authless SearXNG when no API-key providers are configured", async () => {
test("v1 search POST returns 400 when auto-select finds no configured provider (searxng-search is now fallbackOnly)", async () => {
const originalFetch = globalThis.fetch;
let capturedUrl = "";
@@ -451,13 +451,8 @@ test("v1 search POST auto-select uses authless SearXNG when no API-key providers
);
const body = (await response.json()) as any;
assert.equal(response.status, 200);
assert.equal(
capturedUrl,
"http://localhost:8888/search?q=auto+select+self+hosted+search&format=json&categories=general"
);
assert.equal(body.provider, "searxng-search");
assert.equal(body.results[0].title, "Auto-selected SearXNG result");
assert.equal(response.status, 400);
assert.ok(body.error?.message || body.error);
} finally {
globalThis.fetch = originalFetch;
}

View File

@@ -0,0 +1,30 @@
import test from "node:test";
import assert from "node:assert/strict";
const { SEARCH_PROVIDERS, selectProvider } =
await import("../../open-sse/config/searchRegistry.ts");
test("searxng-search has fallbackOnly: true (fix #9543)", () => {
const s = SEARCH_PROVIDERS["searxng-search"];
assert.ok(s);
assert.equal(s.authType, "none");
assert.equal(s.costPerQuery, 0);
assert.equal(s.fallbackOnly, true);
});
test("selectProvider does NOT auto-select searxng-search (fix #9543)", () => {
const auto = selectProvider();
assert.ok(auto);
assert.notEqual(auto.id, "searxng-search");
});
test("duckduckgo-free IS correctly fallbackOnly (design reference)", () => {
const d = SEARCH_PROVIDERS["duckduckgo-free"];
assert.equal(d.fallbackOnly, true);
});
test("selectProvider with explicit searxng-search still works", () => {
const explicit = selectProvider("searxng-search", "web");
assert.ok(explicit);
assert.equal(explicit.id, "searxng-search");
});

View File

@@ -97,3 +97,77 @@ test("costs section titleKey is costsSection", () => {
assert.equal(section.titleKey, "costsSection");
assert.equal(section.titleFallback, "Costs");
});
// ---------------------------------------------------------------------------
// FIX 5 — the "radar" sidebar item must be gated on the RADAR_ENABLED feature
// flag (Sidebar.tsx has no built-in feature-flag awareness, so the item
// definition carries an opt-in `featureFlagKey`, and a small pure filter
// helper decides visibility given a resolved flags map).
// ---------------------------------------------------------------------------
test("FIX5: radar item declares featureFlagKey RADAR_ENABLED", () => {
const section = findSection("costs");
assert.ok(section, "costs section must exist");
const radarItem = sidebarVisibility.getSectionItems(section).find((i) => i.id === "radar");
assert.ok(radarItem, "radar item must exist in costs section");
assert.equal(radarItem.featureFlagKey, "RADAR_ENABLED");
});
test("FIX5: no other costs-section item declares a featureFlagKey (no regression)", () => {
const section = findSection("costs");
assert.ok(section, "costs section must exist");
const otherItems = sidebarVisibility
.getSectionItems(section)
.filter((i) => i.id !== "radar");
for (const item of otherItems) {
assert.equal(
item.featureFlagKey,
undefined,
`${item.id} must not be flag-gated (unexpected regression)`
);
}
});
test("FIX5: isSidebarItemVisibleForFlags — flag off => item hidden", () => {
const section = findSection("costs");
assert.ok(section, "costs section must exist");
const radarItem = sidebarVisibility.getSectionItems(section).find((i) => i.id === "radar")!;
assert.equal(
sidebarVisibility.isSidebarItemVisibleForFlags(radarItem, { RADAR_ENABLED: false }),
false
);
});
test("FIX5: isSidebarItemVisibleForFlags — flag on => item visible", () => {
const section = findSection("costs");
assert.ok(section, "costs section must exist");
const radarItem = sidebarVisibility.getSectionItems(section).find((i) => i.id === "radar")!;
assert.equal(
sidebarVisibility.isSidebarItemVisibleForFlags(radarItem, { RADAR_ENABLED: true }),
true
);
});
test("FIX5: isSidebarItemVisibleForFlags — flag unknown (not yet loaded) fails open => visible", () => {
const section = findSection("costs");
assert.ok(section, "costs section must exist");
const radarItem = sidebarVisibility.getSectionItems(section).find((i) => i.id === "radar")!;
assert.equal(sidebarVisibility.isSidebarItemVisibleForFlags(radarItem, {}), true);
});
test("FIX5: isSidebarItemVisibleForFlags — items without featureFlagKey are always visible", () => {
const section = findSection("costs");
assert.ok(section, "costs section must exist");
const costsItem = sidebarVisibility.getSectionItems(section).find((i) => i.id === "costs")!;
assert.equal(
sidebarVisibility.isSidebarItemVisibleForFlags(costsItem, { RADAR_ENABLED: false }),
true
);
});