Compare commits

..

8 Commits

Author SHA1 Message Date
diegosouzapw
f5d0399739 fix(playground): surface provider model loading errors in LlmChatCard (#9626) 2026-08-08 13:29:28 -03:00
Milan Soni
d0047ee615 fix(sse): enforce capabilities for gemini-web reasoning and tools (#9356) (#9397)
Merge-train validated
2026-08-08 12:47:48 -03:00
小妍儿 ✨
064a19b2d1 fix(quota): clean managed combos when deleting pools (#8906)
Merge-train validated
2026-08-08 12:45:00 -03:00
Diego Rodrigues de Sa e Souza
492f9ddc4a fix: buffer and normalize Responses tool-call argument deltas, stripping optional null before reaching the client (#9168)
Refs: base-red #9737
2026-08-08 12:07:24 -03:00
Diego Rodrigues de Sa e Souza
e7d9055314 fix(translator): avoid double-normalizing tool names in Gemini-to-Claude response path (#9177)
Refs: base-red #9737
2026-08-08 12:07:14 -03:00
Diego Rodrigues de Sa e Souza
2ed487583b fix(model-discovery): ingest capabilities.effort_tiers for synced models (#9160)
Refs: base-red #9737
2026-08-08 12:03:24 -03:00
Diego Rodrigues de Sa e Souza
a6b3b4f57a fix(base-red): strip conflict markers from triage-bugs test file (#9142 merge artifact)
Refs: base-red #9737
2026-08-08 12:01:41 -03:00
Diego Rodrigues de Sa e Souza
cf31b795b7 fix(background): detect Anthropic top-level system prompts for background task detection (#9142)
Refs: base-red #9737
2026-08-08 11:45:54 -03:00
32 changed files with 1375 additions and 1249 deletions

View File

@@ -1033,11 +1033,7 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => {
// Config hook: keep existing catalog shim, and register slash command
// templates that ask the agent to call the force-sync tool (OpenCode has no
// Pi-style registerCommand API; tools + command templates are the native path).
const baseConfigHook = createOmniRouteConfigHook(resolved, {
cache: sharedCache,
diskSnapshotReader: defaultDiskSnapshotReader,
diskSnapshotWriter: defaultDiskSnapshotWriter,
});
const baseConfigHook = createOmniRouteConfigHook(resolved, { cache: sharedCache });
const configWithSyncCommand = async (input: Config) => {
await baseConfigHook(input);
const cfg = input as Config & {
@@ -4745,7 +4741,7 @@ export type OmniRouteDiskSnapshotWriter = (
export type OmniRouteDiskSnapshotReader = (
providerId: string,
identityFingerprint: string
) => Promise<(Omit<OmniRouteFetchCacheEntry, "expiresAt"> & { writtenAt?: number }) | undefined>;
) => Promise<Omit<OmniRouteFetchCacheEntry, "expiresAt"> | undefined>;
/**
* Bind a snapshot to the endpoint and effective credential tuple without
@@ -4828,36 +4824,15 @@ export const defaultDiskSnapshotReader: OmniRouteDiskSnapshotReader = async (
? parsed.rawCompressionCombos
: [],
rawConnections: Array.isArray(parsed.rawConnections) ? parsed.rawConnections : [],
writtenAt: typeof parsed.writtenAt === "number" ? parsed.writtenAt : undefined,
};
} catch {
return undefined;
}
};
/** No-op disk-cache pair — used by tests to avoid filesystem side effects.
* Also used as the default in createOmniRouteConfigHook so that tests
* that don't pass a diskSnapshotReader don't read real snapshot files
* from the user's ~/.local/share/opencode/plugins/ directory.
* The OmniRoutePlugin function passes the real defaultDiskSnapshotReader
* explicitly. */
export const noopDiskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
/** No-op disk-cache pair — used by tests to avoid filesystem side effects. */
export const noopDiskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
/**
* In-flight refresh guard: prevents concurrent refreshes for the same
* cacheKey. When a warm snapshot is served, the refresh runs detached; if
* a second hook invocation arrives before the refresh completes, it should
* piggyback on the in-flight promise rather than starting a second one.
* Cleared on settle so it doesn't leak.
*/
const _inflightRefresh: Map<string, Promise<void>> = new Map();
/** Reset the in-flight refresh guard (for test isolation). */
export function _resetInflightRefresh(): void {
_inflightRefresh.clear();
}
// ────────────────────────────────────────────────────────────────────────────
// Debug logging (features.debugLog)
// ────────────────────────────────────────────────────────────────────────────
@@ -5092,6 +5067,7 @@ export function createDebugLoggingFetch(
}
};
}
export const noopDiskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
export type OmniRouteReadAuthJson = () => Promise<AuthJsonShape | undefined | null>;
@@ -5194,8 +5170,8 @@ export function createOmniRouteConfigHook(
const compressionMetaFetcher =
deps.compressionMetaFetcher ?? defaultOmniRouteCompressionMetaFetcher;
const providersFetcher = deps.providersFetcher ?? defaultOmniRouteProvidersFetcher;
const diskSnapshotReader = deps.diskSnapshotReader ?? noopDiskSnapshotReader;
const diskSnapshotWriter = deps.diskSnapshotWriter ?? noopDiskSnapshotWriter;
const diskSnapshotReader = deps.diskSnapshotReader ?? defaultDiskSnapshotReader;
const diskSnapshotWriter = deps.diskSnapshotWriter ?? defaultDiskSnapshotWriter;
const now = deps.now ?? Date.now;
const cache: OmniRouteFetchCache = deps.cache ?? new Map();
const logger = deps.logger ?? console;
@@ -5290,12 +5266,12 @@ export function createOmniRouteConfigHook(
const t = now();
const cached = cache.get(cacheKey);
let rawModels: OmniRouteRawModelEntry[] = [];
let rawCombos: OmniRouteRawCombo[] = [];
let rawAutoCombos: OmniRouteRawAutoCombo[] = [];
let rawEnrichment: OmniRouteEnrichmentMap = new Map();
let rawCompressionCombos: OmniRouteCompressionCombo[] = [];
let rawConnections: OmniRouteProviderConnection[] = [];
let rawModels: OmniRouteRawModelEntry[];
let rawCombos: OmniRouteRawCombo[];
let rawAutoCombos: OmniRouteRawAutoCombo[];
let rawEnrichment: OmniRouteEnrichmentMap;
let rawCompressionCombos: OmniRouteCompressionCombo[];
let rawConnections: OmniRouteProviderConnection[];
if (cached && cached.expiresAt > t) {
rawModels = cached.rawModels;
@@ -5305,275 +5281,160 @@ export function createOmniRouteConfigHook(
rawCompressionCombos = cached.rawCompressionCombos;
rawConnections = cached.rawConnections;
} else {
// ─────────────────────────────────────────────────────────────────────
// Warm startup: read the disk snapshot before fetching so the provider
// registers immediately with the last-known-good catalog. The live
// fetch then refreshes in the background (detached) and updates the
// cache + snapshot. Gated by features.diskCache (default-on).
// ─────────────────────────────────────────────────────────────────────
let warmSnapshot: Omit<OmniRouteFetchCacheEntry, "expiresAt"> | undefined;
if (wantDiskCache) {
const snapshotResult = await diskSnapshotReader(resolved.providerId, snapshotFingerprint);
if (snapshotResult && snapshotResult.rawModels.length > 0) {
warmSnapshot = snapshotResult;
// Log snapshot age (accept any age — instant beats empty).
const age = (snapshotResult as { writtenAt?: number }).writtenAt;
const ageLabel = typeof age === "number" ? `${Math.round((Date.now() - age) / 3_600_000)}h` : "unknown";
// Fail-open fetcher errors: on /v1/models throw, fall back to empty
// catalog (still publish a stub block so OC has a complete-shape
// entry); on /api/combos throw, publish models-only. Disk-cache
// fallback below recovers the last-known-good catalog when the
// fetcher threw (network down / 403 / timeout) AND features.diskCache
// !== false. A 0-entry SUCCESS (fresh tenant) does NOT trigger
// disk fallback — that's a valid empty catalog.
let modelsFetchThrew = false;
try {
rawModels = await fetcher(baseURL, apiKey, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /v1/models fetch failed; publishing stub provider entry",
err
);
rawModels = [];
modelsFetchThrew = true;
}
const modelsFetchOk = !modelsFetchThrew && rawModels.length > 0;
rawCombos = [];
try {
rawCombos = await combosFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/combos fetch failed; publishing models-only static catalog",
err
);
}
rawAutoCombos = [];
if (wantAutoCombos) {
try {
rawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000);
} catch {
// Already handled inside the default fetcher
}
}
// Eagerly fetch enrichment so the static block can overlay human
// display names on raw model ids. On OC ≤1.15.5 the dynamic
// `provider.models` hook never fires in `serve` mode, so the static
// block IS what reaches `/provider` and the TUI model picker.
// Gated by `features.enrichment` (default-on). Soft-fail on error —
// we still publish a name-less catalog if /api/pricing/models is
// unreachable.
rawEnrichment = new Map();
if (wantEnrichment) {
try {
rawEnrichment = await enrichmentFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
`[omniroute-plugin] config shim: warm startup from disk snapshot (${snapshotResult.rawModels.length} models, age ${ageLabel})`
"[omniroute-plugin] config shim: /api/pricing/models fetch failed; publishing raw-id static catalog",
err
);
}
}
// ─────────────────────────────────────────────────────────────────────
// Parallel refresh: all six fetchers run concurrently via
// Promise.allSettled. Each wrapper never rejects (catches internally)
// so partial failure is tolerated — same soft-fail semantics as the
// old sequential chain, but ~6x faster.
// ─────────────────────────────────────────────────────────────────────
const doRefresh = async (): Promise<void> => {
let modelsFetchThrew = false;
let localRawModels: OmniRouteRawModelEntry[] = [];
let localRawCombos: OmniRouteRawCombo[] = [];
let localRawAutoCombos: OmniRouteRawAutoCombo[] = [];
let localRawEnrichment: OmniRouteEnrichmentMap = new Map();
let localRawCompressionCombos: OmniRouteCompressionCombo[] = [];
let localRawConnections: OmniRouteProviderConnection[] = [];
// Each wrapper keeps the existing try/catch, default value, and
// exact warn message so per-endpoint fallbacks are preserved.
const doModels = async (): Promise<void> => {
try {
localRawModels = await fetcher(baseURL, apiKey, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /v1/models fetch failed; publishing stub provider entry",
err
);
localRawModels = [];
modelsFetchThrew = true;
}
};
const doCombos = async (): Promise<void> => {
try {
localRawCombos = await combosFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/combos fetch failed; publishing models-only static catalog",
err
);
}
};
const doAutoCombos = async (): Promise<void> => {
if (!wantAutoCombos) return;
try {
localRawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000);
} catch {
// Already handled inside the default fetcher
}
};
const doEnrichment = async (): Promise<void> => {
if (!wantEnrichment) return;
try {
localRawEnrichment = await enrichmentFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/pricing/models fetch failed; publishing raw-id static catalog",
err
);
}
};
const doCompression = async (): Promise<void> => {
if (!wantCompressionMeta) return;
try {
localRawCompressionCombos = await compressionMetaFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/context/combos fetch failed; publishing combos without compression suffix",
err
);
}
};
const doConnections = async (): Promise<void> => {
if (!wantUsableOnly) return;
try {
localRawConnections = await providersFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/providers fetch failed; usableOnly filter disabled for this refresh",
err
);
}
};
await Promise.allSettled([
doModels(),
doCombos(),
doAutoCombos(),
doEnrichment(),
doCompression(),
doConnections(),
]);
const modelsFetchOk = !modelsFetchThrew && localRawModels.length > 0;
// Disk-cache fallback (cold first run, no warm snapshot): when the
// live fetch returned no models AND features.diskCache !== false,
// hydrate from the last-known-good snapshot so OC still surfaces a
// usable catalog (e.g. IP whitelist drop, offline laptop).
if (modelsFetchThrew && wantDiskCache && !warmSnapshot) {
const snapshot = await diskSnapshotReader(resolved.providerId, snapshotFingerprint);
if (snapshot && snapshot.rawModels.length > 0) {
logger.warn(
`[omniroute-plugin] config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models)`
);
localRawModels = snapshot.rawModels;
localRawCombos = snapshot.rawCombos;
localRawAutoCombos = snapshot.rawAutoCombos ?? [];
localRawEnrichment = snapshot.rawEnrichment;
localRawCompressionCombos = snapshot.rawCompressionCombos;
localRawConnections = snapshot.rawConnections;
}
// Compression-metadata fetch — opt-in via features.compressionMetadata.
// When on, the default pipeline is appended to every combo `name` so
// the TUI picker advertises which compression a combo applies.
rawCompressionCombos = [];
if (wantCompressionMeta) {
try {
rawCompressionCombos = await compressionMetaFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/context/combos fetch failed; publishing combos without compression suffix",
err
);
}
}
// Cache even partial results — a subsequent provider-hook call should
// not re-burn the timeout window on the same broken endpoint.
cache.set(cacheKey, {
rawModels: localRawModels,
rawCombos: localRawCombos,
rawAutoCombos: localRawAutoCombos,
rawEnrichment: localRawEnrichment,
rawCompressionCombos: localRawCompressionCombos,
rawConnections: localRawConnections,
expiresAt: now() + resolved.modelCacheTtl,
// Provider-connections fetch — opt-in via features.usableOnly. When
// on, the static catalog filters out models/combos whose canonical
// provider has no active connection. Soft-fail (empty list) disables
// the filter for this refresh, never hiding the whole catalog.
rawConnections = [];
if (wantUsableOnly) {
try {
rawConnections = await providersFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/providers fetch failed; usableOnly filter disabled for this refresh",
err
);
}
}
// Disk-cache fallback: when the live fetch returned no models AND
// features.diskCache !== false, hydrate from the last-known-good
// snapshot so OC still surfaces a usable catalog (e.g. IP whitelist
// drop, offline laptop). The snapshot is whatever we last wrote on
// a healthy refresh; staleness is bounded only by how recently the
// user was online.
if (modelsFetchThrew && wantDiskCache) {
const snapshot = await diskSnapshotReader(resolved.providerId, snapshotFingerprint);
if (snapshot && snapshot.rawModels.length > 0) {
logger.warn(
`[omniroute-plugin] config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models)`
);
rawModels = snapshot.rawModels;
rawCombos = snapshot.rawCombos;
rawAutoCombos = snapshot.rawAutoCombos ?? [];
rawEnrichment = snapshot.rawEnrichment;
rawCompressionCombos = snapshot.rawCompressionCombos;
rawConnections = snapshot.rawConnections;
}
}
// Cache even partial results — a subsequent provider-hook call should
// not re-burn the timeout window on the same broken endpoint.
cache.set(cacheKey, {
rawModels,
rawCombos,
rawAutoCombos,
rawEnrichment,
rawCompressionCombos,
rawConnections,
expiresAt: t + resolved.modelCacheTtl,
});
// Startup diagnostics (file-based) — fires at startup via config hook
if (resolved.features?.startupDebug === true) {
await writeStartupDiagnostics({
providerId: resolved.providerId,
baseURL,
modelCount: rawModels.length,
comboCount: rawCombos.length,
enrichmentSize: rawEnrichment.size,
autoComboCount: rawAutoCombos.length,
enrichment: rawEnrichment,
autoCombos: rawAutoCombos,
features: resolved.features,
});
}
// Startup diagnostics (file-based) — fires at startup via config hook
if (resolved.features?.startupDebug === true) {
await writeStartupDiagnostics({
providerId: resolved.providerId,
baseURL,
modelCount: localRawModels.length,
comboCount: localRawCombos.length,
enrichmentSize: localRawEnrichment.size,
autoComboCount: localRawAutoCombos.length,
enrichment: localRawEnrichment,
autoCombos: localRawAutoCombos,
features: resolved.features,
});
}
// Disk-cache write: persist the last successful (or any non-empty)
// catalog so a subsequent cold start with a failed fetch can recover.
// Best-effort; soft-fail keeps us moving when the data dir isn't
// writable (e.g. read-only container). A failed refresh never
// overwrites the snapshot (modelsFetchOk gate).
if (modelsFetchOk && wantDiskCache) {
await diskSnapshotWriter(
resolved.providerId,
{
rawModels: localRawModels,
rawCombos: localRawCombos,
rawAutoCombos: localRawAutoCombos,
rawEnrichment: localRawEnrichment,
rawCompressionCombos: localRawCompressionCombos,
rawConnections: localRawConnections,
},
snapshotFingerprint
);
}
// Re-publish a fresh block via the shared cache so OC >=1.14.49's
// dynamic provider hook picks it up from the cache. When the models
// fetch threw and a warm snapshot was served, keep the warm block
// (no downgrade to stub).
if (modelsFetchOk || !warmSnapshot) {
const freshBlock = buildStaticProviderEntry(
localRawModels,
localRawCombos,
resolved,
baseURL,
apiKey,
localRawEnrichment,
localRawCompressionCombos,
localRawConnections,
localRawAutoCombos
);
const inputWithProvider2 = input as { provider?: Record<string, unknown> };
if (inputWithProvider2.provider) {
inputWithProvider2.provider[resolved.providerId] = freshBlock;
}
}
};
if (warmSnapshot) {
// Warm startup: publish the snapshot block immediately, then run
// the refresh detached (never a floating unhandled rejection).
rawModels = warmSnapshot.rawModels;
rawCombos = warmSnapshot.rawCombos;
rawAutoCombos = warmSnapshot.rawAutoCombos ?? [];
rawEnrichment = warmSnapshot.rawEnrichment;
rawCompressionCombos = warmSnapshot.rawCompressionCombos;
rawConnections = warmSnapshot.rawConnections;
// In-flight guard: if a refresh is already running for this
// cacheKey, piggyback on it instead of starting a second one.
const existing = _inflightRefresh.get(cacheKey);
if (existing) {
// Another refresh is in-flight — don't start a second one.
// The existing refresh will update the cache when it completes.
} else {
const refreshP = doRefresh()
.catch((err: unknown) => {
logger.warn("[omniroute-plugin] config shim: background refresh failed", err);
})
.finally(() => {
_inflightRefresh.delete(cacheKey);
});
_inflightRefresh.set(cacheKey, refreshP);
}
} else {
// Cold first run (no warm snapshot): await the refresh so the
// first publish is always correct. In-flight guard still applies.
const existing = _inflightRefresh.get(cacheKey);
if (existing) {
await existing;
// After the in-flight refresh completes, the cache has the data.
const fresh = cache.get(cacheKey);
if (fresh) {
rawModels = fresh.rawModels;
rawCombos = fresh.rawCombos;
rawAutoCombos = fresh.rawAutoCombos;
rawEnrichment = fresh.rawEnrichment;
rawCompressionCombos = fresh.rawCompressionCombos;
rawConnections = fresh.rawConnections;
}
} else {
const refreshP = doRefresh()
.catch((err: unknown) => {
logger.warn("[omniroute-plugin] config shim: refresh failed", err);
})
.finally(() => {
_inflightRefresh.delete(cacheKey);
});
_inflightRefresh.set(cacheKey, refreshP);
await refreshP;
// After the refresh, the cache has the data.
const fresh = cache.get(cacheKey);
if (fresh) {
rawModels = fresh.rawModels;
rawCombos = fresh.rawCombos;
rawAutoCombos = fresh.rawAutoCombos;
rawEnrichment = fresh.rawEnrichment;
rawCompressionCombos = fresh.rawCompressionCombos;
rawConnections = fresh.rawConnections;
}
}
// Disk-cache write: persist the last successful (or any non-empty)
// catalog so a subsequent cold start with a failed fetch can recover.
// Best-effort; soft-fail keeps us moving when the data dir isn't
// writable (e.g. read-only container).
if (modelsFetchOk && wantDiskCache) {
await diskSnapshotWriter(
resolved.providerId,
{
rawModels,
rawCombos,
rawAutoCombos,
rawEnrichment,
rawCompressionCombos,
rawConnections,
},
snapshotFingerprint
);
}
}

View File

@@ -33,7 +33,6 @@ import {
createOmniRouteProviderHook,
OmniRoutePlugin,
resolveOmniRoutePluginOptions,
_resetInflightRefresh,
type OmniRouteCombosFetcher,
type OmniRouteEnrichmentEntry,
type OmniRouteEnrichmentFetcher,
@@ -48,16 +47,6 @@ import {
type OmniRouteStaticProviderEntry,
} from "../src/index.js";
// ────────────────────────────────────────────────────────────────────────────
// Test isolation: reset the module-level in-flight refresh guard between
// tests so a detached refresh from a previous test doesn't leak into the
// next one.
// ────────────────────────────────────────────────────────────────────────────
test.beforeEach(() => {
_resetInflightRefresh();
});
// ────────────────────────────────────────────────────────────────────────────
// Fixtures
// ────────────────────────────────────────────────────────────────────────────
@@ -1250,10 +1239,7 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async (
);
assert.equal(writes, 0, "disk write skipped when live fetch failed");
assert.ok(
logger.entries.some((e) =>
String(e[0]).includes("using stale disk cache") ||
String(e[0]).includes("warm startup from disk snapshot")
),
logger.entries.some((e) => String(e[0]).includes("using stale disk cache")),
"disk-cache hydration breadcrumb emitted"
);
});

View File

@@ -1,827 +0,0 @@
/**
* Warm-startup + parallel-refresh tests for the opencode-plugin config shim.
*
* Covers `createOmniRouteConfigHook(opts, deps)`:
* - (a) Warm startup: cache miss + matching snapshot → provider block
* populated from snapshot data (not live fetch data).
* - (b) Fingerprint mismatch: reader returns undefined → no warm publish,
* falls through to awaited fetch (cold-start behavior).
* - (c) Successful parallel refresh: all fetchers resolve → cache updated,
* disk snapshot written.
* - (d) Failed refresh keeps the snapshot: warm-served + models fetcher
* rejects → no disk overwrite, block stays at warm-snapshot shape.
* - (e) Parallelism: all six fetchers start concurrently (not sequential).
* - (f) Soft-fail parity under Promise.allSettled: per-endpoint
* fallbacks + logger.warn breadcrumbs preserved.
* - (g) No double-refresh: concurrent hook invocations on the same cacheKey
* trigger only one refresh (in-flight guard).
* - (h) features.diskCache: false disables the warm read entirely.
*
* Mocking strategy: every dependency is DI-injected at hook construction
* (same pattern as config-shim.test.ts). No global monkey-patching.
*/
import test from "node:test";
import assert from "node:assert/strict";
import type { Config } from "@opencode-ai/plugin";
import {
createOmniRouteConfigHook,
resolveOmniRoutePluginOptions,
_resetInflightRefresh,
type OmniRouteAutoCombosFetcher,
type OmniRouteCombosFetcher,
type OmniRouteCompressionMetaFetcher,
type OmniRouteEnrichmentEntry,
type OmniRouteEnrichmentFetcher,
type OmniRouteEnrichmentMap,
type OmniRouteFetchCache,
type OmniRouteModelsFetcher,
type OmniRouteProviderConnection,
type OmniRouteProvidersFetcher,
type OmniRouteRawAutoCombo,
type OmniRouteRawCombo,
type OmniRouteRawModelEntry,
type OmniRouteReadAuthJson,
type OmniRouteStaticProviderEntry,
type OmniRouteDiskSnapshotReader,
type OmniRouteDiskSnapshotWriter,
type OmniRouteCompressionCombo,
} from "../src/index.js";
// ────────────────────────────────────────────────────────────────────────────
// Test isolation: reset the module-level in-flight refresh guard between
// tests so a detached refresh from a previous test doesn't leak into the
// next one (same cacheKey, different cache instance).
// ────────────────────────────────────────────────────────────────────────────
test.beforeEach(() => {
_resetInflightRefresh();
});
// ────────────────────────────────────────────────────────────────────────────
// Fixtures
// ────────────────────────────────────────────────────────────────────────────
const MODEL_CLAUDE: OmniRouteRawModelEntry = {
id: "claude-sonnet-4-6",
capabilities: {
tool_calling: true,
reasoning: true,
vision: true,
thinking: false,
temperature: true,
},
context_length: 200_000,
max_output_tokens: 64_000,
max_input_tokens: 180_000,
input_modalities: ["text", "image"],
output_modalities: ["text"],
};
const MODEL_GEMINI: OmniRouteRawModelEntry = {
id: "gemini-3-flash",
capabilities: { tool_calling: true, reasoning: false, vision: true, thinking: false },
context_length: 1_000_000,
max_output_tokens: 8_192,
input_modalities: ["text", "image"],
output_modalities: ["text"],
};
const COMBO_CLAUDE_TIER: OmniRouteRawCombo = {
id: "combo-claude-tier",
name: "Claude Tier",
models: [
{ id: "s1", kind: "model", model: "claude-sonnet-4-6", weight: 100 },
{ id: "s2", kind: "model", model: "gemini-3-flash", weight: 50 },
],
};
const AUTO_COMBO: OmniRouteRawAutoCombo = {
id: "auto",
name: "Auto",
};
const COMPRESSION_COMBO: OmniRouteCompressionCombo = {
id: "ctx-combo-1",
name: "Context Combo",
pipeline: "gzip",
};
const CONNECTION_CLAUDE: OmniRouteProviderConnection = {
id: "c1",
provider: "claude",
isActive: true,
testStatus: "active",
};
// ────────────────────────────────────────────────────────────────────────────
// DI stub helpers
// ────────────────────────────────────────────────────────────────────────────
function stubReadAuthJson(
value: Record<string, unknown> | undefined | null
): OmniRouteReadAuthJson {
return async () => value as never;
}
function immediateFetcher<T extends (...args: unknown[]) => Promise<unknown>>(
payload: ReturnType<T> extends Promise<infer U> ? U : never
): T & { callCount: () => number; startedAt: () => number | undefined } {
let n = 0;
let start: number | undefined;
const f = async (..._args: unknown[]) => {
start = Date.now();
n++;
return payload;
};
return Object.assign(f as T, { callCount: () => n, startedAt: () => start });
}
function throwingFetcher<T extends (...args: unknown[]) => Promise<unknown>>(
msg = "ECONNREFUSED"
): T & { callCount: () => number } {
let n = 0;
const f = async (..._args: unknown[]) => {
n++;
throw new Error(msg);
};
return Object.assign(f as T, { callCount: () => n });
}
interface WarnCapture {
warn: (...args: unknown[]) => void;
entries: unknown[][];
}
function captureWarn(): WarnCapture {
const entries: unknown[][] = [];
return {
warn: (...args: unknown[]) => {
entries.push(args);
},
entries,
};
}
function makeInput(initialProvider: Record<string, unknown> = {}): Config {
return { provider: initialProvider } as unknown as Config;
}
/** Build a valid auth.json stub for the default providerId. */
function authStub() {
return stubReadAuthJson({
"opencode-omniroute": {
type: "api",
key: "sk-test",
baseURL: "https://or.example.com/v1",
},
});
}
// ────────────────────────────────────────────────────────────────────────────
// (a) Warm startup: cache miss + matching snapshot → provider block populated
// from snapshot data (not live fetch data)
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: snapshot data used when snapshot is present", async () => {
// Live fetch returns MODEL_CLAUDE, but snapshot has MODEL_GEMINI.
// With warm startup, the block should contain the snapshot data.
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const autoCombosFetcher = immediateFetcher<OmniRouteAutoCombosFetcher>([]);
const enrichmentFetcher = immediateFetcher<OmniRouteEnrichmentFetcher>(new Map());
const compressionMetaFetcher = immediateFetcher<OmniRouteCompressionMetaFetcher>([]);
const providersFetcher = immediateFetcher<OmniRouteProvidersFetcher>([]);
const logger = captureWarn();
const snapshot: Omit<import("../src/index.js").OmniRouteFetchCacheEntry, "expiresAt"> = {
rawModels: [MODEL_GEMINI],
rawCombos: [],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
};
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute" },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
autoCombosFetcher,
enrichmentFetcher,
compressionMetaFetcher,
providersFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
const provider = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider;
const entry = provider["opencode-omniroute"];
assert.ok(entry, "provider entry published");
// With warm startup, the block should contain the snapshot data (GEMINI),
// not the live fetch data (CLAUDE). This is the key assertion: the warm
// snapshot is served first, and the live refresh updates the cache in the
// background. On the next hook invocation, the cache will have the fresh data.
const hasGemini = entry.models["opencode-omniroute/gemini-3-flash"] !== undefined;
const hasClaude = entry.models["opencode-omniroute/claude-sonnet-4-6"] !== undefined;
assert.ok(
hasGemini || hasClaude,
"provider block has at least one model"
);
// The warm-startup breadcrumb should be emitted.
assert.ok(
logger.entries.some((e) =>
String(e[0]).includes("warm startup from disk snapshot")
),
"warm-startup breadcrumb emitted"
);
});
// ────────────────────────────────────────────────────────────────────────────
// (b) Fingerprint mismatch: reader returns undefined → no warm publish,
// falls through to awaited fetch
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: fingerprint mismatch → no warm publish, awaited fetch", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const logger = captureWarn();
// Reader returns undefined → fingerprint mismatch or missing snapshot.
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute" },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "provider entry published from live fetch");
// Live fetch data, not snapshot data.
assert.ok(
entry.models["opencode-omniroute/claude-sonnet-4-6"],
"live fetch model present"
);
assert.equal(fetcher.callCount(), 1, "fetcher was called (awaited cold path)");
// No warm-startup breadcrumb when no snapshot.
assert.ok(
!logger.entries.some((e) =>
String(e[0]).includes("warm startup from disk snapshot")
),
"no warm-startup breadcrumb when no snapshot"
);
});
// ────────────────────────────────────────────────────────────────────────────
// (c) Successful parallel refresh: all fetchers resolve → cache updated,
// disk snapshot written, block re-published with fresh data
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: parallel refresh updates cache + writes snapshot", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([COMBO_CLAUDE_TIER]);
const autoCombosFetcher = immediateFetcher<OmniRouteAutoCombosFetcher>([AUTO_COMBO]);
const enrichmentFetcher = immediateFetcher<OmniRouteEnrichmentFetcher>(
new Map<string, OmniRouteEnrichmentEntry>([
["claude-sonnet-4-6", { name: "Claude Sonnet 4.6" }],
])
);
const compressionMetaFetcher = immediateFetcher<OmniRouteCompressionMetaFetcher>([
COMPRESSION_COMBO,
]);
const providersFetcher = immediateFetcher<OmniRouteProvidersFetcher>([CONNECTION_CLAUDE]);
const logger = captureWarn();
const snapshot: Omit<import("../src/index.js").OmniRouteFetchCacheEntry, "expiresAt"> = {
rawModels: [MODEL_GEMINI],
rawCombos: [],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
};
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot;
let snapshotWrites = 0;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {
snapshotWrites++;
};
const sharedCache: OmniRouteFetchCache = new Map();
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", modelCacheTtl: 60_000 },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
autoCombosFetcher,
enrichmentFetcher,
compressionMetaFetcher,
providersFetcher,
diskSnapshotReader,
diskSnapshotWriter,
cache: sharedCache,
logger,
}
);
const input = makeInput();
await hook(input);
// Warm block should have been published.
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "warm provider entry published");
// Give detached refresh time to complete.
await new Promise((r) => setTimeout(r, 100));
// After parallel refresh, the cache should have the fresh data.
const cacheKey = Array.from(sharedCache.keys())[0];
assert.ok(cacheKey, "cache entry created");
const cached = sharedCache.get(cacheKey)!;
assert.ok(cached.expiresAt > 0, "cache entry has expiresAt");
// Fresh data from the live fetchers (not the stale snapshot).
assert.equal(cached.rawModels.length, 1, "cache has fresh models");
assert.equal(cached.rawModels[0].id, "claude-sonnet-4-6", "cache has correct model");
// Disk snapshot should have been written.
assert.equal(snapshotWrites, 1, "disk snapshot written after successful refresh");
});
// ────────────────────────────────────────────────────────────────────────────
// (d) Failed refresh keeps the snapshot: warm-served + models fetcher
// rejects → no disk overwrite, block stays at warm-snapshot shape
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: failed refresh keeps the snapshot, no disk overwrite", async () => {
const fetcher = throwingFetcher<OmniRouteModelsFetcher>();
const combosFetcher = throwingFetcher<OmniRouteCombosFetcher>();
const logger = captureWarn();
const snapshot: Omit<import("../src/index.js").OmniRouteFetchCacheEntry, "expiresAt"> = {
rawModels: [MODEL_GEMINI],
rawCombos: [COMBO_CLAUDE_TIER],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
};
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot;
let snapshotWrites = 0;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {
snapshotWrites++;
};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute" },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "warm provider entry published");
// The block should contain the warm snapshot data (gemini), not be
// downgraded to a stub.
assert.ok(
entry.models["opencode-omniroute/gemini-3-flash"],
"warm snapshot model preserved (not downgraded to stub)"
);
// Give detached refresh time to complete.
await new Promise((r) => setTimeout(r, 100));
// No disk write on failed refresh.
assert.equal(snapshotWrites, 0, "no disk snapshot written when models fetch failed");
});
// ────────────────────────────────────────────────────────────────────────────
// (e) Parallelism: all six fetchers start concurrently (not sequential)
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: all fetchers start concurrently (parallel fan-out)", async () => {
const startTimes: number[] = [];
const barrier = new Promise<void>((r) => {
setTimeout(r, 30);
});
function instrumentedFetcher<T extends (...args: unknown[]) => Promise<unknown>>(
payload: ReturnType<T> extends Promise<infer U> ? U : never
): T & { callCount: () => number } {
let n = 0;
const f = async (..._args: unknown[]) => {
startTimes.push(Date.now());
n++;
await barrier;
return payload;
};
return Object.assign(f as T, { callCount: () => n });
}
const fetcher = instrumentedFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = instrumentedFetcher<OmniRouteCombosFetcher>([]);
const autoCombosFetcher = instrumentedFetcher<OmniRouteAutoCombosFetcher>([]);
const enrichmentFetcher = instrumentedFetcher<OmniRouteEnrichmentFetcher>(new Map());
const compressionMetaFetcher = instrumentedFetcher<OmniRouteCompressionMetaFetcher>([]);
const providersFetcher = instrumentedFetcher<OmniRouteProvidersFetcher>([]);
const logger = captureWarn();
// No snapshot → cold path (awaited). All fetchers must still start
// concurrently.
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", features: { enrichment: true, compressionMetadata: true, usableOnly: true } },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
autoCombosFetcher,
enrichmentFetcher,
compressionMetaFetcher,
providersFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
// All fetchers should have been called.
assert.equal(fetcher.callCount(), 1, "models fetcher called");
assert.equal(combosFetcher.callCount(), 1, "combos fetcher called");
assert.equal(autoCombosFetcher.callCount(), 1, "autoCombos fetcher called");
assert.equal(enrichmentFetcher.callCount(), 1, "enrichment fetcher called");
assert.equal(compressionMetaFetcher.callCount(), 1, "compressionMeta fetcher called");
assert.equal(providersFetcher.callCount(), 1, "providers fetcher called");
// All start times should be within 20ms of each other (parallel fan-out),
// NOT sequential (which would show ~30ms gaps between each).
assert.ok(startTimes.length >= 6, "all 6 fetchers started");
const minStart = Math.min(...startTimes);
const maxStart = Math.max(...startTimes);
assert.ok(
maxStart - minStart < 20,
`all fetchers started within 20ms (spread: ${maxStart - minStart}ms) — parallel fan-out confirmed`
);
});
// ────────────────────────────────────────────────────────────────────────────
// (f) Soft-fail parity under Promise.allSettled: per-endpoint fallbacks +
// logger.warn breadcrumbs preserved
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: combos reject → models-only catalog with warn", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = throwingFetcher<OmniRouteCombosFetcher>("403 Forbidden");
const logger = captureWarn();
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute" },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "provider entry published");
assert.ok(
entry.models["opencode-omniroute/claude-sonnet-4-6"],
"models-only catalog (no combos)"
);
assert.ok(
logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")),
"combos-fetch breadcrumb emitted"
);
});
test("warm-startup: enrichment rejects → raw-id catalog with warn", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const enrichmentFetcher = throwingFetcher<OmniRouteEnrichmentFetcher>("ETIMEDOUT");
const logger = captureWarn();
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute" },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
enrichmentFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "provider entry published");
assert.equal(
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
"claude-sonnet-4-6",
"raw id retained (no enrichment)"
);
assert.ok(
logger.entries.some((e) => String(e[0]).includes("/api/pricing/models fetch failed")),
"enrichment-fetch breadcrumb emitted"
);
});
test("warm-startup: providers reject → usableOnly filter disabled with warn", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const providersFetcher = throwingFetcher<OmniRouteProvidersFetcher>("ETIMEDOUT");
const logger = captureWarn();
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", features: { usableOnly: true } },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
providersFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "provider entry published");
// Soft-fail: model kept (filter disabled).
assert.ok(
entry.models["opencode-omniroute/claude-sonnet-4-6"],
"model kept (usableOnly filter disabled)"
);
assert.ok(
logger.entries.some((e) => String(e[0]).includes("/api/providers fetch failed")),
"providers-fetch breadcrumb emitted"
);
});
// ────────────────────────────────────────────────────────────────────────────
// (g) No double-refresh: concurrent hook invocations on the same cacheKey
// trigger only one refresh (in-flight guard)
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: concurrent hook invocations dedupe refresh", async () => {
let fetchCount = 0;
const slowResolve = new Promise<void>((r) => {
setTimeout(r, 100);
});
const fetcher: OmniRouteModelsFetcher = async () => {
fetchCount++;
await slowResolve;
return [MODEL_CLAUDE];
};
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const logger = captureWarn();
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const sharedCache: OmniRouteFetchCache = new Map();
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", modelCacheTtl: 60_000 },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
diskSnapshotReader,
diskSnapshotWriter,
cache: sharedCache,
logger,
}
);
// Fire two concurrent hook invocations on the same cache.
const inputA = makeInput();
const inputB = makeInput();
await Promise.all([hook(inputA), hook(inputB)]);
// Both should have published, but the refresh should only run once.
assert.equal(
fetchCount,
1,
"models fetcher called only once across concurrent invocations (in-flight guard)"
);
});
// ────────────────────────────────────────────────────────────────────────────
// (h) features.diskCache: false disables the warm read entirely
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: diskCache=false disables warm read, falls through to awaited fetch", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const logger = captureWarn();
let readerCalled = false;
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => {
readerCalled = true;
return {
rawModels: [MODEL_GEMINI],
rawCombos: [],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
};
};
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", features: { diskCache: false } },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
assert.equal(readerCalled, false, "disk snapshot reader NOT called when diskCache=false");
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "provider entry published from live fetch");
assert.ok(
entry.models["opencode-omniroute/claude-sonnet-4-6"],
"live fetch model present (not snapshot)"
);
});
// ────────────────────────────────────────────────────────────────────────────
// Warm startup: snapshot age logged
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: snapshot age is logged when warm-starting from disk", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const logger = captureWarn();
const snapshot: Omit<import("../src/index.js").OmniRouteFetchCacheEntry, "expiresAt"> & {
writtenAt?: number;
} = {
rawModels: [MODEL_GEMINI],
rawCombos: [],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
writtenAt: Date.now() - 3_600_000, // 1 hour ago
};
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute" },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
// The log should mention "warm startup from disk snapshot".
assert.ok(
logger.entries.some((e) =>
String(e[0]).includes("warm startup from disk snapshot")
),
"warm-startup breadcrumb emitted"
);
});
// ────────────────────────────────────────────────────────────────────────────
// Warm startup: empty snapshot (rawModels.length === 0) is skipped
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: empty snapshot (rawModels.length=0) is skipped, falls through to fetch", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const logger = captureWarn();
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => ({
rawModels: [],
rawCombos: [],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
});
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute" },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "provider entry published from live fetch");
// Live data, not empty snapshot.
assert.ok(
entry.models["opencode-omniroute/claude-sonnet-4-6"],
"live fetch model present (empty snapshot skipped)"
);
assert.equal(fetcher.callCount(), 1, "fetcher was called (awaited cold path)");
});

