mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-09 00:32:13 +03:00
Compare commits
8 Commits
feat/9490-
...
fix/9626-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5d0399739 | ||
|
|
d0047ee615 | ||
|
|
064a19b2d1 | ||
|
|
492f9ddc4a | ||
|
|
e7d9055314 | ||
|
|
2ed487583b | ||
|
|
a6b3b4f57a | ||
|
|
cf31b795b7 |
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
@@ -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.
|
||||
1
changelog.d/fixes/8906-quota-pool-combo-cleanup.md
Normal file
1
changelog.d/fixes/8906-quota-pool-combo-cleanup.md
Normal 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
|
||||
1
changelog.d/fixes/9142-fix.plan.md
Normal file
1
changelog.d/fixes/9142-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(background): detect Anthropic top-level system prompts for background task detection (#9142)
|
||||
1
changelog.d/fixes/9160-fix.plan.md
Normal file
1
changelog.d/fixes/9160-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(model-discovery): ingest capabilities.effort_tiers for synced models (#9160)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(translator): buffer and normalize upstream tool-call argument deltas so optional null values are stripped before reaching the client (#9168)
|
||||
1
changelog.d/fixes/9177-fix.plan.md
Normal file
1
changelog.d/fixes/9177-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(translator): avoid double-normalizing tool names in Gemini-to-Claude response path (#9177)
|
||||
1
changelog.d/fixes/9626-playground-errors.md
Normal file
1
changelog.d/fixes/9626-playground-errors.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(playground): surface provider model loading errors and offer retry (#9626)
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
121
open-sse/executors/gemini-web/capabilities.ts
Normal file
121
open-sse/executors/gemini-web/capabilities.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
427
tests/integration/quota-pool-delete-combo-cleanup.test.ts
Normal file
427
tests/integration/quota-pool-delete-combo-cleanup.test.ts
Normal 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), []);
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
258
tests/unit/gemini-web-capabilities-9356.test.ts
Normal file
258
tests/unit/gemini-web-capabilities-9356.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
62
tests/unit/repro-9626.test.ts
Normal file
62
tests/unit/repro-9626.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user