mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 08:12:20 +03:00
Compare commits
18 Commits
fix/9541-f
...
fix/8994-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d1b580c2d | ||
|
|
1e15583f29 | ||
|
|
9995bc4893 | ||
|
|
c9a3361e5a | ||
|
|
c9debe92bd | ||
|
|
fad3539a69 | ||
|
|
919f9acd80 | ||
|
|
8a573c56e3 | ||
|
|
f338363cd3 | ||
|
|
28dc5af7ba | ||
|
|
616175a93e | ||
|
|
e4e0c254ea | ||
|
|
2e9abab944 | ||
|
|
4cc9cf8123 | ||
|
|
6c3aea6ba6 | ||
|
|
9edefd4572 | ||
|
|
8fbd331567 | ||
|
|
535c75b60a |
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
1
changelog.d/fixes/8946-no-tool-output.md
Normal file
1
changelog.d/fixes/8946-no-tool-output.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(compression): drop orphan custom_tool_call/local_shell_call/apply_patch_call on compaction restore (#8946)
|
||||
1
changelog.d/fixes/8994-vertex-partner-claude.md
Normal file
1
changelog.d/fixes/8994-vertex-partner-claude.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(vertex): route Claude models to native rawPredict endpoint and respect custom targetFormat overrides (#8994)
|
||||
1
changelog.d/fixes/9435-kiro-import-overwrite.md
Normal file
1
changelog.d/fixes/9435-kiro-import-overwrite.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(oauth): Kiro import token endpoint no longer overwrites existing connection when using shared cached OIDC clientId (#9435)
|
||||
1
changelog.d/fixes/9531-ci-combo-matrix.md
Normal file
1
changelog.d/fixes/9531-ci-combo-matrix.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(ci): include combo-matrix tests in test-integration job (#9531)
|
||||
1
changelog.d/fixes/9534-modelsdevsync-timers.md
Normal file
1
changelog.d/fixes/9534-modelsdevsync-timers.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(test): prevent flaky modelsDevSync timer assertions by serializing test execution within the file (#9534)
|
||||
1
changelog.d/fixes/9536-usage-misreporting.md
Normal file
1
changelog.d/fixes/9536-usage-misreporting.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(backend): map cache tokens in OpenAI-to-Claude non-streaming usage translation (#9536)
|
||||
1
changelog.d/fixes/9541-fastpath-db-corruption.md
Normal file
1
changelog.d/fixes/9541-fastpath-db-corruption.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(db): add transient-error retry to corruption probe to prevent data loss under concurrent load (#9541)
|
||||
1
changelog.d/fixes/9543-searxng-auto-select.md
Normal file
1
changelog.d/fixes/9543-searxng-auto-select.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(search): mark searxng-search as fallbackOnly to prevent auto-select without instance (#9543)
|
||||
1
changelog.d/fixes/9545-gpt56-effort-tools.md
Normal file
1
changelog.d/fixes/9545-gpt56-effort-tools.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): strip provider prefix in getModelTargetFormat to route GPT-5.6 models to /v1/responses (#9545)
|
||||
1
changelog.d/fixes/9550-amazon-q-alias.md
Normal file
1
changelog.d/fixes/9550-amazon-q-alias.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(model): add "aq" alias for amazon-q provider so parseModel resolves it instead of falling back to OpenAI (#9550)
|
||||
1
changelog.d/fixes/9551-proxyfetch-context-bypass.md
Normal file
1
changelog.d/fixes/9551-proxyfetch-context-bypass.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(proxy): NO_PROXY now bypasses context-level proxy in resolveProxyForRequest (#9551)
|
||||
1
changelog.d/fixes/9560-turbopack-nft-guard.md
Normal file
1
changelog.d/fixes/9560-turbopack-nft-guard.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(build): lazy-resolve module-level fs paths to avoid Turbopack NFT whole-source trace (#9560)
|
||||
1
changelog.d/fixes/9567-chatcore-sse-flaky.md
Normal file
1
changelog.d/fixes/9567-chatcore-sse-flaky.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): replace timer-based waits with polling to fix flaky chatCore/SSE tests under CI load (#9567)
|
||||
1
changelog.d/fixes/9568-gemini-tool-casing.md
Normal file
1
changelog.d/fixes/9568-gemini-tool-casing.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(translator):** restore original tool name casing in Gemini/Antigravity response translators ([#9568](https://github.com/diegosouzapw/OmniRoute/issues/9568))
|
||||
1
changelog.d/fixes/9575-tool-call-case.md
Normal file
1
changelog.d/fixes/9575-tool-call-case.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(translator): add case-insensitive fallback for upstream tool call name lookups (#9575)
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
---
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ export const SEARCH_PROVIDERS: Record<string, SearchProviderConfig> = {
|
||||
maxMaxResults: 50,
|
||||
timeoutMs: 10_000,
|
||||
cacheTTLMs: 3 * 60 * 1000,
|
||||
fallbackOnly: true,
|
||||
},
|
||||
|
||||
"ollama-search": {
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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?.(
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
96
src/lib/db/probeUtils.ts
Normal 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;
|
||||
}
|
||||
@@ -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
27
src/lib/radar/autoSync.ts
Normal 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
114
src/lib/radar/scheduler.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -469,6 +469,7 @@ const COSTS_ITEMS: readonly SidebarItemDefinition[] = [
|
||||
i18nKey: "radar",
|
||||
subtitleKey: "radarSubtitle",
|
||||
icon: "radar",
|
||||
featureFlagKey: "RADAR_ENABLED",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
160
tests/unit/9536-usage-misreporting-openai-to-claude.test.ts
Normal file
160
tests/unit/9536-usage-misreporting-openai-to-claude.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
29
tests/unit/9545-gpt56-reasoning-tools.test.ts
Normal file
29
tests/unit/9545-gpt56-reasoning-tools.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
61
tests/unit/9551-proxyfetch-no-proxy-context-bypass.test.ts
Normal file
61
tests/unit/9551-proxyfetch-no-proxy-context-bypass.test.ts
Normal 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");
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
52
tests/unit/9560-turbopack-nft-lazy-module-fs.test.ts
Normal file
52
tests/unit/9560-turbopack-nft-lazy-module-fs.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
138
tests/unit/9568-gemini-tool-casing-mismatch.test.ts
Normal file
138
tests/unit/9568-gemini-tool-casing-mismatch.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 ?? []);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
144
tests/unit/compression/responses-orphan-tool-call.test.ts
Normal file
144
tests/unit/compression/responses-orphan-tool-call.test.ts
Normal 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})`
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
87
tests/unit/kiro-import-overwrite-9435.test.ts
Normal file
87
tests/unit/kiro-import-overwrite-9435.test.ts
Normal 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}`);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
135
tests/unit/probe-9541-repro.test.ts
Normal file
135
tests/unit/probe-9541-repro.test.ts
Normal 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(", ")}`
|
||||
);
|
||||
});
|
||||
128
tests/unit/probe-9575-tool-name-case.test.ts
Normal file
128
tests/unit/probe-9575-tool-name-case.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
46
tests/unit/radar-auto-sync.test.ts
Normal file
46
tests/unit/radar-auto-sync.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
153
tests/unit/radar-scheduler.test.ts
Normal file
153
tests/unit/radar-scheduler.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
39
tests/unit/repro-9550-amazon-q-alias-resolution.test.ts
Normal file
39
tests/unit/repro-9550-amazon-q-alias-resolution.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
30
tests/unit/search-select-provider-searxng-bug-9543.test.ts
Normal file
30
tests/unit/search-select-provider-searxng-bug-9543.test.ts
Normal 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");
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user