View File

@@ -1,5 +0,0 @@
---
feature: 9490
---
**Warm catalog startup from disk snapshot + parallel refresh** (opencode-plugin): The config-shim hook now reads the last disk snapshot *before* fetching, so the provider registers immediately with the last-known-good catalog (~1-2s vs ~30s on a warm gateway). All six fetchers run concurrently via `Promise.allSettled` instead of sequentially. A failed refresh keeps the snapshot (no overwrite). An in-flight guard prevents concurrent refreshes for the same cache key. The `features.diskCache: false` opt-out disables the warm read entirely.

View File

@@ -0,0 +1 @@
- **fix(quota):** Deleting a quota pool now removes its scoped managed combos without racing in-flight pool mutations ([#8906](https://github.com/diegosouzapw/OmniRoute/pull/8906)) — thanks @xiaoyaner0201

View File

@@ -0,0 +1 @@
- fix(background): detect Anthropic top-level system prompts for background task detection (#9142)

View File

@@ -0,0 +1 @@
- fix(model-discovery): ingest capabilities.effort_tiers for synced models (#9160)

View File

@@ -0,0 +1 @@
- fix(translator): buffer and normalize upstream tool-call argument deltas so optional null values are stripped before reaching the client (#9168)

View File

@@ -0,0 +1 @@
- fix(translator): avoid double-normalizing tool names in Gemini-to-Claude response path (#9177)

View File

@@ -0,0 +1 @@
- fix(playground): surface provider model loading errors and offer retry (#9626)

View File

@@ -8,9 +8,32 @@ export const gemini_webProvider: RegistryEntry = {
baseUrl: "https://gemini.google.com/app",
authType: "apikey",
authHeader: "cookie",
// #9356: `supportsReasoning: false` is a live-behavior statement, not a guess
// about the underlying Gemini model. The executor drives the gemini.google.com
// web UI by typing a prompt, so it has no thinking-budget control to set and
// never surfaces `reasoning_content` — agent routers reading /v1/models must
// not select these for reasoning work. `toolCalling: false` is the matching
// statement for native function calling; the prompt-emulation shim (#7286)
// stays available and is advertised separately as `toolCalling: "emulated"`
// on the provider constant (src/shared/constants/providers/web-cookie.ts).
models: [
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", toolCalling: false },
{ id: "gemini-3.5-flash", name: "Gemini 3.5 Flash", toolCalling: false },
{ id: "gemini-3.1-flash-lite", name: "Gemini 3.1 Flash-Lite", toolCalling: false },
{
id: "gemini-3.1-pro",
name: "Gemini 3.1 Pro",
toolCalling: false,
supportsReasoning: false,
},
{
id: "gemini-3.5-flash",
name: "Gemini 3.5 Flash",
toolCalling: false,
supportsReasoning: false,
},
{
id: "gemini-3.1-flash-lite",
name: "Gemini 3.1 Flash-Lite",
toolCalling: false,
supportsReasoning: false,
},
],
};

View File

@@ -14,9 +14,13 @@
*/
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts";
import { prepareToolMessages } from "../translator/webTools.ts";
import { buildToolModeResponse } from "./chatgptWebTools.ts";
import {
checkGeminiWebUnsupportedControls,
GEMINI_WEB_UNSUPPORTED_CONTROL_CODE,
} from "./gemini-web/capabilities.ts";
// ─── Constants ──────────────────────────────────────────────────────────────
@@ -406,6 +410,33 @@ export class GeminiWebExecutor extends BaseExecutor {
const { model, body, stream, credentials, signal, log, onCredentialsRefreshed } = input;
const requestBody = body as GeminiRequestBody;
// #9356: fail fast on controls this provider cannot honor (reasoning_effort
// above "minimal", forced tool_choice). Runs before the credential check and
// before Playwright launches — the request is unservable no matter which
// cookie is used, and answering 200 with ordinary prose made agents believe
// their reasoning/tool requirements had been met. See ./gemini-web/capabilities.ts.
const violation = checkGeminiWebUnsupportedControls(body as Record<string, unknown>);
if (violation) {
log?.warn?.(
"GEMINI-WEB",
`Rejected request: "${violation.param}" is not supported by this provider`
);
return {
response: new Response(
JSON.stringify(
buildErrorBody(400, violation.message, null, {
type: "invalid_request_error",
code: GEMINI_WEB_UNSUPPORTED_CONTROL_CODE,
})
),
{ status: 400, headers: { "Content-Type": "application/json" } }
),
url: GEMINI_URL,
headers: {},
transformedBody: body,
};
}
const cookie = resolveGeminiWebCookie(credentials);
if (!cookie) {
return {

View File

@@ -0,0 +1,121 @@
/**
* Request-contract guards for the Gemini Web executor (#9356).
*
* gemini-web is not an API client. It launches Playwright, types ONE flat
* prompt string into the gemini.google.com `.ql-editor` contenteditable,
* presses Enter, and captures the first `StreamGenerate` response off the page
* (see ../gemini-web.ts). There is no JSON request body on the wire, which
* makes two OpenAI controls structurally impossible to honor:
*
* • `reasoning_effort` — no field exists to carry a thinking budget. Unlike
* deepseek-web or perplexity-web, which post a real payload and can flip a
* `thinking_enabled` flag or swap the model preference, there is nothing
* here to set.
* • forced `tool_choice` — the tools support gemini-web does have is the
* prompt-emulation shim (`translator/webTools.ts`, #7286): it ASKS the
* model, in prose, to answer with `<tool>{...}</tool>` and parses whatever
* comes back. That is best-effort by construction. "required" / "any" /
* a named function is a GUARANTEE, and a prompt cannot make one.
*
* Before this module both were accepted and quietly ignored, so an agent got a
* 200 with `finish_reason: "stop"`, no `reasoning_content`, and `tool_calls: []`
* and concluded its requirements had been met (#9356). Failing the request is
* the honest answer: the caller can drop the control, or route to a model that
* actually implements it.
*
* Deliberately NOT rejected — these are already satisfied or already work:
* • `reasoning_effort: "none" | "minimal"` — asking for as little reasoning as
* possible is something a non-thinking provider trivially complies with.
* • `tool_choice: "auto" | "none"` and plain `tools[]` — the #7286 emulation
* path, which several shipped combos depend on (#5240, #8488). Untouched.
*
* Pure and dependency-free so the whole contract is unit-testable without a
* browser.
*/
/** `error.code` on every compatibility rejection raised here. */
export const GEMINI_WEB_UNSUPPORTED_CONTROL_CODE = "unsupported_control_for_provider";
/** Effort levels a non-thinking provider already complies with. */
const SATISFIED_EFFORT_LEVELS = new Set(["none", "minimal"]);
/** `tool_choice` strings that demand a tool call rather than merely offering one. */
const FORCING_TOOL_CHOICE_STRINGS = new Set(["required", "any"]);
/** `tool_choice: { type }` values that pin the model to a specific/any tool. */
const FORCING_TOOL_CHOICE_TYPES = new Set(["function", "tool", "any"]);
export interface GeminiWebCapabilityViolation {
/** Which request field could not be honored. */
param: "reasoning_effort" | "tool_choice";
/** Client-facing explanation — already safe to put in a response body. */
message: string;
}
function normalizeString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim().toLowerCase() : null;
}
/**
* True when `tool_choice` demands a tool call. Covers the OpenAI strings
* ("required"), the Anthropic-flavored ones the translators also emit ("any"),
* and the object forms that name a function or force any tool. "auto" / "none"
* and every unrecognized shape are treated as non-forcing — this guard only
* blocks contracts it is certain gemini-web cannot keep.
*/
export function isForcingToolChoice(toolChoice: unknown): boolean {
const asString = normalizeString(toolChoice);
if (asString) return FORCING_TOOL_CHOICE_STRINGS.has(asString);
if (toolChoice && typeof toolChoice === "object" && !Array.isArray(toolChoice)) {
const type = normalizeString((toolChoice as Record<string, unknown>).type);
return type !== null && FORCING_TOOL_CHOICE_TYPES.has(type);
}
return false;
}
/** True when `reasoning_effort` asks for MORE thinking than "none at all". */
export function requestsThinkingBudget(reasoningEffort: unknown): boolean {
const effort = normalizeString(reasoningEffort);
if (effort === null) return false;
return !SATISFIED_EFFORT_LEVELS.has(effort);
}
/**
* Inspect an OpenAI-shaped request body for controls gemini-web cannot honor.
* Returns the first violation found, or `null` when the request is servable.
*
* `reasoning_effort` is checked before `tool_choice` only for determinism; a
* request carrying both is rejected either way.
*/
export function checkGeminiWebUnsupportedControls(
body: Record<string, unknown> | null | undefined
): GeminiWebCapabilityViolation | null {
if (!body || typeof body !== "object") return null;
if (requestsThinkingBudget(body.reasoning_effort)) {
return {
param: "reasoning_effort",
message:
'Model provider "gemini-web" does not support "reasoning_effort". It drives the ' +
"gemini.google.com web UI through a typed prompt and has no thinking-budget control " +
'to set, so any effort above "minimal" would be silently ignored. Remove ' +
'"reasoning_effort" (or send "none"/"minimal") or route to a reasoning-capable model.',
};
}
if (isForcingToolChoice(body.tool_choice)) {
return {
param: "tool_choice",
message:
'Model provider "gemini-web" cannot guarantee a forced tool call. Its tool support is ' +
"prompt-emulated — the model is asked to emit a tool block and may answer with prose " +
'instead — so "tool_choice" values that require one ("required", "any", or a named ' +
'function) cannot be honored. Use "auto" to keep best-effort tool calling, or route to ' +
"a model with native function calling.",
};
}
return null;
}

View File

@@ -190,15 +190,31 @@ export function getBackgroundTaskReason(
const messages = toMessageArray(typedBody.messages ?? typedBody.input ?? []);
if (!Array.isArray(messages) || messages.length === 0) return null;
// Find system message
// Derive system content from messages array (OpenAI format) or top-level
// system field (Anthropic format).
const systemMsg = messages.find(
(message: BackgroundMessage) => message.role === "system" || message.role === "developer"
);
if (!systemMsg) return null;
const systemContent =
typeof systemMsg.content === "string" ? systemMsg.content.toLowerCase() : "";
let systemContent = "";
if (systemMsg && typeof systemMsg.content === "string") {
systemContent = systemMsg.content.toLowerCase();
} else if (!systemMsg) {
// Anthropic top-level system field: string or array of text blocks
const raw = (typedBody as Record<string, unknown>).system;
if (typeof raw === "string") {
systemContent = raw.toLowerCase();
} else if (Array.isArray(raw)) {
systemContent = raw
.map((part) =>
part && typeof (part as { text?: unknown }).text === "string"
? (part as { text: string }).text
: ""
)
.filter(Boolean)
.join(" ")
.toLowerCase();
}
}
if (!systemContent) return null;
// Check against detection patterns

View File

@@ -112,7 +112,7 @@ export function geminiToClaudeResponse(chunk, state) {
// 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 restoredToolName = mappedName ?? normalizeToolName(rawToolName);
const idx = state.contentBlockIndex++;
const toolId = fc.id || `toolu_${Date.now()}_${idx}`;

View File

@@ -874,6 +874,7 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
if (state.currentToolCallId) state.toolCallIdsSeen.add(state.currentToolCallId);
const toolName = normalizeToolName(item.name);
state.currentToolName = toolName; // track for schema lookup at done time
if (!toolName) {
// Some Responses providers briefly emit placeholder/empty tool names.
// Defer emission until output_item.done in case the final name is populated there.
@@ -919,26 +920,9 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
state.currentToolCallArgsBuffer = (state.currentToolCallArgsBuffer || "") + argsDelta;
if (state.currentToolCallDeferred) return null;
return {
id: state.chatId,
object: "chat.completion.chunk",
created: state.created,
model: state.model || "gpt-4",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: state.toolCallIndex,
function: { arguments: argsDelta },
},
],
},
finish_reason: null,
},
],
};
// #9168: buffer arguments until output_item.done for schema-aware null normalization
// Previously emitted raw null values for optional enum fields (e.g. isolation: null).
return null;
}
// Function call done — emit args chunk from item.arguments when no deltas were received,
@@ -1011,6 +995,35 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
if (item.arguments != null && !buffered) {
const argsToEmit = stripEmptyOptionalToolArgs(item.arguments, toolName, toolSchema);
const argsStr = typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit);
if (argsStr) {
return {
id: state.chatId,
object: "chat.completion.chunk",
created: state.created,
model: state.model || "gpt-4",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: currentIndex,
function: { arguments: argsStr },
},
],
},
finish_reason: null,
},
],
};
}
} else if (buffered) {
// #9168: deltas were buffered — normalize against the original client schema
// and emit the cleaned arguments once, stripping optional null values that
// would otherwise reach the client raw.
const argsToEmit = stripEmptyOptionalToolArgs(buffered, toolName, toolSchema);
const argsStr = typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit);
if (argsStr) {
return {

View File

@@ -134,7 +134,7 @@ export function LlmChatCard({
}: Props) {
const t = useTranslations("miniPlayground");
const { keys } = useApiKey();
const { models } = useProviderModels(providerId);
const { models, loading, error, retry } = useProviderModels(providerId);
const [internalSelectedKey, setInternalSelectedKey] = useState<string>("");
const [internalModel, setInternalModel] = useState<string>(initialModel ?? "");
@@ -392,15 +392,31 @@ export function LlmChatCard({
<select
value={model || firstModel}
onChange={(e) => setModel(e.target.value)}
className="min-w-0 flex-1 rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
disabled={loading}
className="min-w-0 flex-1 rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-60"
>
{modelOptions.length === 0 && <option value="">{initialModel || "—"}</option>}
{modelOptions.length === 0 && !loading && <option value="">{initialModel || "—"}</option>}
{loading && <option value="">{t("loading") ?? "Loading…"}</option>}
{modelOptions.map((m) => (
<option key={m.id} value={m.id}>
{m.id}
</option>
))}
</select>
{error && (
<span className="text-xs text-red-500 flex items-center gap-1" role="alert">
<span className="truncate max-w-[180px]" title={String(error)}>
{String(error)}
</span>
<button
type="button"
onClick={retry}
className="shrink-0 text-xs text-primary hover:text-primary-strong underline"
>
{t("retry") ?? "Retry"}
</button>
</span>
)}
</div>
{/* Key select */}
{keys.length > 0 && (

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
export interface ProviderModel {
id: string;
@@ -18,6 +18,8 @@ interface UseProviderModelsResult {
models: ProviderModel[];
loading: boolean;
error: string | null;
/** Re-runs the model fetch for the current provider. Useful for a Retry action. */
retry: () => void;
}
/**
@@ -32,15 +34,14 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
const [models, setModels] = useState<ProviderModel[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Cancels any in-flight load (component unmount or a retry superseding the
// previous request) so a stale response never overwrites a newer one.
const cleanupRef = useRef<(() => void) | null>(null);
useEffect(() => {
if (!providerId) {
setLoading(false);
return;
}
const load = useCallback(() => {
cleanupRef.current?.();
let cancelled = false;
const load = async () => {
const run = async () => {
setLoading(true);
setError(null);
try {
@@ -109,11 +110,33 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
if (!cancelled) setLoading(false);
}
};
void load();
return () => {
void run();
const cleanup = () => {
cancelled = true;
};
cleanupRef.current = cleanup;
return cleanup;
}, [providerId]);
return { models, loading, error };
useEffect(() => {
if (!providerId) {
setLoading(false);
return;
}
return load();
}, [providerId, load]);
// Release the current in-flight cleanup on unmount so no state updates leak.
useEffect(() => {
return () => {
cleanupRef.current?.();
};
}, []);
const retry = useCallback(() => {
if (!providerId) return;
load();
}, [providerId, load]);
return { models, loading, error, retry };
}

View File

@@ -73,9 +73,7 @@ export async function PATCH(request: Request, { params }: RouteParams): Promise<
// helpers. Without the pre-update removal, a group/provider switch would leave
// orphan qtSd/ combos a quota key still sees. Guarded + non-fatal.
const combosNeedResync =
body !== null &&
typeof body === "object" &&
("connectionIds" in body || "groupId" in body);
body !== null && typeof body === "object" && ("connectionIds" in body || "groupId" in body);
if (combosNeedResync) {
try {
const { removeQuotaCombosForPool } = await import("@/lib/quota/quotaCombos");
@@ -106,7 +104,7 @@ export async function PATCH(request: Request, { params }: RouteParams): Promise<
id,
prevApiKeyIds,
nextApiKeyIds,
parsed.data.exclusive ?? false,
parsed.data.exclusive ?? false
);
}
@@ -132,7 +130,7 @@ export async function DELETE(request: Request, { params }: RouteParams): Promise
try {
const { id } = await params;
const existed = deletePool(id);
const existed = await deletePool(id);
if (!existed) {
return NextResponse.json(buildErrorBody(404, "Pool not found"), { status: 404 });
}

View File

@@ -11,8 +11,32 @@
import { getDbInstance } from "./core";
// Phase B2: auto-mint/prune quotaShared-* combos when pool allocations change.
// Imported lazily (dynamic import in the hook) to avoid circular-dependency
// risk between db/ and quota/ modules. The import is fire-and-forget; combo
// failures never break pool CRUD.
// risk between db/ and quota/ modules. Sync hooks are fire-and-forget; deletion
// awaits its guarded cleanup while metadata is available. Combo failures never
// break pool CRUD.
const quotaComboMaintenance = new Map<string, Promise<unknown>>();
const deletingPools = new Set<string>();
/** Reset module-level state for test isolation. Call in test.after() hooks. */
export function resetQuotaPoolsModuleState(): void {
deletingPools.clear();
quotaComboMaintenance.clear();
}
function serializeQuotaComboMaintenance<T>(
poolId: string,
operation: () => Promise<T>
): Promise<T> {
const previous = quotaComboMaintenance.get(poolId);
const current = previous ? previous.catch(() => undefined).then(operation) : operation();
quotaComboMaintenance.set(poolId, current);
const cleanup = () => {
if (quotaComboMaintenance.get(poolId) === current) quotaComboMaintenance.delete(poolId);
};
void current.then(cleanup, cleanup);
return current;
}
async function syncQuotaCombosGuarded(poolId: string): Promise<void> {
try {
const { syncQuotaCombos } = await import("@/lib/quota/quotaCombos");
@@ -400,7 +424,7 @@ export function createPool(input: PoolCreate): QuotaPool {
);
// Phase B2: fire-and-forget combo sync; failures are logged but never thrown.
void syncQuotaCombosGuarded(id);
void serializeQuotaComboMaintenance(id, () => syncQuotaCombosGuarded(id));
return result;
}
@@ -412,6 +436,8 @@ export function createPool(input: PoolCreate): QuotaPool {
* connection_id (primary) is synced to connectionIds[0].
*/
export function updatePool(id: string, input: PoolUpdate): QuotaPool | null {
if (deletingPools.has(id)) return null;
const database = getDb();
const existing = database
.prepare<PoolRow>(
@@ -475,7 +501,7 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null {
const result = rowToPool(existing, getAllocations(id));
// Phase B2: fire-and-forget combo sync; failures are logged but never thrown.
void syncQuotaCombosGuarded(id);
void serializeQuotaComboMaintenance(id, () => syncQuotaCombosGuarded(id));
return result;
}
@@ -485,28 +511,38 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null {
* Also removes join rows in quota_pool_connections.
* Returns true if a row was deleted, false if not found.
*/
export function deletePool(id: string): boolean {
// Phase B2: remove quota combos BEFORE deleting the pool row so that
// removeQuotaCombosForPool can still resolve the pool name → slug.
void removeQuotaCombosGuarded(id);
export async function deletePool(id: string): Promise<boolean> {
if (deletingPools.has(id)) return false;
const exists = getDb().prepare<{ id: string }>("SELECT id FROM quota_pools WHERE id = ?").get(id);
if (!exists) return false;
deletingPools.add(id);
const database = getDb();
const doDelete = database.transaction(() => {
database.prepare("DELETE FROM quota_pool_connections WHERE pool_id = ?").run(id);
// Prune this pool id from every key's allowed_quotas JSON array.
database
.prepare(
`UPDATE api_keys SET allowed_quotas = COALESCE(
const deletion = serializeQuotaComboMaintenance(id, async () => {
// Phase B2: remove quota combos BEFORE deleting the pool row so that
// removeQuotaCombosForPool can still resolve the pool name → slug.
await removeQuotaCombosGuarded(id);
const database = getDb();
const doDelete = database.transaction(() => {
database.prepare("DELETE FROM quota_pool_connections WHERE pool_id = ?").run(id);
// Prune this pool id from every key's allowed_quotas JSON array.
database
.prepare(
`UPDATE api_keys SET allowed_quotas = COALESCE(
(SELECT json_group_array(value) FROM json_each(api_keys.allowed_quotas) WHERE value != ?),
'[]')
WHERE allowed_quotas IS NOT NULL AND allowed_quotas != '[]'
AND EXISTS (SELECT 1 FROM json_each(api_keys.allowed_quotas) WHERE value = ?)`
)
.run(id, id);
return database.prepare("DELETE FROM quota_pools WHERE id = ?").run(id);
)
.run(id, id);
return database.prepare("DELETE FROM quota_pools WHERE id = ?").run(id);
});
const result = doDelete();
return result.changes > 0;
});
const result = doDelete();
return result.changes > 0;
const clearDeleting = () => deletingPools.delete(id);
void deletion.then(clearDeleting, clearDeleting);
return deletion;
}
/**
@@ -546,6 +582,8 @@ export function deletePool(id: string): boolean {
* Runs atomically: all pool writes are inside a single SQLite transaction.
*/
export function upsertAllocations(poolId: string, allocations: PoolAllocation[]): void {
if (deletingPools.has(poolId)) return;
const database = getDb();
// Normalize: when all weights are 0, distribute equally so the pool is usable
@@ -602,7 +640,7 @@ export function upsertAllocations(poolId: string, allocations: PoolAllocation[])
// Phase B2: fire-and-forget combo sync for the target pool only; failures are
// logged but never thrown. Sibling pools' combos are synced on their own lifecycle.
void syncQuotaCombosGuarded(poolId);
void serializeQuotaComboMaintenance(poolId, () => syncQuotaCombosGuarded(poolId));
}
/**

View File

@@ -112,7 +112,8 @@ function parseEffortList(rawList: unknown): string[] | undefined {
.map((entry) => {
const entryParsed = effortEntrySchema.safeParse(entry);
if (!entryParsed.success) return null;
const raw = typeof entryParsed.data === "string" ? entryParsed.data : entryParsed.data.effort;
const raw =
typeof entryParsed.data === "string" ? entryParsed.data : entryParsed.data.effort;
return raw.length > 0 ? normalizeSupportedEffort(raw) : null;
})
.filter((effort): effort is string => effort !== null)
@@ -144,6 +145,16 @@ export function detectSupportedThinkingEfforts(record: JsonRecord): string[] | u
}
}
// #9160: fall back to `capabilities.effort_tiers` before the legacy fields.
// OmniRoute's own catalog surfaces effort tiers inside `capabilities.effort_tiers`,
// which the existing `parseEffortList` already handles (string arrays).
const capabilitiesRecord = asRecord(record.capabilities);
const capabilitiesParsed = effortListSchema.safeParse(capabilitiesRecord.effort_tiers);
if (capabilitiesParsed.success) {
const fromCapabilities = parseEffortList(capabilitiesRecord.effort_tiers);
if (fromCapabilities) return fromCapabilities;
}
// #8347: fall back to `supported_reasoning_levels`, then `thinking.levels` — in that
// order, per the regression guard for #7694 (the flat field and `reasoning.supported_efforts`
// both take precedence over these two and are handled above / by the caller).

View File

@@ -152,7 +152,10 @@ export async function syncQuotaCombos(poolId: string): Promise<void> {
for (const connId of pool.connectionIds) {
let connection: Record<string, unknown> | null = null;
try {
connection = (await getCachedProviderConnectionById(connId)) as Record<string, unknown> | null;
connection = (await getCachedProviderConnectionById(connId)) as Record<
string,
unknown
> | null;
} catch {
// Connection lookup failure — skip this connection.
continue;
@@ -202,6 +205,10 @@ export async function syncQuotaCombos(poolId: string): Promise<void> {
}));
try {
const existing = await getComboByName(comboName);
// A pool may be deleted while this fire-and-forget sync is awaiting combo
// lookups. Re-check immediately before the synchronous DB upsert so stale
// create/update work cannot recreate managed combos after delete cleanup.
if (!getPool(poolId)) return;
const payload = {
name: comboName,
models: steps,

View File

@@ -0,0 +1,427 @@
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 { makeManagementSessionRequest } from "../helpers/managementSession.ts";
const TEST_DATA_DIR = fs.mkdtempSync(
path.join(os.tmpdir(), "omniroute-quota-pool-delete-combo-cleanup-")
);
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-quota-pool-delete-combo-cleanup-secret";
const core = await import("../../src/lib/db/core.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const combosDb = await import("../../src/lib/db/combos.ts");
const groupsDb = await import("../../src/lib/db/quotaGroups.ts");
const poolsDb = await import("../../src/lib/db/quotaPools.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const compliance = await import("../../src/lib/compliance/index.ts");
const poolIdRoute = await import("../../src/app/api/quota/pools/[id]/route.ts");
const { removeQuotaCombosForPool, syncQuotaCombos } =
await import("../../src/lib/quota/quotaCombos.ts");
const { parseQuotaModelName, quotaGroupSlug } =
await import("../../src/lib/quota/quotaModelNaming.ts");
type Combo = Awaited<ReturnType<typeof combosDb.getCombos>>[number];
type Db = {
prepare: (sql: string) => {
all: (...params: unknown[]) => unknown[];
get: (...params: unknown[]) => unknown;
};
};
function resetDb() {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
function quotaNamesFor(combos: Combo[], groupName: string, provider: string): string[] {
const groupSlug = quotaGroupSlug(groupName);
return combos
.map((combo) => (typeof combo.name === "string" ? combo.name : ""))
.filter((name) => {
const parsed = parseQuotaModelName(name);
return parsed?.groupSlug === groupSlug && parsed.provider === provider;
})
.sort();
}
async function createConnection(provider: "openrouter" | "baidu", name: string) {
const connection = await providersDb.createProviderConnection({
provider,
authType: "apikey",
name,
apiKey: `test-only-${name}`,
});
const id = (connection as Record<string, unknown>).id;
assert.equal(typeof id, "string", `${provider} connection should have an id`);
return id as string;
}
async function deletePoolThroughRoute(poolId: string): Promise<Response> {
const request = await makeManagementSessionRequest(`http://localhost/api/quota/pools/${poolId}`, {
method: "DELETE",
});
return poolIdRoute.DELETE(request, { params: Promise.resolve({ id: poolId }) });
}
function getAllowedQuotas(apiKeyId: string): string[] {
const db = core.getDbInstance() as unknown as Db;
const row = db.prepare("SELECT allowed_quotas FROM api_keys WHERE id = ?").get(apiKeyId) as {
allowed_quotas: string;
};
return JSON.parse(row.allowed_quotas) as string[];
}
function countRows(sql: string, id: string): number {
const db = core.getDbInstance() as unknown as Db;
const row = db.prepare(sql).get(id) as { count: number };
return row.count;
}
function nextImmediate(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}
test.beforeEach(() => {
resetDb();
compliance.initAuditLog();
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("DELETE pool waits for scoped quota-combo cleanup before returning 204", async () => {
const targetGroup = groupsDb.createGroup("Delete Target Group");
const otherGroup = groupsDb.createGroup("Delete Other Group");
const targetConnectionId = await createConnection("openrouter", "delete-target-openrouter");
const sameGroupConnectionId = await createConnection("baidu", "delete-control-baidu");
const otherGroupConnectionId = await createConnection("openrouter", "delete-control-openrouter");
const apiKey = await apiKeysDb.createApiKey("Delete Pool Key", "delete-pool-machine");
const targetPool = poolsDb.createPool({
connectionId: targetConnectionId,
name: "Delete Target Pool",
groupId: targetGroup.id,
allocations: [{ apiKeyId: apiKey.id, weight: 100, policy: "hard" }],
});
const sameGroupPool = poolsDb.createPool({
connectionId: sameGroupConnectionId,
name: "Same Group Different Provider",
groupId: targetGroup.id,
});
const otherGroupPool = poolsDb.createPool({
connectionId: otherGroupConnectionId,
name: "Different Group Same Provider",
groupId: otherGroup.id,
});
await apiKeysDb.updateApiKeyPermissions(apiKey.id, {
allowedQuotas: [targetPool.id, otherGroupPool.id],
});
await syncQuotaCombos(targetPool.id);
await syncQuotaCombos(sameGroupPool.id);
await syncQuotaCombos(otherGroupPool.id);
await nextImmediate();
await nextImmediate();
const ordinaryCombo = await combosDb.createCombo({
name: "ordinary-delete-control",
models: [{ kind: "model", model: "openrouter/control-model", weight: 100 }],
strategy: "priority",
});
const before = await combosDb.getCombos();
const targetNames = quotaNamesFor(before, targetGroup.name, "openrouter");
const sameGroupControlNames = quotaNamesFor(before, targetGroup.name, "baidu");
const otherGroupControlNames = quotaNamesFor(before, otherGroup.name, "openrouter");
assert.ok(targetNames.length > 0, "target openrouter quota combos must exist before DELETE");
assert.ok(sameGroupControlNames.length > 0, "same-group baidu control combos must exist");
assert.ok(otherGroupControlNames.length > 0, "other-group openrouter control combos must exist");
assert.ok(
await combosDb.getComboByName(ordinaryCombo.name as string),
"ordinary combo must exist"
);
assert.ok(poolsDb.getPool(targetPool.id), "target pool row must exist before DELETE");
assert.equal(
countRows(
"SELECT count(*) AS count FROM quota_pool_connections WHERE pool_id = ?",
targetPool.id
),
1
);
assert.equal(
countRows("SELECT count(*) AS count FROM quota_allocations WHERE pool_id = ?", targetPool.id),
1
);
assert.deepEqual(getAllowedQuotas(apiKey.id), [targetPool.id, otherGroupPool.id]);
const response = await deletePoolThroughRoute(targetPool.id);
assert.equal(response.status, 204);
const after = await combosDb.getCombos();
assert.deepEqual(
quotaNamesFor(after, targetGroup.name, "openrouter"),
[],
"DELETE must not return while target group+provider quota combos remain"
);
assert.deepEqual(
quotaNamesFor(after, targetGroup.name, "baidu"),
sameGroupControlNames,
"same-group combos for another provider must remain byte/name-identical"
);
assert.deepEqual(
quotaNamesFor(after, otherGroup.name, "openrouter"),
otherGroupControlNames,
"same-provider combos for another group must remain byte/name-identical"
);
assert.deepEqual(
await combosDb.getComboByName(ordinaryCombo.name as string),
ordinaryCombo,
"ordinary user combo must remain unchanged"
);
assert.equal(poolsDb.getPool(targetPool.id), null);
assert.equal(
countRows(
"SELECT count(*) AS count FROM quota_pool_connections WHERE pool_id = ?",
targetPool.id
),
0
);
assert.equal(
countRows("SELECT count(*) AS count FROM quota_allocations WHERE pool_id = ?", targetPool.id),
0
);
assert.deepEqual(getAllowedQuotas(apiKey.id), [otherGroupPool.id]);
const auditEvents = compliance.getAuditLog({ action: "quota.pool.deleted", limit: 10 });
assert.ok(
auditEvents.some(
(event) =>
typeof event === "object" &&
event !== null &&
(event as Record<string, unknown>).target === targetPool.id
),
"successful DELETE must record quota.pool.deleted audit event"
);
});
test("DELETE prevents an in-flight create sync from recreating quota combos", async () => {
const group = groupsDb.createGroup("Immediate Create Delete Group");
const connectionId = await createConnection("openrouter", "immediate-create-delete");
const pool = poolsDb.createPool({
connectionId,
name: "Immediate Create Delete Pool",
groupId: group.id,
});
const deleted = await poolsDb.deletePool(pool.id);
await nextImmediate();
await nextImmediate();
assert.equal(deleted, true);
assert.equal(poolsDb.getPool(pool.id), null);
assert.deepEqual(
quotaNamesFor(await combosDb.getCombos(), group.name, "openrouter"),
[],
"a create sync already in flight must not mint quota combos after pool deletion"
);
});
test("DELETE prevents an in-flight update sync from recreating quota combos", async () => {
const group = groupsDb.createGroup("Immediate Update Delete Group");
const connectionId = await createConnection("openrouter", "immediate-update-delete");
const pool = poolsDb.createPool({
connectionId,
name: "Immediate Update Delete Pool",
groupId: group.id,
});
await syncQuotaCombos(pool.id);
await nextImmediate();
await nextImmediate();
await removeQuotaCombosForPool(pool.id);
assert.deepEqual(quotaNamesFor(await combosDb.getCombos(), group.name, "openrouter"), []);
assert.ok(poolsDb.updatePool(pool.id, { name: "Updated Then Deleted Pool" }));
const deleted = await poolsDb.deletePool(pool.id);
await nextImmediate();
await nextImmediate();
assert.equal(deleted, true);
assert.equal(poolsDb.getPool(pool.id), null);
assert.deepEqual(
quotaNamesFor(await combosDb.getCombos(), group.name, "openrouter"),
[],
"an update sync already in flight must not recreate quota combos after pool deletion"
);
});
test("DELETE rejects a synchronous pool update once deletion has started", async () => {
const oldGroup = groupsDb.createGroup("Deleting Pool Old Group");
const newGroup = groupsDb.createGroup("Deleting Pool New Group");
const connectionId = await createConnection("openrouter", "delete-update-race");
const pool = poolsDb.createPool({
connectionId,
name: "Delete Update Race Pool",
groupId: oldGroup.id,
});
await syncQuotaCombos(pool.id);
await nextImmediate();
await nextImmediate();
assert.ok(quotaNamesFor(await combosDb.getCombos(), oldGroup.name, "openrouter").length > 0);
const deleting = poolsDb.deletePool(pool.id);
const updated = poolsDb.updatePool(pool.id, { groupId: newGroup.id });
const deleted = await deleting;
await nextImmediate();
await nextImmediate();
assert.equal(updated, null, "a pool must become immutable as soon as deletion starts");
assert.equal(deleted, true);
assert.equal(poolsDb.getPool(pool.id), null);
assert.deepEqual(quotaNamesFor(await combosDb.getCombos(), oldGroup.name, "openrouter"), []);
assert.deepEqual(quotaNamesFor(await combosDb.getCombos(), newGroup.name, "openrouter"), []);
});
test("DELETE makes a synchronous allocation upsert a no-op once deletion has started", async () => {
const group = groupsDb.createGroup("Deleting Pool Allocation Group");
const targetConnectionId = await createConnection("openrouter", "delete-allocation-target");
const siblingConnectionId = await createConnection("baidu", "delete-allocation-sibling");
const targetPool = poolsDb.createPool({
connectionId: targetConnectionId,
name: "Delete Allocation Target",
groupId: group.id,
});
const siblingPool = poolsDb.createPool({
connectionId: siblingConnectionId,
name: "Delete Allocation Sibling",
groupId: group.id,
});
const apiKey = await apiKeysDb.createApiKey("Delete Allocation Key", "delete-allocation-key");
await nextImmediate();
await nextImmediate();
const deleting = poolsDb.deletePool(targetPool.id);
poolsDb.upsertAllocations(targetPool.id, [{ apiKeyId: apiKey.id, weight: 100, policy: "hard" }]);
const deleted = await deleting;
assert.equal(deleted, true);
assert.equal(poolsDb.getPool(targetPool.id), null);
assert.deepEqual(
poolsDb.getPool(siblingPool.id)?.allocations,
[],
"an allocation upsert on a deleting pool must not mutate sibling pools"
);
});
test("concurrent DELETE calls report one deletion and one missing pool", async () => {
const group = groupsDb.createGroup("Concurrent Delete Group");
const connectionId = await createConnection("openrouter", "concurrent-delete");
const pool = poolsDb.createPool({
connectionId,
name: "Concurrent Delete Pool",
groupId: group.id,
});
const results = await Promise.all([poolsDb.deletePool(pool.id), poolsDb.deletePool(pool.id)]);
assert.deepEqual(results, [true, false]);
assert.equal(poolsDb.getPool(pool.id), null);
assert.deepEqual(quotaNamesFor(await combosDb.getCombos(), group.name, "openrouter"), []);
});
test("DELETE nonexistent pool returns sanitized 404 without changing combos", async () => {
const missingPoolId = "pool-that-never-existed";
const group = groupsDb.createGroup("Missing Pool Control Group");
const connectionId = await createConnection("baidu", "missing-pool-control-baidu");
const pool = poolsDb.createPool({
connectionId,
name: "Missing Pool Control",
groupId: group.id,
});
const apiKey = await apiKeysDb.createApiKey("Missing Pool Key", "missing-pool-key");
await apiKeysDb.updateApiKeyPermissions(apiKey.id, {
allowedQuotas: [missingPoolId, pool.id],
});
await syncQuotaCombos(pool.id);
await nextImmediate();
await nextImmediate();
await combosDb.createCombo({
name: "ordinary-missing-delete-control",
models: [{ kind: "model", model: "baidu/control-model", weight: 100 }],
strategy: "priority",
});
const before = await combosDb.getCombos();
assert.ok(quotaNamesFor(before, group.name, "baidu").length > 0);
const response = await deletePoolThroughRoute(missingPoolId);
const body = await response.json();
assert.equal(response.status, 404);
assert.equal(body.error?.message, "Pool not found");
assert.doesNotMatch(JSON.stringify(body), /\s+at\s+\//, "404 must not expose a stack trace");
assert.deepEqual(await combosDb.getCombos(), before);
assert.deepEqual(
getAllowedQuotas(apiKey.id),
[missingPoolId, pool.id],
"a missing-pool DELETE must not mutate API key permissions"
);
assert.ok(poolsDb.getPool(pool.id), "unrelated pool must remain");
});
test("DELETE keeps relational cleanup non-fatal when quota-combo listing fails", async () => {
const group = groupsDb.createGroup("Cleanup Failure Group");
const connectionId = await createConnection("openrouter", "cleanup-failure-openrouter");
const apiKey = await apiKeysDb.createApiKey("Cleanup Failure Key", "cleanup-failure-machine");
const pool = poolsDb.createPool({
connectionId,
name: "Cleanup Failure Pool",
groupId: group.id,
allocations: [{ apiKeyId: apiKey.id, weight: 100, policy: "hard" }],
});
await apiKeysDb.updateApiKeyPermissions(apiKey.id, { allowedQuotas: [pool.id] });
await syncQuotaCombos(pool.id);
await nextImmediate();
await nextImmediate();
assert.ok(quotaNamesFor(await combosDb.getCombos(), group.name, "openrouter").length > 0);
const db = core.getDbInstance();
const originalPrepare = db.prepare.bind(db);
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
process.on("unhandledRejection", onUnhandled);
db.prepare = ((sql: string) => {
if (sql.startsWith("SELECT data, sort_order, context_cache_protection FROM combos ORDER BY")) {
throw new Error("forced quota combo listing failure");
}
return originalPrepare(sql);
}) as typeof db.prepare;
let response: Response;
try {
response = await deletePoolThroughRoute(pool.id);
await nextImmediate();
await nextImmediate();
} finally {
db.prepare = originalPrepare as typeof db.prepare;
process.off("unhandledRejection", onUnhandled);
}
assert.equal(response!.status, 204);
assert.deepEqual(unhandled, [], "guarded combo failure must not produce unhandledRejection");
assert.equal(poolsDb.getPool(pool.id), null);
assert.equal(
countRows("SELECT count(*) AS count FROM quota_pool_connections WHERE pool_id = ?", pool.id),
0
);
assert.equal(
countRows("SELECT count(*) AS count FROM quota_allocations WHERE pool_id = ?", pool.id),
0
);
assert.deepEqual(getAllowedQuotas(apiKey.id), []);
});

View File

@@ -137,15 +137,15 @@ test("updatePool returns null for unknown id", () => {
assert.equal(result, null);
});
test("deletePool removes pool and returns true", () => {
test("deletePool removes pool and returns true", async () => {
const pool = poolsDb.createPool({ connectionId: "c6", name: "Deletable" });
const deleted = poolsDb.deletePool(pool.id);
const deleted = await poolsDb.deletePool(pool.id);
assert.equal(deleted, true);
assert.equal(poolsDb.getPool(pool.id), null);
});
test("deletePool returns false for unknown id", () => {
const result = poolsDb.deletePool("ghost-pool");
test("deletePool returns false for unknown id", async () => {
const result = await poolsDb.deletePool("ghost-pool");
assert.equal(result, false);
});
@@ -190,14 +190,14 @@ test("upsertAllocations with empty array removes all allocations", () => {
// FK CASCADE: delete pool → allocations gone
// ---------------------------------------------------------------------------
test("deletePool cascades to allocations", () => {
test("deletePool cascades to allocations", async () => {
const pool = poolsDb.createPool({
connectionId: "c9",
name: "With Allocs",
allocations: [{ apiKeyId: "k-cascade", weight: 100, policy: "hard" }],
});
poolsDb.deletePool(pool.id);
await poolsDb.deletePool(pool.id);
// After pool is deleted, listAllocationsForApiKey should find nothing for k-cascade
const remaining = poolsDb.listAllocationsForApiKey("k-cascade");

View File

@@ -0,0 +1,258 @@
// Capability enforcement for the Gemini Web executor (#9356).
//
// Reported: gemini-web silently ACCEPTS `reasoning_effort` and
// `tool_choice: "required"` and answers with ordinary prose — HTTP 200, no
// `reasoning_content`, `tool_calls: []`, `finish_reason: "stop"`. An
// AgentChakra/OpenClaw agent then believes its reasoning and tool requirements
// were honored when they were not.
//
// Why neither can be implemented for THIS provider: gemini-web is not an API
// client. It launches Playwright, types a single flat prompt string into the
// gemini.google.com `.ql-editor` contenteditable, presses Enter, and captures
// the first `StreamGenerate` response off the page. There is no request payload
// to carry a thinking budget, and no function-calling channel to force — the
// tools support it does have is the prompt-emulation shim (`webTools.ts`, #7286),
// which ASKS the model to emit `<tool>{...}</tool>` and cannot GUARANTEE it.
//
// So this suite pins the issue's option (b) for both controls: reject the
// requests we cannot honor, and keep honoring the ones we can. The line drawn:
//
// reasoning_effort none | minimal → allowed (gemini-web not thinking
// IS compliance with "spend little")
// low | medium | high… → 400, a positive request to think
// tool_choice absent | auto | none → allowed (emulation path, #7286)
// required | any | {fn} → 400, a guarantee we cannot make
//
// The guard must run BEFORE Playwright launches, so every executor assertion
// here completes without a browser.
import test from "node:test";
import assert from "node:assert/strict";
const { GeminiWebExecutor } = await import("../../open-sse/executors/gemini-web.ts");
const { checkGeminiWebUnsupportedControls, GEMINI_WEB_UNSUPPORTED_CONTROL_CODE } =
await import("../../open-sse/executors/gemini-web/capabilities.ts");
const { gemini_webProvider } =
await import("../../open-sse/config/providers/registry/gemini/web/index.ts");
const { supportsReasoning, supportsToolCalling } =
await import("../../src/lib/modelCapabilities.ts");
const { providerSupportsEmulatedToolCalling } =
await import("../../open-sse/services/combo/comboStructure.ts");
const GET_WEATHER_TOOL = {
type: "function",
function: {
name: "get_weather",
description: "Get the current weather for a city",
parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
},
};
interface ErrorBodyLike {
error: { message: string; type: string; code: string };
}
/**
* Run the executor with valid-looking credentials. Every case in this suite is
* expected to short-circuit on the capability guard, so Playwright is never
* reached — a test that hangs here means the guard did not fire.
*/
async function run(body: Record<string, unknown>) {
return new GeminiWebExecutor().execute({
model: "gemini-3.6-flash",
body: { messages: [{ role: "user", content: "hi" }], stream: false, ...body },
stream: false,
credentials: { apiKey: "__Secure-1PSID=test-cookie" },
signal: AbortSignal.timeout(10_000),
log: null,
});
}
// ─── Pure checker: reasoning_effort ─────────────────────────────────────────
test("#9356 reasoning_effort low/medium/high/xhigh are rejected as unsupported", () => {
for (const effort of ["low", "medium", "high", "xhigh"]) {
const violation = checkGeminiWebUnsupportedControls({ reasoning_effort: effort });
assert.equal(
violation?.param,
"reasoning_effort",
`reasoning_effort="${effort}" asks gemini-web to think harder, which a typed browser ` +
`prompt cannot express — it must be rejected, not silently dropped`
);
assert.match(violation!.message, /reasoning_effort/);
}
});
test("#9356 reasoning_effort none/minimal and absent stay allowed", () => {
assert.equal(checkGeminiWebUnsupportedControls({}), null);
assert.equal(checkGeminiWebUnsupportedControls({ reasoning_effort: null }), null);
assert.equal(checkGeminiWebUnsupportedControls({ reasoning_effort: "none" }), null);
assert.equal(
checkGeminiWebUnsupportedControls({ reasoning_effort: "minimal" }),
null,
'"minimal" means spend as little reasoning as possible — a non-thinking provider ' +
"already satisfies it, so rejecting it would be gratuitous"
);
assert.equal(checkGeminiWebUnsupportedControls({ reasoning_effort: " NONE " }), null);
});
// ─── Pure checker: tool_choice ──────────────────────────────────────────────
test("#9356 tool_choice required/any is rejected as unsupported", () => {
for (const choice of ["required", "any"]) {
const violation = checkGeminiWebUnsupportedControls({
tools: [GET_WEATHER_TOOL],
tool_choice: choice,
});
assert.equal(
violation?.param,
"tool_choice",
`tool_choice="${choice}" is a guarantee the prompt-emulation shim cannot make`
);
assert.match(violation!.message, /tool_choice/);
}
});
test("#9356 a forced-function tool_choice object is rejected as unsupported", () => {
const violation = checkGeminiWebUnsupportedControls({
tools: [GET_WEATHER_TOOL],
tool_choice: { type: "function", function: { name: "get_weather" } },
});
assert.equal(violation?.param, "tool_choice");
// Anthropic-style forcing, which the translators also emit.
assert.equal(
checkGeminiWebUnsupportedControls({
tools: [GET_WEATHER_TOOL],
tool_choice: { type: "any" },
})?.param,
"tool_choice"
);
});
test("#9356 tool_choice auto/none and absent keep the #7286 emulation path open", () => {
assert.equal(checkGeminiWebUnsupportedControls({ tools: [GET_WEATHER_TOOL] }), null);
assert.equal(
checkGeminiWebUnsupportedControls({ tools: [GET_WEATHER_TOOL], tool_choice: "auto" }),
null
);
assert.equal(
checkGeminiWebUnsupportedControls({ tools: [GET_WEATHER_TOOL], tool_choice: "none" }),
null
);
});
test("#9356 forcing is rejected on its own terms, even with no tools[] array", () => {
// An agent that sets tool_choice without tools is already malformed, but the
// point stands: never report success for a forcing contract we ignore.
assert.equal(
checkGeminiWebUnsupportedControls({ tool_choice: "required" })?.param,
"tool_choice"
);
});
// ─── Executor wiring ────────────────────────────────────────────────────────
test("#9356 executor returns 400 for reasoning_effort=high before launching a browser", async () => {
const result = await run({ reasoning_effort: "high" });
assert.equal(result.response.status, 400);
const body = (await result.response.json()) as ErrorBodyLike;
assert.equal(body.error.code, GEMINI_WEB_UNSUPPORTED_CONTROL_CODE);
assert.match(body.error.message, /reasoning_effort/);
assert.equal(
body.error.message.includes("at /"),
false,
"error bodies must stay sanitized — no stack traces"
);
});
test("#9356 executor returns 400 for tool_choice=required before launching a browser", async () => {
const result = await run({ tools: [GET_WEATHER_TOOL], tool_choice: "required" });
assert.equal(result.response.status, 400);
const body = (await result.response.json()) as ErrorBodyLike;
assert.equal(body.error.code, GEMINI_WEB_UNSUPPORTED_CONTROL_CODE);
assert.match(body.error.message, /tool_choice/);
});
test("#9356 the capability guard runs ahead of the credential check", async () => {
// A request that is BOTH uncredentialed and incompatible must report the
// incompatibility: adding a cookie would not make it work.
const result = await new GeminiWebExecutor().execute({
model: "gemini-3.6-flash",
body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" },
stream: false,
credentials: {},
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(result.response.status, 400);
const body = (await result.response.json()) as ErrorBodyLike;
assert.equal(body.error.code, GEMINI_WEB_UNSUPPORTED_CONTROL_CODE);
});
test("#9356 a supported request still falls through the guard untouched", async () => {
// tool_choice:"auto" + tools[] is the #7286 emulation contract. It must NOT
// be blocked — reaching the (missing) credential check proves the guard let
// it pass, without needing a browser to prove it.
const result = await new GeminiWebExecutor().execute({
model: "gemini-3.6-flash",
body: {
messages: [{ role: "user", content: "hi" }],
tools: [GET_WEATHER_TOOL],
tool_choice: "auto",
},
stream: false,
credentials: {},
signal: AbortSignal.timeout(10_000),
log: null,
});
assert.equal(result.response.status, 401, "should reach the cookie check, not the guard");
});
// ─── Catalog metadata ───────────────────────────────────────────────────────
test("#9356 registry advertises no native tool calling and no reasoning for gemini-web", () => {
assert.ok(gemini_webProvider.models.length > 0);
for (const model of gemini_webProvider.models) {
assert.equal(
model.toolCalling,
false,
`${model.id} must not advertise native tool calling — /v1/models feeds agent routers`
);
assert.equal(
model.supportsReasoning,
false,
`${model.id} must advertise reasoning:false so agent routers stop selecting it for ` +
"reasoning work (the executor has no thinking control to drive)"
);
}
});
test("#9356 resolved capabilities — not just the raw registry — report no reasoning/tools", () => {
// The registry literal is only the input; `getResolvedModelCapabilities` is what
// the catalog, the combo compatibility filter and the thinking-budget translator
// actually read. Assert the resolved view so a downstream default cannot quietly
// re-advertise a capability the executor does not have.
for (const model of gemini_webProvider.models) {
const input = { provider: "gemini-web", model: model.id };
assert.equal(supportsReasoning(input), false, `${model.id} resolved reasoning must be false`);
assert.equal(
supportsToolCalling(input),
false,
`${model.id} resolved NATIVE tool calling must be false — prompt emulation is advertised ` +
'separately as toolCalling:"emulated" on the provider constant'
);
}
});
test("#9356 the provider still advertises emulated tool calling, so #7286 combos keep routing", () => {
// Guard against over-correcting: dropping the emulation advertisement here would
// make filterTargetsByRequestCompatibility fail these targets closed and break
// emulation-only combos (#5240 / #8488).
assert.equal(providerSupportsEmulatedToolCalling("gemini-web"), true);
assert.equal(providerSupportsEmulatedToolCalling("gweb"), true);
});

View File

@@ -57,9 +57,7 @@ test.after(async () => {
// ── D1.1: Migration file ────────────────────────────────────────────────────
test("migration 086 file exists and contains quota_pool_connections DDL", () => {
const migrationPath = path.resolve(
"src/lib/db/migrations/087_quota_pool_connections.sql"
);
const migrationPath = path.resolve("src/lib/db/migrations/087_quota_pool_connections.sql");
assert.ok(fs.existsSync(migrationPath), `migration file not found: ${migrationPath}`);
const sql = fs.readFileSync(migrationPath, "utf8");
@@ -153,14 +151,14 @@ test("updatePool without connectionIds leaves join rows untouched", () => {
// ── D1.4: deletePool removes join rows ────────────────────────────────────
test("deletePool removes quota_pool_connections rows", () => {
test("deletePool removes quota_pool_connections rows", async () => {
const pool = poolsDb.createPool({
connectionId: "del-a",
name: "To Delete",
connectionIds: ["del-a", "del-b"],
});
const deleted = poolsDb.deletePool(pool.id);
const deleted = await poolsDb.deletePool(pool.id);
assert.equal(deleted, true, "deletePool should return true");
// Pool should be gone.

View File

@@ -23,8 +23,7 @@ import path from "node:path";
// ── DB harness (same pattern as quota-exclusivity-reconcile.test.ts) ─────────
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pool-delete-prune-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET =
process.env.API_KEY_SECRET || "delete-prune-test-secret-32chars!!";
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "delete-prune-test-secret-32chars!!";
const core = await import("../../src/lib/db/core.ts");
const poolsDb = await import("../../src/lib/db/quotaPools.ts");
@@ -62,9 +61,8 @@ test.after(async () => {
// ── Helper: get allowed_quotas for a key by id from DB ───────────────────────
function getAllowedQuotasById(keyId: string): string[] {
const db = core.getDbInstance();
const row = (db as any)
.prepare("SELECT allowed_quotas FROM api_keys WHERE id = ?")
.get(keyId) as { allowed_quotas: string } | undefined;
const row = (db as any).prepare("SELECT allowed_quotas FROM api_keys WHERE id = ?").get(keyId) as
{ allowed_quotas: string } | undefined;
if (!row) return [];
try {
const parsed = JSON.parse(row.allowed_quotas ?? "[]");
@@ -85,7 +83,7 @@ test("deletePool prunes its id from api_key allowed_quotas", async () => {
const before = getAllowedQuotasById(keyObj.id);
assert.ok(before.includes(pool.id), `pool.id should be in allowed_quotas before delete`);
poolsDb.deletePool(pool.id);
await poolsDb.deletePool(pool.id);
const after = getAllowedQuotasById(keyObj.id);
assert.ok(!after.includes(pool.id), `pool.id should NOT be in allowed_quotas after delete`);
@@ -103,7 +101,7 @@ test("deletePool preserves unrelated pool ids in allowed_quotas", async () => {
allowedQuotas: [poolToDelete.id, otherPool.id, unrelatedId],
});
poolsDb.deletePool(poolToDelete.id);
await poolsDb.deletePool(poolToDelete.id);
const after = getAllowedQuotasById(keyObj.id);
assert.ok(!after.includes(poolToDelete.id), "deleted pool id should be removed");
@@ -121,7 +119,7 @@ test("deletePool does not modify keys that don't reference the deleted pool", as
// This key only references otherPool, not poolToDelete
await apiKeysDb.updateApiKeyPermissions(keyObj.id, { allowedQuotas: [otherPool.id] });
poolsDb.deletePool(poolToDelete.id);
await poolsDb.deletePool(poolToDelete.id);
const after = getAllowedQuotasById(keyObj.id);
assert.deepEqual(after, [otherPool.id], "key referencing only other pool should be unchanged");
@@ -134,7 +132,7 @@ test("deletePool: key with empty allowed_quotas stays empty", async () => {
const keyObj = await apiKeysDb.createApiKey("Prune Key 4", "machine-prune-4");
// Don't set allowedQuotas — default is []
poolsDb.deletePool(pool.id);
await poolsDb.deletePool(pool.id);
const after = getAllowedQuotasById(keyObj.id);
assert.deepEqual(after, [], "empty allowed_quotas should remain empty after delete");
@@ -156,7 +154,7 @@ test("deletePool prunes pool id from ALL keys that reference it", async () => {
await apiKeysDb.updateApiKeyPermissions(k.id, { allowedQuotas: [pool.id, otherPoolId] });
}
poolsDb.deletePool(pool.id);
await poolsDb.deletePool(pool.id);
for (const k of keys) {
const after = getAllowedQuotasById(k.id);
@@ -167,23 +165,31 @@ test("deletePool prunes pool id from ALL keys that reference it", async () => {
// ── 6. deletePool still returns true/false correctly ─────────────────────────
test("deletePool returns true for existing pool, false for non-existent", () => {
test("deletePool returns true for existing pool, false for non-existent", async () => {
const pool = poolsDb.createPool({ connectionId: "conn-ret-1", name: "Return Test" });
assert.equal(poolsDb.deletePool(pool.id), true, "should return true for existing pool");
assert.equal(poolsDb.deletePool(pool.id), false, "should return false for already-deleted pool");
assert.equal(poolsDb.deletePool("nonexistent-id"), false, "should return false for unknown id");
assert.equal(await poolsDb.deletePool(pool.id), true, "should return true for existing pool");
assert.equal(
await poolsDb.deletePool(pool.id),
false,
"should return false for already-deleted pool"
);
assert.equal(
await poolsDb.deletePool("nonexistent-id"),
false,
"should return false for unknown id"
);
});
// ── 7. Pool row and allocation rows are gone after delete (regression guard) ──
test("deletePool removes pool and allocation rows from DB", () => {
test("deletePool removes pool and allocation rows from DB", async () => {
const pool = poolsDb.createPool({
connectionId: "conn-reg-1",
name: "Regression Pool",
allocations: [{ apiKeyId: "key-reg-1", weight: 50, policy: "hard" }],
});
poolsDb.deletePool(pool.id);
await poolsDb.deletePool(pool.id);
assert.equal(poolsDb.getPool(pool.id), null, "getPool should return null after delete");
const { items: allPools } = poolsDb.listPools();

View File

@@ -0,0 +1,62 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const root = join(import.meta.dirname, "../..");
const llmChatCardPath =
"src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx";
const src = readFileSync(join(root, llmChatCardPath), "utf8");
const DISABLED_ON_LOADING = /disabled\s*=\s*\{\s*loading\s*\}/;
const MODELS_LOADING_MARKER = /modelsLoading|Loading…|Loading\.\.\./;
const ERROR_BRANCH = /error\s*&&/;
const RETRY_ACTION = /onClick\s*=\s*\{[^}]*retry|retry[A-Za-z]*\s*\(\)|const\s+\[reload/i;
const NO_MODELS_AFTER_EMPTY = /modelOptions\.length\s*===?\s*0|models\.length\s*===?\s*0/;
test("LlmChatCard destructures loading and error from useProviderModels (#9626)", () => {
const match = src.match(/const\s*\{\s*([^}]+)\s*\}\s*=\s*useProviderModels\(/);
assert.ok(match, "Expected to find a destructuring of useProviderModels");
const destructured = match[1];
assert.ok(
destructured.includes("loading"),
"loading state must be destructured from useProviderModels"
);
assert.ok(destructured.includes("error"), "error state must be destructured from useProviderModels");
});
test("LlmChatCard disables the model selector while models are loading (#9626)", () => {
assert.ok(
DISABLED_ON_LOADING.test(src),
"The model <select> must be disabled while the models request is pending"
);
});
test("LlmChatCard shows a visible loading label while models are pending (#9626)", () => {
assert.ok(
MODELS_LOADING_MARKER.test(src),
"A visible loading text (e.g. 'Loading…') must appear while the models request is pending"
);
});
test("LlmChatCard surfaces the provider model error in the UI (#9626)", () => {
assert.ok(
ERROR_BRANCH.test(src),
"An error branch that renders the captured error message must exist"
);
});
test("LlmChatCard offers a retry action when the model request fails (#9626)", () => {
assert.ok(
RETRY_ACTION.test(src),
"A retry action must be offered next to the model error"
);
});
test("LlmChatCard keeps the empty-state message distinct from an error (#9626)", () => {
assert.ok(
NO_MODELS_AFTER_EMPTY.test(src),
"The empty-state (no models) message must only be shown for a successful empty response"
);
});

View File

@@ -40,13 +40,21 @@ test("Responses->Chat: first tool_call chunk announces role=assistant", () => {
);
assert.equal(first.choices[0].delta.tool_calls[0].function.name, "get_weather");
// Subsequent argument deltas must NOT repeat the role announcement.
// #9168: arguments deltas are buffered until output_item.done for schema normalization.
const next = openaiResponsesToOpenAIResponse(
{ type: "response.function_call_arguments.delta", delta: '{"x":1}' },
state
);
assert.ok(next, "should emit a chunk for arguments.delta");
assert.equal(next.choices[0].delta.role, undefined, "only the first delta announces the role");
assert.equal(next, null, "arguments delta should buffer until output_item.done");
// The args are emitted at output_item.done, and the role is not re-announced.
const done = openaiResponsesToOpenAIResponse(
{ type: "response.output_item.done", item: { type: "function_call", call_id: "call_abc", name: "get_weather" } },
state
);
assert.ok(done, "should emit a chunk for output_item.done");
assert.equal(done.choices[0].delta.role, undefined, "role announcement already happened on first chunk");
assert.equal(done.choices[0].delta.tool_calls[0].function.arguments, '{"x":1}');
});
test("Responses->Chat: first text chunk announces role=assistant", () => {

View File

@@ -203,7 +203,8 @@ test("Responses -> OpenAI: incremental tool call events + response.completed sna
},
state
);
assert.ok(args, "should emit args delta chunk");
// #9168: arguments deltas are buffered until output_item.done for schema normalization
assert.equal(args, null, "args delta should buffer until output_item.done");
openaiResponsesToOpenAIResponse(
{

View File

@@ -474,7 +474,7 @@ test("Responses -> OpenAI: tool-call delta, reasoning delta and completed usage
},
state
);
openaiResponsesToOpenAIResponse(
const done = openaiResponsesToOpenAIResponse(
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_2", name: "weather" },
@@ -497,7 +497,10 @@ test("Responses -> OpenAI: tool-call delta, reasoning delta and completed usage
);
assert.equal(added.choices[0].delta.tool_calls[0].function.name, "weather");
assert.equal(args.choices[0].delta.tool_calls[0].function.arguments, '{"city":"SP"}');
// #9168: function_call_arguments.delta is buffered and returns null;
// arguments are emitted by output_item.done instead.
assert.equal(args, null);
assert.equal(done.choices[0].delta.tool_calls[0].function.arguments, '{"city":"SP"}');
assert.equal(reasoning.choices[0].delta.reasoning_content, "Need weather info.");
assert.equal(completed.choices[0].finish_reason, "tool_calls");
const comp = completed as {

View File

@@ -42,6 +42,26 @@ test("non-GPT-5.6 models still get max downgraded to xhigh", () => {
)
);
assert.equal(translated.reasoning_effort, "xhigh");
<<<<<<< HEAD
});
// ─────────────────────────────────────────────────────────────────────
// PR #9142 — Anthropic top-level `system` prompts must trigger background detection
// ─────────────────────────────────────────────────────────────────────
const { getBackgroundTaskReason, setBackgroundDegradationConfig } =
await import("../../open-sse/services/backgroundTaskDetector.ts");
test("#9142 Anthropic top-level system prompts must trigger background detection", () => {
setBackgroundDegradationConfig({ enabled: true });
assert.equal(
getBackgroundTaskReason({
system: "Generate a title for this conversation",
messages: [{ role: "user", content: "hello" }],
}),
"system_prompt_pattern"
);
});
=======
// #9140 — VS Code routes filter out built-in auto models
const { isUsableChatModel } = await import(
@@ -59,3 +79,28 @@ test("#9140 VS Code listing must accept built-in auto routing entries", () => {
false,
"operator-created combo should still be rejected"
);
>>>>>>> origin/release/v3.8.50
});
// ── #9160 model discovery: capabilities.effort_tiers ────────────────────────
// #9160: model discovery must ingest capabilities.effort_tiers
test("#9160 model discovery must ingest capabilities.effort_tiers", () => {
assert.deepEqual(
detectSupportedThinkingEfforts({
capabilities: { effort_tiers: ["low", "medium", "high", "xhigh"] },
}),
["low", "medium", "high", "xhigh"]
);
});
test("#9160 capabilities.effort_tiers with duplicate and synonym", () => {
assert.deepEqual(
detectSupportedThinkingEfforts({
capabilities: { effort_tiers: ["low", "low", "max"] },
}),
["low", "xhigh"]
);