mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-09 08:42:15 +03:00
Compare commits
22 Commits
fix/9626-p
...
feat/9490-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49def2f0c3 | ||
|
|
e0ce95c592 | ||
|
|
13dcbfd117 | ||
|
|
ec02945d97 | ||
|
|
cd121844ce | ||
|
|
8b29e73b27 | ||
|
|
b7864cdb6c | ||
|
|
77cce62357 | ||
|
|
c5f0ce01bc | ||
|
|
00b79b5507 | ||
|
|
b5e17bdbde | ||
|
|
aefa2b665b | ||
|
|
df1ea5bd77 | ||
|
|
a90c5e5aba | ||
|
|
c88b96244f | ||
|
|
93ee4dce9f | ||
|
|
6c95e2b354 | ||
|
|
3835f318d0 | ||
|
|
57e1d88ae6 | ||
|
|
69647b3b94 | ||
|
|
61f962294d | ||
|
|
a2c15c5a8c |
@@ -1033,7 +1033,11 @@ 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 });
|
||||
const baseConfigHook = createOmniRouteConfigHook(resolved, {
|
||||
cache: sharedCache,
|
||||
diskSnapshotReader: defaultDiskSnapshotReader,
|
||||
diskSnapshotWriter: defaultDiskSnapshotWriter,
|
||||
});
|
||||
const configWithSyncCommand = async (input: Config) => {
|
||||
await baseConfigHook(input);
|
||||
const cfg = input as Config & {
|
||||
@@ -4741,7 +4745,7 @@ export type OmniRouteDiskSnapshotWriter = (
|
||||
export type OmniRouteDiskSnapshotReader = (
|
||||
providerId: string,
|
||||
identityFingerprint: string
|
||||
) => Promise<Omit<OmniRouteFetchCacheEntry, "expiresAt"> | undefined>;
|
||||
) => Promise<(Omit<OmniRouteFetchCacheEntry, "expiresAt"> & { writtenAt?: number }) | undefined>;
|
||||
|
||||
/**
|
||||
* Bind a snapshot to the endpoint and effective credential tuple without
|
||||
@@ -4824,15 +4828,36 @@ 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. */
|
||||
/** 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;
|
||||
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)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -5067,7 +5092,6 @@ export function createDebugLoggingFetch(
|
||||
}
|
||||
};
|
||||
}
|
||||
export const noopDiskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
|
||||
|
||||
export type OmniRouteReadAuthJson = () => Promise<AuthJsonShape | undefined | null>;
|
||||
|
||||
@@ -5170,8 +5194,8 @@ export function createOmniRouteConfigHook(
|
||||
const compressionMetaFetcher =
|
||||
deps.compressionMetaFetcher ?? defaultOmniRouteCompressionMetaFetcher;
|
||||
const providersFetcher = deps.providersFetcher ?? defaultOmniRouteProvidersFetcher;
|
||||
const diskSnapshotReader = deps.diskSnapshotReader ?? defaultDiskSnapshotReader;
|
||||
const diskSnapshotWriter = deps.diskSnapshotWriter ?? defaultDiskSnapshotWriter;
|
||||
const diskSnapshotReader = deps.diskSnapshotReader ?? noopDiskSnapshotReader;
|
||||
const diskSnapshotWriter = deps.diskSnapshotWriter ?? noopDiskSnapshotWriter;
|
||||
const now = deps.now ?? Date.now;
|
||||
const cache: OmniRouteFetchCache = deps.cache ?? new Map();
|
||||
const logger = deps.logger ?? console;
|
||||
@@ -5266,12 +5290,12 @@ export function createOmniRouteConfigHook(
|
||||
const t = now();
|
||||
const cached = cache.get(cacheKey);
|
||||
|
||||
let rawModels: OmniRouteRawModelEntry[];
|
||||
let rawCombos: OmniRouteRawCombo[];
|
||||
let rawAutoCombos: OmniRouteRawAutoCombo[];
|
||||
let rawEnrichment: OmniRouteEnrichmentMap;
|
||||
let rawCompressionCombos: OmniRouteCompressionCombo[];
|
||||
let rawConnections: OmniRouteProviderConnection[];
|
||||
let rawModels: OmniRouteRawModelEntry[] = [];
|
||||
let rawCombos: OmniRouteRawCombo[] = [];
|
||||
let rawAutoCombos: OmniRouteRawAutoCombo[] = [];
|
||||
let rawEnrichment: OmniRouteEnrichmentMap = new Map();
|
||||
let rawCompressionCombos: OmniRouteCompressionCombo[] = [];
|
||||
let rawConnections: OmniRouteProviderConnection[] = [];
|
||||
|
||||
if (cached && cached.expiresAt > t) {
|
||||
rawModels = cached.rawModels;
|
||||
@@ -5281,160 +5305,275 @@ export function createOmniRouteConfigHook(
|
||||
rawCompressionCombos = cached.rawCompressionCombos;
|
||||
rawConnections = cached.rawConnections;
|
||||
} else {
|
||||
// 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) {
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 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";
|
||||
logger.warn(
|
||||
"[omniroute-plugin] config shim: /api/pricing/models fetch failed; publishing raw-id static catalog",
|
||||
err
|
||||
`[omniroute-plugin] config shim: warm startup from disk snapshot (${snapshotResult.rawModels.length} models, age ${ageLabel})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
);
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
createOmniRouteProviderHook,
|
||||
OmniRoutePlugin,
|
||||
resolveOmniRoutePluginOptions,
|
||||
_resetInflightRefresh,
|
||||
type OmniRouteCombosFetcher,
|
||||
type OmniRouteEnrichmentEntry,
|
||||
type OmniRouteEnrichmentFetcher,
|
||||
@@ -47,6 +48,16 @@ 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
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -1239,7 +1250,10 @@ 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")),
|
||||
logger.entries.some((e) =>
|
||||
String(e[0]).includes("using stale disk cache") ||
|
||||
String(e[0]).includes("warm startup from disk snapshot")
|
||||
),
|
||||
"disk-cache hydration breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
827
@omniroute/opencode-plugin/tests/warm-startup.test.ts
Normal file
827
@omniroute/opencode-plugin/tests/warm-startup.test.ts
Normal file
@@ -0,0 +1,827 @@
|
||||
/**
|
||||
* 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)");
|
||||
});
|
||||
@@ -52,8 +52,11 @@ export class ServerSupervisor {
|
||||
// silently, so a boot that never becomes ready looked like a dead hang with zero
|
||||
// output even at APP_LOG_LEVEL=debug. Pipe stdout too and buffer it alongside
|
||||
// stderr so a readiness timeout can surface what the child actually printed.
|
||||
// #9156: macOS launchd cannot resolve bare "node" because its PATH is
|
||||
// minimal. Always use process.execPath (the absolute path to the running
|
||||
// Node.js binary) so the supervisor never depends on PATH resolution.
|
||||
this.child = spawn(
|
||||
process.versions.bun ? process.execPath : "node",
|
||||
process.execPath,
|
||||
process.versions.bun
|
||||
? [this.serverPath]
|
||||
: buildNodeRuntimeArgs(process.env, this.memoryLimit, this.serverPath),
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
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/7754-best-free-fallback.md
Normal file
1
changelog.d/fixes/7754-best-free-fallback.md
Normal file
@@ -0,0 +1 @@
|
||||
- test(combo): guard auto/best-free never leaks the combo name as a model (#7754)
|
||||
1
changelog.d/fixes/8847-bun-prebuilds.md
Normal file
1
changelog.d/fixes/8847-bun-prebuilds.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(build): include better-sqlite3 prebuilds in standalone bun bundle
|
||||
1
changelog.d/fixes/9156-macos-autostart-execpath.md
Normal file
1
changelog.d/fixes/9156-macos-autostart-execpath.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(cli): use process.execPath for macOS launchd autostart
|
||||
1
changelog.d/fixes/9486-claude-400-quota.md
Normal file
1
changelog.d/fixes/9486-claude-400-quota.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): classify 400 out of extra usage as quota_exhausted for Anthropic OAuth
|
||||
1
changelog.d/fixes/9623-connection-test-recovery.md
Normal file
1
changelog.d/fixes/9623-connection-test-recovery.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(resilience): failed connection test now sets a short cooldown so connections recover after transient outages (#9623)
|
||||
1
changelog.d/fixes/9624-telemetry-cleanup-wiring.md
Normal file
1
changelog.d/fixes/9624-telemetry-cleanup-wiring.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(db): wire telemetry cleanup scheduler in Next.js startup path (#9624)
|
||||
1
changelog.d/fixes/9625-domain-cost-ms.md
Normal file
1
changelog.d/fixes/9625-domain-cost-ms.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(db): align domain_cost_history cleanup cutoff with millisecond column (#9625)
|
||||
1
changelog.d/fixes/9633-npm-build-files.md
Normal file
1
changelog.d/fixes/9633-npm-build-files.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(build): add build-next-isolated.mjs sibling imports to package.json files array
|
||||
@@ -149,6 +149,18 @@ export const ERROR_RULES: ErrorRule[] = [
|
||||
backoff: true,
|
||||
reason: "quota_exhausted",
|
||||
},
|
||||
{
|
||||
id: "out_of_extra_usage",
|
||||
text: "out of extra usage",
|
||||
backoff: true,
|
||||
reason: "quota_exhausted",
|
||||
},
|
||||
{
|
||||
id: "extra_usage_required",
|
||||
text: "extra usage required",
|
||||
backoff: true,
|
||||
reason: "quota_exhausted",
|
||||
},
|
||||
{ id: "capacity", text: "capacity", backoff: true, reason: "model_capacity" },
|
||||
{ id: "overloaded", text: "overloaded", backoff: true, reason: "model_capacity" },
|
||||
{ id: "high_demand", text: "high demand", backoff: true, reason: "model_capacity" },
|
||||
|
||||
@@ -190,10 +190,26 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an `error` field carries a real failure signal. A key-presence check
|
||||
* (`!= null`) false-positives on benign values some backends emit on every
|
||||
* chunk (`{}`, `""`, `false`, `0`) — e.g. tool-call turns where a chunk with
|
||||
* real tool_calls content also carries `"error": {}`. Only substantive values
|
||||
* are treated as upstream failures.
|
||||
*/
|
||||
function isSubstantiveError(value: unknown): boolean {
|
||||
if (value === null || value === undefined) return false;
|
||||
if (typeof value === "string") return value.trim().length > 0;
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
return Object.keys(value as Record<string, unknown>).length > 0;
|
||||
}
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function isStreamingUpstreamError(parsed: unknown, eventType: string): boolean {
|
||||
if (eventType === "response.failed" || eventType === "error") return true;
|
||||
if (!isRecord(parsed)) return false;
|
||||
if (parsed.error != null) return true;
|
||||
if (isSubstantiveError(parsed.error)) return true;
|
||||
|
||||
const nestedResponse = isRecord(parsed.response) ? parsed.response : null;
|
||||
return nestedResponse?.status === "failed" && nestedResponse.error != null;
|
||||
|
||||
@@ -34,8 +34,11 @@
|
||||
"scripts/dev/tls-options.mjs",
|
||||
"scripts/check/check-supported-node-runtime.ts",
|
||||
"scripts/dev/sync-env.mjs",
|
||||
"scripts/build/native-binary-compat.mjs",
|
||||
"scripts/build/assembleStandalone.mjs",
|
||||
"scripts/build/backendOnlyPages.mjs",
|
||||
"scripts/build/build-next-isolated.mjs",
|
||||
"scripts/build/build-tproxy-native.mjs",
|
||||
"scripts/build/native-binary-compat.mjs",
|
||||
"scripts/build/runtime-env.mjs",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
|
||||
@@ -89,6 +89,15 @@ const NATIVE_ASSET_ENTRIES = [
|
||||
src: ["node_modules", "better-sqlite3", "build"],
|
||||
dest: ["node_modules", "better-sqlite3", "build"],
|
||||
},
|
||||
{
|
||||
// #8847: Bun (and npx -g global installs) resolve better-sqlite3's native
|
||||
// binary from prebuilds/ instead of build/Release/, so the compiled build/
|
||||
// copy alone leaves a hollow package that falls back to sql.js (OOM under
|
||||
// Bun). Ship the prebuilds alongside the compiled binary.
|
||||
label: "better-sqlite3 prebuilds (Bun / global installs)",
|
||||
src: ["node_modules", "better-sqlite3", "prebuilds"],
|
||||
dest: ["node_modules", "better-sqlite3", "prebuilds"],
|
||||
},
|
||||
{
|
||||
// TPROXY IP_TRANSPARENT addon (Fase 3 / Epic A). Built by build-tproxy-native
|
||||
// before assembly; Linux-only + opt-in, so the source is absent on non-Linux
|
||||
|
||||
@@ -121,6 +121,9 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
|
||||
// shipped via package.json "files", so it must be allowed in the tarball.
|
||||
"open-sse/utils/setupPolyfill.ts",
|
||||
"package.json",
|
||||
"scripts/build/assembleStandalone.mjs",
|
||||
"scripts/build/backendOnlyPages.mjs",
|
||||
"scripts/build/build-tproxy-native.mjs",
|
||||
"scripts/build/build-next-isolated.mjs",
|
||||
"scripts/check/check-supported-node-runtime.ts",
|
||||
"scripts/build/native-binary-compat.mjs",
|
||||
|
||||
@@ -609,14 +609,6 @@ async function main() {
|
||||
args: ["run", "check:pack-artifact"],
|
||||
timeout: 20 * 60 * 1000,
|
||||
});
|
||||
// WS1.2 (#7065 class): boot the REAL packed tarball from a clean install —
|
||||
// the runtime gate structure checks cannot provide. Reuses the same dist/ build.
|
||||
slow.push({
|
||||
id: "pack-boot",
|
||||
label: "Tarball boot-smoke (installed CLI serves /health)",
|
||||
args: ["run", "check:pack-boot"],
|
||||
timeout: 15 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
slow.forEach((g) => announce(`${g.label} [parallel]`));
|
||||
const slowResults = await Promise.all(
|
||||
@@ -633,6 +625,41 @@ async function main() {
|
||||
detail: code === 0 ? "pass" : firstFailureLine(out),
|
||||
});
|
||||
});
|
||||
|
||||
if (WITH_BUILD) {
|
||||
// WS1.2 (#7065 class): boot the REAL packed tarball from a clean install.
|
||||
// check:pack-artifact is the builder for dist/ when staging is absent, so the
|
||||
// boot smoke MUST run after it completes. Running both in the parallel wave
|
||||
// races check:pack-boot against dist/server.js creation on clean worktrees.
|
||||
const packArtifactIndex = slow.findIndex((g) => g.id === "pack-artifact");
|
||||
const packArtifactResult = slowResults[packArtifactIndex];
|
||||
const bootLabel = "Tarball boot-smoke (installed CLI serves /health)";
|
||||
|
||||
if (!packArtifactResult || packArtifactResult.code !== 0) {
|
||||
const out = "skipped because package-artifact did not produce a valid dist/ build";
|
||||
saveGateLog("pack-boot", out);
|
||||
record({
|
||||
id: "pack-boot",
|
||||
label: bootLabel,
|
||||
kind: "hard",
|
||||
ok: false,
|
||||
detail: out,
|
||||
});
|
||||
} else {
|
||||
announce(bootLabel);
|
||||
const { code, out } = await runAsync(npmCmd, ["run", "check:pack-boot"], {
|
||||
timeout: 15 * 60 * 1000,
|
||||
});
|
||||
saveGateLog("pack-boot", out);
|
||||
record({
|
||||
id: "pack-boot",
|
||||
label: bootLabel,
|
||||
kind: "hard",
|
||||
ok: code === 0,
|
||||
detail: code === 0 ? "pass" : firstFailureLine(out),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (WITH_BUILD) {
|
||||
// --with-build without the suites (--quick): still verify the package artifact.
|
||||
const { code, out } = await runAsync(npmCmd, ["run", "check:pack-artifact"], {
|
||||
|
||||
@@ -701,6 +701,17 @@ export async function testSingleConnection(connectionId: string, validationModel
|
||||
? makeDiagnosis("ok", "local", null, null)
|
||||
: classifyFailure({ error: result.error, statusCode: result.statusCode, provider }));
|
||||
|
||||
// #9623: a failed connection test must not paint the connection permanently red.
|
||||
// Previously a non-terminal failure wrote `testStatus: "error"` with
|
||||
// `rateLimitedUntil: null` — since the cooldown filter only ever skips entries
|
||||
// whose rateLimitedUntil is in the future, a null cooldown left the connection
|
||||
// permanently unavailable after a transient outage. Give non-terminal test
|
||||
// failures a short cooldown so the lazy-recovery path retries them.
|
||||
const terminalTestStatuses = new Set(["banned", "expired", "credits_exhausted"]);
|
||||
const isTerminalFailure =
|
||||
!result.valid && terminalTestStatuses.has(String(diagnosis.code ?? diagnosis.type ?? "").toLowerCase());
|
||||
const testFailureCooldownMs = result.valid ? 0 : 30_000; // 30s retry window
|
||||
|
||||
const updateData: Record<string, any> = {
|
||||
testStatus: result.valid ? "active" : "error",
|
||||
lastError: result.valid ? null : result.error,
|
||||
@@ -709,7 +720,12 @@ export async function testSingleConnection(connectionId: string, validationModel
|
||||
lastErrorType: result.valid ? null : diagnosis.type,
|
||||
lastErrorSource: result.valid ? null : diagnosis.source,
|
||||
errorCode: result.valid ? null : diagnosis.code || result.statusCode || null,
|
||||
rateLimitedUntil: result.valid ? null : connection.rateLimitedUntil || null,
|
||||
rateLimitedUntil:
|
||||
result.valid || isTerminalFailure
|
||||
? result.valid
|
||||
? null
|
||||
: connection.rateLimitedUntil || null
|
||||
: new Date(Date.now() + testFailureCooldownMs).toISOString(),
|
||||
};
|
||||
|
||||
if (result.valid) {
|
||||
|
||||
@@ -10191,6 +10191,8 @@
|
||||
"copied": "Copied!",
|
||||
"run": "Run",
|
||||
"running": "Running...",
|
||||
"loading": "Loading...",
|
||||
"retry": "Retry",
|
||||
"response": "Response",
|
||||
"tunnel": "Tunnel",
|
||||
"send": "Send",
|
||||
|
||||
@@ -10191,6 +10191,8 @@
|
||||
"copied": "Copiado!",
|
||||
"run": "Executar",
|
||||
"running": "Executando...",
|
||||
"loading": "Carregando...",
|
||||
"retry": "Tentar novamente",
|
||||
"response": "Resposta",
|
||||
"tunnel": "Tunnel",
|
||||
"send": "Enviar",
|
||||
|
||||
@@ -10191,6 +10191,8 @@
|
||||
"copied": "Đã sao chép!",
|
||||
"run": "Chạy",
|
||||
"running": "Đang chạy...",
|
||||
"loading": "Đang tải...",
|
||||
"retry": "Thử lại",
|
||||
"response": "Phản hồi",
|
||||
"tunnel": "Đường hầm",
|
||||
"send": "Gửi",
|
||||
|
||||
@@ -306,6 +306,7 @@ export async function registerNodejs(): Promise<void> {
|
||||
{ applyRuntimeSettings },
|
||||
{ startRuntimeConfigHotReload },
|
||||
{ startSpendBatchWriter },
|
||||
{ startCleanupScheduler },
|
||||
{ registerDefaultGuardrails },
|
||||
{ ensurePersistentManagementPasswordHash },
|
||||
{ skillExecutor },
|
||||
@@ -320,6 +321,7 @@ export async function registerNodejs(): Promise<void> {
|
||||
import("@/lib/config/runtimeSettings"),
|
||||
import("@/lib/config/hotReload"),
|
||||
import("@/lib/spend/batchWriter"),
|
||||
import("@/lib/db/cleanup"),
|
||||
import("@/lib/guardrails"),
|
||||
import("@/lib/auth/managementPassword"),
|
||||
import("@/lib/skills/executor"),
|
||||
@@ -489,6 +491,17 @@ export async function registerNodejs(): Promise<void> {
|
||||
console.warn("[STARTUP] Could not initialize vacuum scheduler (non-fatal):", msg);
|
||||
}
|
||||
|
||||
// Retention cleanup scheduler (#4691/#6988, #9624): runs the general retention
|
||||
// cleanup once after startup and then every 6 hours. Previously this was only
|
||||
// wired into the unused src/server-init.ts, so telemetry tables grew unboundedly
|
||||
// even with retention.autoCleanupEnabled=true. Idempotent (guarded internally).
|
||||
try {
|
||||
startCleanupScheduler();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn("[STARTUP] Could not start cleanup scheduler (non-fatal):", msg);
|
||||
}
|
||||
|
||||
// Warm the model catalog's durable, apiKey-independent sub-caches at
|
||||
// startup — see warmModelCatalogCache() for why the top-level Response
|
||||
// cache alone doesn't deliver this. Fire-and-forget, non-fatal.
|
||||
|
||||
@@ -253,14 +253,15 @@ export async function cleanupMemoryEntries(): Promise<CleanupResult> {
|
||||
|
||||
/**
|
||||
* Clean up old domain_cost_history based on retention settings. (#6848)
|
||||
* Uses unix-epoch `timestamp` column (INTEGER).
|
||||
* The `timestamp` column stores epoch milliseconds (saveCostEntry default
|
||||
* is Date.now()), so the cutoff must be in milliseconds to match. (#9625)
|
||||
*/
|
||||
export async function cleanupDomainCostHistory(): Promise<CleanupResult> {
|
||||
const db = getDbInstance();
|
||||
const retention = getRetentionSettings();
|
||||
|
||||
const retentionDays = retention.domainCostHistory;
|
||||
const cutoffEpoch = Math.floor(Date.now() / 1000) - retentionDays * 86_400;
|
||||
const cutoffEpoch = Date.now() - retentionDays * 86_400_000;
|
||||
|
||||
const result: CleanupResult = { deleted: 0, errors: 0 };
|
||||
|
||||
|
||||
@@ -17,12 +17,6 @@ import { getDbInstance } from "./core";
|
||||
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>
|
||||
|
||||
@@ -64,7 +64,9 @@
|
||||
"tests/unit/anthropic-thinking-signature-recovery.test.ts",
|
||||
"tests/unit/antigravity-429-quota-tdd.test.ts",
|
||||
"tests/unit/antigravity-prefer-stored-project.test.ts",
|
||||
"tests/unit/api-key-policy-noauth-allowed-connections.test.ts",
|
||||
"tests/unit/api-key-rotator-health.test.ts",
|
||||
"tests/unit/api-key-policy-noauth-allowed-connections.test.ts",
|
||||
"tests/unit/appearance-widget-settings-schema.test.ts",
|
||||
"tests/unit/auth-antigravity-account-retry-v2.test.ts",
|
||||
"tests/unit/auth-clear-account-error.test.ts",
|
||||
@@ -213,7 +215,9 @@
|
||||
"tests/unit/executor-web-cookie-sweep.test.ts",
|
||||
"tests/unit/format-provider-error-cause.test.ts",
|
||||
"tests/unit/forwarded-header-budget.test.ts",
|
||||
"tests/unit/gemini-web-capabilities-9356.test.ts",
|
||||
"tests/unit/gemini-web-missing-browser-3516.test.ts",
|
||||
"tests/unit/gemini-web-capabilities-9356.test.ts",
|
||||
"tests/unit/grok-cli-oauth.test.ts",
|
||||
"tests/unit/guardrails-api-3496.test.ts",
|
||||
"tests/unit/headroom-codex-quota-snapshot-6379.test.ts",
|
||||
@@ -273,7 +277,9 @@
|
||||
"tests/unit/rate-limit-manager.test.ts",
|
||||
"tests/unit/rate-limit-queue-timeout-lockout.test.ts",
|
||||
"tests/unit/repro-7503-no-choices.test.ts",
|
||||
"tests/unit/repro-9486.test.ts",
|
||||
"tests/unit/repro-9630-combo-false-503.test.ts",
|
||||
"tests/unit/repro-9486.test.ts",
|
||||
"tests/unit/repro-antigravity-404-family-cooldown-hijack.test.ts",
|
||||
"tests/unit/responses-handler.test.ts",
|
||||
"tests/unit/rotation-config-omniroute.test.ts",
|
||||
|
||||
@@ -255,10 +255,7 @@ test("buildAdobeImagePayload attaches referenceBlobs like live adobe_atach_image
|
||||
{ id: "2a4f1025-e0dc-4671-a11a-7dfd3c07bd94", usage: "general" },
|
||||
{ id: "84c11d1a-e798-4300-a63e-c06504ca2068", usage: "general" },
|
||||
]);
|
||||
assert.equal(
|
||||
(nano.generationMetadata as Record<string, unknown>).module,
|
||||
"text2image"
|
||||
);
|
||||
assert.equal((nano.generationMetadata as Record<string, unknown>).module, "text2image");
|
||||
|
||||
const gpt = buildAdobeImagePayload({
|
||||
prompt: "edit me",
|
||||
@@ -270,10 +267,7 @@ test("buildAdobeImagePayload attaches referenceBlobs like live adobe_atach_image
|
||||
assert.deepEqual(gpt.referenceBlobs, [
|
||||
{ id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", usage: "subject" },
|
||||
]);
|
||||
assert.equal(
|
||||
(gpt.generationMetadata as Record<string, unknown>).module,
|
||||
"image2image"
|
||||
);
|
||||
assert.equal((gpt.generationMetadata as Record<string, unknown>).module, "image2image");
|
||||
|
||||
// gpt-image: only first 2 subject refs survive (extra screenshots hang colligo).
|
||||
const gptMany = buildAdobeImagePayload({
|
||||
@@ -312,10 +306,7 @@ test("adobeFireflyMaxImageRefs + adaptive image timeout", () => {
|
||||
DEFAULT_IMAGE_TIMEOUT_MS + 2 * ADOBE_FIREFLY_IMAGE_TIMEOUT_PER_REF_MS
|
||||
);
|
||||
assert.equal(adobeFireflyImageTimeoutMs({ timeoutMs: 120_000, refCount: 5 }), 120_000);
|
||||
assert.equal(
|
||||
adobeFireflyImageTimeoutMs({ refCount: 99 }),
|
||||
ADOBE_FIREFLY_IMAGE_TIMEOUT_MAX_MS
|
||||
);
|
||||
assert.equal(adobeFireflyImageTimeoutMs({ refCount: 99 }), ADOBE_FIREFLY_IMAGE_TIMEOUT_MAX_MS);
|
||||
});
|
||||
|
||||
test("extractAdobeSourceImageSources reads Media page image fields", () => {
|
||||
@@ -369,10 +360,10 @@ test("resolveAdobeSourceImageIds uploads data URLs then returns blob ids", async
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
assert.match(String(headers["content-type"] || headers["Content-Type"] || ""), /image\//);
|
||||
assert.ok(init?.body);
|
||||
return new Response(
|
||||
JSON.stringify({ images: [{ id: `blob-${uploadCalls}` }] }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
return new Response(JSON.stringify({ images: [{ id: `blob-${uploadCalls}` }] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch ${u}`);
|
||||
};
|
||||
@@ -476,8 +467,7 @@ test("buildAdobeSubmitNonce is sha256(user_id + prompt[:256])", async () => {
|
||||
type: "access_token",
|
||||
client_id: "clio-playground-web",
|
||||
})
|
||||
)
|
||||
.toString("base64url");
|
||||
).toString("base64url");
|
||||
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url");
|
||||
const token = `${header}.${payload}.${"x".repeat(40)}`;
|
||||
// Pad token length for looksLikeAdobeJwt (>=80)
|
||||
@@ -509,8 +499,7 @@ test("buildAdobeSubmitNonce is sha256(user_id + prompt[:256])", async () => {
|
||||
});
|
||||
|
||||
test("normalizeAdobePollUrl rewrites firefly-epo jobs/result to BKS", () => {
|
||||
const raw =
|
||||
"https://firefly-epo855232.adobe.io/jobs/result/4ae9fd2a-0864-46dd-9834-cfc16e91faa6";
|
||||
const raw = "https://firefly-epo855232.adobe.io/jobs/result/4ae9fd2a-0864-46dd-9834-cfc16e91faa6";
|
||||
const out = normalizeAdobePollUrl(raw);
|
||||
assert.match(out, /^https:\/\/bks-epo8552\.adobe\.io\/v2\/jobs\/result\/4ae9fd2a/);
|
||||
assert.match(out, /host=firefly-epo855232\.adobe\.io/);
|
||||
@@ -592,9 +581,9 @@ test("fallback catalog has image and video entries from get_models capture", ()
|
||||
|
||||
test("extractAdobeAccountIdFromToken reads user_id claim", () => {
|
||||
// {"user_id":"0EB@AdobeID"} base64url
|
||||
const payload = Buffer.from(JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token" })).toString(
|
||||
"base64url"
|
||||
);
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token" })
|
||||
).toString("base64url");
|
||||
const jwt = `eyJhbGciOiJub25lIn0.${payload}.sig`;
|
||||
assert.equal(extractAdobeAccountIdFromToken(jwt), "0EB@AdobeID");
|
||||
});
|
||||
@@ -602,18 +591,7 @@ test("extractAdobeAccountIdFromToken reads user_id claim", () => {
|
||||
// --- Handlers (mocked fetch) ----------------------------------------------
|
||||
|
||||
function jsonResponse(status: number, body: unknown, headerMap: Record<string, string> = {}) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: {
|
||||
get: (name: string) => {
|
||||
const key = Object.keys(headerMap).find((k) => k.toLowerCase() === name.toLowerCase());
|
||||
return key ? headerMap[key] : null;
|
||||
},
|
||||
},
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
} as unknown as Response;
|
||||
return new Response(JSON.stringify(body) ?? null, { status, headers: headerMap });
|
||||
}
|
||||
|
||||
test("handleAdobeFireflyImageGeneration returns 400 when prompt is missing", async () => {
|
||||
@@ -779,13 +757,19 @@ test("guest JWT without AdobeID is detected", () => {
|
||||
const emptyPayload = Buffer.from("{}").toString("base64url");
|
||||
const guestJwt = `eyJhbGciOiJub25lIn0.${emptyPayload}.sig`;
|
||||
// Pad to lookLikeAdobeJwt length if needed
|
||||
const longGuest = `eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify({ client_id: "clio-playground-web" })).toString("base64url")}.` + "x".repeat(40);
|
||||
const longGuest =
|
||||
`eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify({ client_id: "clio-playground-web" })).toString("base64url")}.` +
|
||||
"x".repeat(40);
|
||||
assert.equal(isAdobeGuestAccessToken(longGuest), true);
|
||||
const userJwt =
|
||||
`eyJhbGciOiJSUzI1NiJ9.` +
|
||||
Buffer.from(JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token", client_id: "clio-playground-web" })).toString(
|
||||
"base64url"
|
||||
) +
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
user_id: "0EB@AdobeID",
|
||||
type: "access_token",
|
||||
client_id: "clio-playground-web",
|
||||
})
|
||||
).toString("base64url") +
|
||||
`.` +
|
||||
"y".repeat(40);
|
||||
assert.equal(isAdobeGuestAccessToken(userJwt), false);
|
||||
@@ -832,7 +816,13 @@ test("cookie exchange rejects guest IMS tokens", async () => {
|
||||
});
|
||||
|
||||
test("isAdobeTransientSubmitError detects 408 system under load", () => {
|
||||
assert.equal(isAdobeTransientSubmitError(408, '{"error_code":"timeout_error","message":"system under load"}'), true);
|
||||
assert.equal(
|
||||
isAdobeTransientSubmitError(
|
||||
408,
|
||||
'{"error_code":"timeout_error","message":"system under load"}'
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.equal(isAdobeTransientSubmitError(429, "rate"), true);
|
||||
assert.equal(isAdobeTransientSubmitError(400, "bad request"), false);
|
||||
assert.ok(generateAdobeNonce().length === 64);
|
||||
@@ -880,11 +870,7 @@ test("image submit retries on 408 then succeeds", async () => {
|
||||
if (submits < 3) {
|
||||
return jsonResponse(408, { error_code: "timeout_error", message: "system under load" });
|
||||
}
|
||||
return jsonResponse(
|
||||
200,
|
||||
{ links: { result: { href: "https://poll.example/job/r1" } } },
|
||||
{}
|
||||
);
|
||||
return jsonResponse(200, { links: { result: { href: "https://poll.example/job/r1" } } }, {});
|
||||
}
|
||||
if (u.includes("poll.example")) {
|
||||
return jsonResponse(200, {
|
||||
@@ -909,7 +895,11 @@ test("adobeFireflyGenerateImage cookie path exchanges IMS token first", async ()
|
||||
const userTok =
|
||||
`eyJhbGciOiJSUzI1NiJ9.` +
|
||||
Buffer.from(
|
||||
JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token", client_id: "clio-playground-web" })
|
||||
JSON.stringify({
|
||||
user_id: "0EB@AdobeID",
|
||||
type: "access_token",
|
||||
client_id: "clio-playground-web",
|
||||
})
|
||||
).toString("base64url") +
|
||||
`.` +
|
||||
"s".repeat(40);
|
||||
@@ -931,11 +921,7 @@ test("adobeFireflyGenerateImage cookie path exchanges IMS token first", async ()
|
||||
? (init.headers as Record<string, string>).Authorization
|
||||
: auth;
|
||||
assert.equal(headerAuth, `Bearer ${userTok}`);
|
||||
return jsonResponse(
|
||||
200,
|
||||
{},
|
||||
{ "x-override-status-link": "https://poll.example/job/c1" }
|
||||
);
|
||||
return jsonResponse(200, {}, { "x-override-status-link": "https://poll.example/job/c1" });
|
||||
}
|
||||
if (String(url).includes("poll.example")) {
|
||||
return jsonResponse(200, {
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
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";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-claude-rendering-"));
|
||||
const previousDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const { openaiResponsesToOpenAIResponse } =
|
||||
await import("../../open-sse/translator/response/openai-responses.ts");
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
const { createSSETransformStreamWithLogger } = await import("../../open-sse/utils/stream.ts");
|
||||
const { resetDbInstance } = await import("../../src/lib/db/core.ts");
|
||||
|
||||
test.after(() => {
|
||||
resetDbInstance();
|
||||
if (previousDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = previousDataDir;
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("Responses->Chat: output_item.done emits arguments when no delta chunks were sent", () => {
|
||||
const state = {
|
||||
@@ -38,7 +53,7 @@ test("Responses->Chat: output_item.done emits arguments when no delta chunks wer
|
||||
assert.equal(state.toolCallIndex, 1);
|
||||
});
|
||||
|
||||
test("Responses->Chat: output_item.done does not re-emit arguments already streamed via deltas", () => {
|
||||
test("Responses->Chat: buffered argument deltas emit once at output_item.done", () => {
|
||||
const state = {
|
||||
started: true,
|
||||
chatId: "chatcmpl-test",
|
||||
@@ -46,9 +61,19 @@ test("Responses->Chat: output_item.done does not re-emit arguments already strea
|
||||
toolCallIndex: 0,
|
||||
finishReasonSent: false,
|
||||
currentToolCallId: "call_abc",
|
||||
currentToolCallArgsBuffer: '{"query":"search"}',
|
||||
currentToolCallArgsBuffer: "",
|
||||
};
|
||||
|
||||
const deltaResult = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.function_call_arguments.delta",
|
||||
delta: '{"query":"search"}',
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
assert.equal(deltaResult, null);
|
||||
|
||||
const chunk = {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
@@ -62,7 +87,8 @@ test("Responses->Chat: output_item.done does not re-emit arguments already strea
|
||||
|
||||
const result = openaiResponsesToOpenAIResponse(chunk, state);
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.ok(result);
|
||||
assert.equal(result.choices[0].delta.tool_calls[0].function.arguments, '{"query":"search"}');
|
||||
assert.equal(state.toolCallIndex, 1);
|
||||
});
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ test("ServerSupervisor starts Node with IPv4-first DNS", async () => {
|
||||
|
||||
assert.deepEqual(spawnCalls, [
|
||||
{
|
||||
command: "node",
|
||||
command: process.execPath,
|
||||
args: ["--dns-result-order=ipv4first", "--max-old-space-size=2048", "/app/server.js"],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -80,6 +80,8 @@ test("#7849: the two-message pathological pair stays bounded", () => {
|
||||
Date.now() - started < 4000,
|
||||
"the pathological pair must stay fast; quadratic work would take seconds"
|
||||
);
|
||||
assert.strictEqual(result.body, body, "bounded processing must preserve the input body");
|
||||
assert.equal(result.compressed, false, "the non-deduplicable pair must fail open");
|
||||
assert.ok(Array.isArray((result.body as { messages?: unknown[] }).messages));
|
||||
});
|
||||
|
||||
|
||||
@@ -97,6 +97,21 @@ test("package.json files[] excludes nested node_modules from the published packa
|
||||
);
|
||||
});
|
||||
|
||||
test("build-next-isolated sibling imports are allowed in the published package", () => {
|
||||
const buildDependencies = [
|
||||
"scripts/build/assembleStandalone.mjs",
|
||||
"scripts/build/backendOnlyPages.mjs",
|
||||
"scripts/build/build-tproxy-native.mjs",
|
||||
];
|
||||
|
||||
const unexpectedPaths = findUnexpectedArtifactPaths(buildDependencies, {
|
||||
exactPaths: PACK_ARTIFACT_ALLOWED_EXACT_PATHS,
|
||||
prefixPaths: PACK_ARTIFACT_ALLOWED_PATH_PREFIXES,
|
||||
});
|
||||
|
||||
assert.deepEqual(unexpectedPaths, []);
|
||||
});
|
||||
|
||||
test("webdav-handler.mjs is allowed in staging dist/ (server-ws.mjs dependency, missed in 3.8.22 build)", () => {
|
||||
const unexpectedPaths = findUnexpectedArtifactPaths(["webdav-handler.mjs"], {
|
||||
exactPaths: APP_STAGING_ALLOWED_EXACT_PATHS,
|
||||
|
||||
@@ -212,7 +212,7 @@ test("#8969: mocked execute posts Chat Completions with Bearer, no Cookie, strip
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
const rawBody = typeof init?.body === "string" ? init.body : "{}";
|
||||
const rawBody = await new Request(input, init).text();
|
||||
seen.push({
|
||||
url: String(input),
|
||||
method: (init?.method || "GET").toUpperCase(),
|
||||
|
||||
167
tests/unit/quality-validation-benign-error.test.ts
Normal file
167
tests/unit/quality-validation-benign-error.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* TDD regression guard — quality validation false-positive on benign `error`
|
||||
* fields in streaming SSE chunks.
|
||||
*
|
||||
* `isStreamingUpstreamError` treats ANY non-null `error` field as an upstream
|
||||
* failure: `parsed.error != null` is true for `{}`, `""`, `false`, and `0`.
|
||||
* When a client like opencode issues a tool-call turn, the upstream SSE opens
|
||||
* with role-only frames (no recognized content) and a later chunk that carries
|
||||
* real tool_calls content PLUS a benign empty `error` field (a field some
|
||||
* backends emit on every chunk). The error gate runs BEFORE the content
|
||||
* recognizers, so that single frame short-circuits to "error" → 502
|
||||
* "streaming upstream error" — while the same combo via kilocode (different
|
||||
* wire format) never emits the empty `error` field and works fine.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { validateResponseQuality } = await import("../../open-sse/services/combo.ts");
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const silentLog = { warn: () => {} };
|
||||
|
||||
function openAiSseStream(events: string[]): ReadableStream<Uint8Array> {
|
||||
const body = events.join("\n") + "\n";
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(body));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI-compatible tool-call stream that ALSO carries a benign empty `error`
|
||||
* field on the tool_calls chunk. Some backends emit `"error": {}` or
|
||||
* `"error": ""` alongside every chunk; that is not a real upstream failure.
|
||||
* The frame must be treated as CONTENT (valid), not ERROR.
|
||||
*/
|
||||
function makeToolCallStreamWithBenignError(): Response {
|
||||
const events = [
|
||||
// role-only first chunk — no recognized content, widens the peek window
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_1",
|
||||
object: "chat.completion.chunk",
|
||||
created: 123,
|
||||
model: "gpt-4o",
|
||||
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||
})}`,
|
||||
"",
|
||||
// tool_calls delta + benign empty `error` field (the bug trigger)
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_2",
|
||||
object: "chat.completion.chunk",
|
||||
created: 123,
|
||||
model: "gpt-4o",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{ index: 0, id: "call_1", type: "function", function: { name: "Bash", arguments: "" } },
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
error: {},
|
||||
})}`,
|
||||
"",
|
||||
`data: [DONE]`,
|
||||
"",
|
||||
];
|
||||
return new Response(openAiSseStream(events), {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
test("OpenAI stream with tool_calls + benign empty error:{} field is VALID (not 502)", async () => {
|
||||
const res = makeToolCallStreamWithBenignError();
|
||||
const out = await validateResponseQuality(res, true, silentLog);
|
||||
assert.equal(
|
||||
out.valid,
|
||||
true,
|
||||
`expected valid for tool_calls chunk with benign error:{}, got valid=false (reason: ${out.reason})`
|
||||
);
|
||||
assert.ok(out.clonedResponse, "clonedResponse must be present for valid streaming response");
|
||||
});
|
||||
|
||||
test("OpenAI stream with tool_calls + benign empty error:'' field is VALID", async () => {
|
||||
const events = [
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_3",
|
||||
object: "chat.completion.chunk",
|
||||
created: 123,
|
||||
model: "gpt-4o",
|
||||
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||
})}`,
|
||||
"",
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_4",
|
||||
object: "chat.completion.chunk",
|
||||
created: 123,
|
||||
model: "gpt-4o",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{ index: 0, id: "call_2", type: "function", function: { name: "Read", arguments: "" } },
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
error: "",
|
||||
})}`,
|
||||
"",
|
||||
`data: [DONE]`,
|
||||
"",
|
||||
];
|
||||
const res = new Response(openAiSseStream(events), {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
const out = await validateResponseQuality(res, true, silentLog);
|
||||
assert.equal(
|
||||
out.valid,
|
||||
true,
|
||||
`expected valid for tool_calls chunk with benign error:"", got valid=false (reason: ${out.reason})`
|
||||
);
|
||||
});
|
||||
|
||||
test("Stream with a REAL non-empty error object is still flagged as invalid", async () => {
|
||||
const events = [
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_5",
|
||||
object: "chat.completion.chunk",
|
||||
created: 123,
|
||||
model: "gpt-4o",
|
||||
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||
})}`,
|
||||
"",
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_6",
|
||||
object: "chat.completion.chunk",
|
||||
created: 123,
|
||||
model: "gpt-4o",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: null }],
|
||||
error: { message: "upstream quota exceeded", code: "rate_limit_exceeded" },
|
||||
})}`,
|
||||
"",
|
||||
`data: [DONE]`,
|
||||
"",
|
||||
];
|
||||
const res = new Response(openAiSseStream(events), {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
const out = await validateResponseQuality(res, true, silentLog);
|
||||
assert.equal(
|
||||
out.valid,
|
||||
false,
|
||||
`expected invalid for real error object, got valid=true (reason: ${out.reason})`
|
||||
);
|
||||
assert.match(out.reason ?? "", /streaming upstream error/, "reason should mention the upstream error");
|
||||
});
|
||||
@@ -26,6 +26,12 @@ function wait(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Leave enough scheduling headroom for a loaded CI/devbox while keeping the
|
||||
// executing callback longer than the queue-only budget. The actual queued-job
|
||||
// case stays short because it controls dispatch deterministically.
|
||||
const DISPATCHED_QUEUE_BUDGET_MS = 2_000;
|
||||
const QUEUED_QUEUE_BUDGET_MS = 250;
|
||||
|
||||
test.afterEach(async () => {
|
||||
await rateLimitManager.__resetRateLimitManagerForTests();
|
||||
});
|
||||
@@ -43,14 +49,23 @@ async function triggerQueueTimeout() {
|
||||
concurrentRequests: 1,
|
||||
requestsPerMinute: 100000,
|
||||
minTimeBetweenRequestsMs: 0,
|
||||
maxWaitMs: 40,
|
||||
maxWaitMs: DISPATCHED_QUEUE_BUDGET_MS,
|
||||
});
|
||||
rateLimitManager.enableRateLimitProtection("conn-queue-timeout");
|
||||
const connectionId = "conn-dispatched-timeout";
|
||||
rateLimitManager.enableRateLimitProtection(connectionId);
|
||||
|
||||
return rateLimitManager.withRateLimit("openai", "conn-queue-timeout", "gpt-4o", async () => {
|
||||
await wait(400); // > maxWaitMs (40ms) → Bottleneck fails the job
|
||||
return "should-not-reach";
|
||||
});
|
||||
let dispatched = false;
|
||||
const result = await rateLimitManager.withRateLimit(
|
||||
"test-provider",
|
||||
connectionId,
|
||||
null,
|
||||
async () => {
|
||||
dispatched = true;
|
||||
await wait(DISPATCHED_QUEUE_BUDGET_MS + 250);
|
||||
return "should-not-reach";
|
||||
}
|
||||
);
|
||||
return { dispatched, result };
|
||||
}
|
||||
|
||||
async function triggerQueuedTimeout() {
|
||||
@@ -60,7 +75,7 @@ async function triggerQueuedTimeout() {
|
||||
concurrentRequests: 1,
|
||||
requestsPerMinute: 0,
|
||||
minTimeBetweenRequestsMs: 0,
|
||||
maxWaitMs: 40,
|
||||
maxWaitMs: QUEUED_QUEUE_BUDGET_MS,
|
||||
});
|
||||
const connectionId = "conn-queued-timeout";
|
||||
rateLimitManager.enableRateLimitProtection(connectionId);
|
||||
@@ -79,13 +94,12 @@ async function triggerQueuedTimeout() {
|
||||
await firstExecuting;
|
||||
|
||||
let caught: unknown;
|
||||
let queuedDispatched = false;
|
||||
try {
|
||||
await rateLimitManager.withRateLimit(
|
||||
"test-provider",
|
||||
connectionId,
|
||||
null,
|
||||
async () => "should-not-dispatch"
|
||||
);
|
||||
await rateLimitManager.withRateLimit("test-provider", connectionId, null, async () => {
|
||||
queuedDispatched = true;
|
||||
return "should-not-dispatch";
|
||||
});
|
||||
assert.fail("expected the queued job to expire");
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
@@ -93,16 +107,20 @@ async function triggerQueuedTimeout() {
|
||||
releaseFirst();
|
||||
await first;
|
||||
}
|
||||
return caught;
|
||||
return { caught, queuedDispatched };
|
||||
}
|
||||
|
||||
test("#4165 a dispatched provider call is not killed by the queue budget", async () => {
|
||||
const result = await triggerQueueTimeout();
|
||||
assert.equal(result, "should-not-reach");
|
||||
const execution = await triggerQueueTimeout();
|
||||
assert.equal(execution.dispatched, true, "the callback must enter execution");
|
||||
assert.equal(execution.result, "should-not-reach");
|
||||
});
|
||||
|
||||
test("#4165 queue expiry surfaces a clear local error", async () => {
|
||||
const caught = (await triggerQueuedTimeout()) as Error & { code?: string };
|
||||
const result = await triggerQueuedTimeout();
|
||||
assert.ok(result.caught instanceof Error, "queue expiry must reject with an Error");
|
||||
assert.equal(result.queuedDispatched, false, "an expired queued callback must never dispatch");
|
||||
const caught = result.caught as Error & { code?: string };
|
||||
assert.equal(caught.code, "RATE_LIMIT_QUEUE_TIMEOUT");
|
||||
assert.match(caught.message, /maxWaitMs/);
|
||||
assert.match(caught.message, /not an upstream/i);
|
||||
|
||||
53
tests/unit/repro-7754.test.ts
Normal file
53
tests/unit/repro-7754.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createBuiltinAutoCombo } from "@omniroute/open-sse/services/autoCombo/builtinCatalog.ts";
|
||||
|
||||
// #7754: `auto/best-free` combo name must never leak downstream as the model id.
|
||||
// When the free-tier candidate pool resolves non-empty, every model in the combo
|
||||
// must carry a concrete `<provider>/<model>` id — never the literal combo name.
|
||||
|
||||
test("#7754 auto/best-free never leaks the combo name as a model", async () => {
|
||||
const combo = await createBuiltinAutoCombo("auto/best-free", "best-free");
|
||||
const models = combo.models || [];
|
||||
|
||||
// The combo id is the modelStr by design (routing resolves it back), but the
|
||||
// models array must never contain it as a target model.
|
||||
const leak = models.filter(
|
||||
(m) =>
|
||||
(m.id || "") === "auto/best-free" ||
|
||||
(m.model || "") === "auto/best-free" ||
|
||||
(m.modelStr || "") === "auto/best-free"
|
||||
);
|
||||
assert.equal(leak.length, 0, `combo name leaked as a target model: ${JSON.stringify(leak)}`);
|
||||
});
|
||||
|
||||
test("#7754 every auto/best-free model carries a concrete provider/model", async () => {
|
||||
const combo = await createBuiltinAutoCombo("auto/best-free", "best-free");
|
||||
const models = combo.models || [];
|
||||
for (const m of models) {
|
||||
assert.ok(
|
||||
m.model && m.model !== "auto/best-free",
|
||||
`model missing concrete id: ${JSON.stringify(m)}`
|
||||
);
|
||||
assert.ok(
|
||||
m.providerId && m.providerId !== "auto",
|
||||
`model missing concrete provider: ${JSON.stringify(m)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("#7754 empty free-tier pool degrades with a clear 503, not a name leak", async () => {
|
||||
// When NO free-tier candidate exists, createBuiltinAutoCombo must either
|
||||
// return an empty models[] (which the #6458 route check converts to a clear
|
||||
// 503) or throw — never synthesize a target whose model is the combo name.
|
||||
const combo = await createBuiltinAutoCombo("auto/best-free", "best-free");
|
||||
const models = combo.models || [];
|
||||
if (models.length === 0) {
|
||||
// Empty pool is fine — the route layer (#6458) converts it to a clear 503.
|
||||
assert.equal(combo.candidatePool?.length || 0, 0);
|
||||
} else {
|
||||
// Non-empty pool must not leak.
|
||||
const leak = models.filter((m) => (m.model || "") === "auto/best-free");
|
||||
assert.equal(leak.length, 0);
|
||||
}
|
||||
});
|
||||
@@ -9,15 +9,29 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(__dirname, "../..");
|
||||
const WORKFLOW = resolve(repoRoot, ".github/workflows/quality.yml");
|
||||
|
||||
function loadWorkflow(): any {
|
||||
return parse(readFileSync(WORKFLOW, "utf8"));
|
||||
interface WorkflowStep {
|
||||
name?: string;
|
||||
run?: string;
|
||||
"continue-on-error"?: boolean;
|
||||
}
|
||||
|
||||
interface WorkflowDocument {
|
||||
jobs?: Record<string, { steps?: WorkflowStep[] }>;
|
||||
}
|
||||
|
||||
function loadWorkflow(): WorkflowDocument {
|
||||
return parse(readFileSync(WORKFLOW, "utf8")) as WorkflowDocument;
|
||||
}
|
||||
|
||||
function invokesGate(run: string): boolean {
|
||||
if (!run) return false;
|
||||
return /npm run (check:|typecheck:)/.test(run) || /npm run "check/.test(run) || /npm run \\"check/.test(run);
|
||||
return (
|
||||
/npm run (check:|typecheck:)/.test(run) ||
|
||||
/npm run "check/.test(run) ||
|
||||
/npm run \\"check/.test(run)
|
||||
);
|
||||
}
|
||||
function stepCanFail(step: any): boolean {
|
||||
function stepCanFail(step: WorkflowStep): boolean {
|
||||
return step?.["continue-on-error"] !== true;
|
||||
}
|
||||
|
||||
@@ -25,7 +39,7 @@ test("repro #8542: fast-gates must not fail-fast into a later gate", () => {
|
||||
const wf = loadWorkflow();
|
||||
const job = wf.jobs?.["fast-gates"];
|
||||
assert.ok(job, "fast-gates job must exist");
|
||||
const steps: any[] = job.steps ?? [];
|
||||
const steps: WorkflowStep[] = job.steps ?? [];
|
||||
assert.ok(steps.length >= 5, `fast-gates must have >=5 steps, got ${steps.length}`);
|
||||
|
||||
const gateSteps = steps.map((s, i) => ({ s, i })).filter(({ s }) => invokesGate(s?.run ?? ""));
|
||||
@@ -51,4 +65,4 @@ test("repro #8542: fast-gates must not fail-fast into a later gate", () => {
|
||||
maskedPairs.slice(0, 12).join("\n") +
|
||||
(maskedPairs.length > 12 ? `\n... (+${maskedPairs.length - 12} more)` : "")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
70
tests/unit/repro-8847.test.ts
Normal file
70
tests/unit/repro-8847.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
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 { syncStandaloneNativeAssets } from "../../scripts/build/assembleStandalone.mjs";
|
||||
|
||||
/**
|
||||
* Repro #8847: better-sqlite3 prebuilds are not included in the standalone
|
||||
* bundle, so the bundled app fails when the platform's prebuild is needed
|
||||
* (e.g. under Bun, which resolves the native binary via prebuilds/ rather
|
||||
* than build/Release/).
|
||||
*
|
||||
* The test creates a synthetic node_modules/better-sqlite3/ tree with both
|
||||
* the compiled build/Release/ binary AND the prebuilds/ directory, then
|
||||
* confirms that syncStandaloneNativeAssets copies both into the standalone
|
||||
* output. On the unfixed code this fails because NATIVE_ASSET_ENTRIES only
|
||||
* lists better-sqlite3/build/.
|
||||
*/
|
||||
test("repro-8847: better-sqlite3 prebuilds are bundled alongside the compiled binary", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "repro-8847-"));
|
||||
const projectRoot = path.join(tmp, "src-root");
|
||||
|
||||
// Seed better-sqlite3 with both build/Release/ and prebuilds/.
|
||||
const bsqlDir = path.join(projectRoot, "node_modules", "better-sqlite3");
|
||||
fs.mkdirSync(path.join(bsqlDir, "build", "Release"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(bsqlDir, "build", "Release", "better_sqlite3.node"),
|
||||
"// native binary placeholder"
|
||||
);
|
||||
fs.mkdirSync(path.join(bsqlDir, "prebuilds"), { recursive: true });
|
||||
for (const target of [
|
||||
"darwin-arm64.node",
|
||||
"darwin-x64.node",
|
||||
"linux-arm64.node",
|
||||
"linux-x64.node",
|
||||
"linuxmusl-arm64.node",
|
||||
"linuxmusl-x64.node",
|
||||
"win32-arm64.node",
|
||||
"win32-x64.node",
|
||||
]) {
|
||||
fs.writeFileSync(path.join(bsqlDir, "prebuilds", target), `// ${target}`);
|
||||
}
|
||||
|
||||
const outDir = path.join(tmp, "standalone");
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
// Act: copy native assets into the standalone output.
|
||||
await syncStandaloneNativeAssets(projectRoot, fs.promises, { log() {} }, outDir);
|
||||
|
||||
// Assert: the compiled build/Release/ binary was copied.
|
||||
assert.ok(
|
||||
fs.existsSync(
|
||||
path.join(outDir, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node")
|
||||
),
|
||||
"compiled native binary (build/Release/) must be in the standalone bundle"
|
||||
);
|
||||
|
||||
// Assert: the prebuilds/ directory was also copied.
|
||||
const prebuildsDir = path.join(outDir, "node_modules", "better-sqlite3", "prebuilds");
|
||||
assert.ok(fs.existsSync(prebuildsDir), "prebuilds/ directory must be in the standalone bundle");
|
||||
|
||||
// Assert: at least one prebuild file was copied.
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(prebuildsDir, "linux-x64.node")),
|
||||
"linux-x64 prebuild must be in the standalone bundle"
|
||||
);
|
||||
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
111
tests/unit/repro-9156.test.ts
Normal file
111
tests/unit/repro-9156.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// #9156: macOS launchd autostart fails because the supervisor spawns the child
|
||||
// with bare "node", but launchd's PATH cannot resolve it. process.execPath is
|
||||
// always the absolute path to the running Node.js binary and is always resolvable.
|
||||
//
|
||||
// We verify the fix via:
|
||||
// 1. Static source analysis — the spawn() call must use process.execPath
|
||||
// unconditionally (no fallback to bare "node"). This runs without any
|
||||
// experimental flags so it serves as the permanent regression guard.
|
||||
// 2. Runtime test via mock.module (requires --experimental-test-module-mocks)
|
||||
// that captures the actual spawn arguments.
|
||||
|
||||
const __filename = new URL(import.meta.url).pathname;
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const SUPERVISOR_PATH = path.resolve(
|
||||
__dirname,
|
||||
"../../bin/cli/runtime/processSupervisor.mjs"
|
||||
);
|
||||
const supervisorSrc = fs.readFileSync(SUPERVISOR_PATH, "utf8");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Source-level verification (no experimental flag required)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("spawn() uses process.execPath unconditionally, no bare 'node' fallback (#9156)", () => {
|
||||
// Must NOT contain the old conditional that falls back to bare "node"
|
||||
assert.ok(
|
||||
!supervisorSrc.includes('process.versions.bun ? process.execPath : "node"'),
|
||||
"must NOT have a conditional fallback to bare 'node'"
|
||||
);
|
||||
|
||||
// Must use process.execPath as the first argument to spawn()
|
||||
const execPathPattern = /spawn\(\s*process\.execPath\s*,/;
|
||||
assert.ok(
|
||||
execPathPattern.test(supervisorSrc),
|
||||
"spawn() must receive process.execPath as first argument"
|
||||
);
|
||||
});
|
||||
|
||||
test("process.execPath is an absolute path to the running Node.js binary", () => {
|
||||
assert.ok(
|
||||
path.isAbsolute(process.execPath),
|
||||
`process.execPath must be absolute, got: ${process.execPath}`
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(process.execPath),
|
||||
`process.execPath must exist: ${process.execPath}`
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Runtime test via mock.module (requires --experimental-test-module-mocks)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Run manually: node --experimental-test-module-mocks --import tsx/esm --test tests/unit/repro-9156.test.ts
|
||||
|
||||
import { mock } from "node:test";
|
||||
|
||||
if (typeof mock.module === "function") {
|
||||
test("(runtime) ServerSupervisor.start() spawns with process.execPath (#9156)", async () => {
|
||||
let spawnExecutable: string | undefined;
|
||||
const { EventEmitter } = await import("node:events");
|
||||
|
||||
const mockChild = Object.assign(new EventEmitter(), {
|
||||
pid: 12345,
|
||||
stdout: null,
|
||||
stderr: null,
|
||||
kill: () => {},
|
||||
});
|
||||
|
||||
mock.module("node:child_process", {
|
||||
exports: {
|
||||
spawn: (...args: unknown[]) => {
|
||||
spawnExecutable = args[0] as string;
|
||||
return mockChild;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
process.env.PORT = "0";
|
||||
|
||||
const { ServerSupervisor } = await import(
|
||||
"../../bin/cli/runtime/processSupervisor.mjs"
|
||||
);
|
||||
|
||||
const supervisor = new ServerSupervisor({
|
||||
serverPath: "/fake/server.js",
|
||||
env: {},
|
||||
maxRestarts: 0,
|
||||
});
|
||||
|
||||
spawnExecutable = undefined;
|
||||
supervisor.start();
|
||||
|
||||
assert.ok(spawnExecutable, "spawn() must have been called");
|
||||
assert.equal(
|
||||
spawnExecutable,
|
||||
process.execPath,
|
||||
`expected process.execPath, got: ${spawnExecutable}`
|
||||
);
|
||||
assert.notEqual(spawnExecutable, "node", "must not be bare 'node'");
|
||||
|
||||
mockChild.removeAllListeners();
|
||||
delete process.env.PORT;
|
||||
});
|
||||
}
|
||||
71
tests/unit/repro-9486.test.ts
Normal file
71
tests/unit/repro-9486.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Issue #9486 — Anthropic OAuth returns HTTP 400 with "out of extra usage" in
|
||||
* the error body when a tool-carrying request exceeds the account's usage quota.
|
||||
* This should be classified as quota_exhausted (not generic bad_request), so the
|
||||
* account fallback mechanism applies a proper cooldown and combo routing can
|
||||
* skip to another target.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { matchErrorRuleByText, findMatchingErrorRule, ERROR_RULES } =
|
||||
await import("../../open-sse/config/errorConfig.ts");
|
||||
const { checkFallbackError, classifyErrorText } =
|
||||
await import("../../open-sse/services/accountFallback.ts");
|
||||
const { RateLimitReason } = await import("../../open-sse/config/constants.ts");
|
||||
|
||||
test("#9486 ERROR_RULES has a text rule for 'out of extra usage' → quota_exhausted", () => {
|
||||
const rule = ERROR_RULES.find((r) => r.text === "out of extra usage");
|
||||
assert.ok(rule, "expected a rule for 'out of extra usage'");
|
||||
assert.equal(rule!.reason, "quota_exhausted");
|
||||
// Should use backoff so the fallback path applies exponential scaling
|
||||
assert.equal(rule!.backoff, true);
|
||||
});
|
||||
|
||||
test("#9486 matchErrorRuleByText finds 'out of extra usage' rule", () => {
|
||||
const rule = matchErrorRuleByText("out of extra usage");
|
||||
assert.ok(rule, "expected a matching rule");
|
||||
assert.equal(rule!.reason, "quota_exhausted");
|
||||
});
|
||||
|
||||
test("#9486 matchErrorRuleByText finds rule in a longer error message", () => {
|
||||
const rule = matchErrorRuleByText(
|
||||
"Error: 400 - out of extra usage. You have exceeded your usage quota for this billing period."
|
||||
);
|
||||
assert.ok(rule, "expected a matching rule from longer message");
|
||||
assert.equal(rule!.reason, "quota_exhausted");
|
||||
});
|
||||
|
||||
test("#9486 findMatchingErrorRule with 400 + 'out of extra usage' returns quota_exhausted", () => {
|
||||
const rule = findMatchingErrorRule(400, "out of extra usage");
|
||||
assert.ok(rule, "expected a matching rule");
|
||||
assert.equal(rule!.reason, "quota_exhausted");
|
||||
});
|
||||
|
||||
test("#9486 checkFallbackError returns quota_exhausted for 400 + 'out of extra usage'", () => {
|
||||
const out = checkFallbackError(400, "out of extra usage", 0, null, "claude");
|
||||
assert.equal(out.shouldFallback, true);
|
||||
assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
// Should get a non-zero cooldown (quota exhaustion is not transient)
|
||||
assert.ok(out.cooldownMs > 0, `expected positive cooldown, got ${out.cooldownMs}ms`);
|
||||
});
|
||||
|
||||
test("#9486 checkFallbackError handles 'Extra usage required' (same class)", () => {
|
||||
// Anthropic sometimes returns "Extra usage required" instead of "out of extra usage"
|
||||
const out = checkFallbackError(400, "Extra usage required", 0, null, "claude");
|
||||
assert.equal(out.shouldFallback, true);
|
||||
assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
});
|
||||
|
||||
test("#9486 classifyErrorText flags 'out of extra usage' as QUOTA_EXHAUSTED", () => {
|
||||
const out = classifyErrorText("out of extra usage");
|
||||
assert.equal(out, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
});
|
||||
|
||||
test("#9486 generic 400 without quota text still gets no fallback (regression guard)", () => {
|
||||
// Regression guard: a plain 400 with no quota-related text must NOT trigger
|
||||
// fallback, preserving the existing behavior for non-quota 400 errors.
|
||||
const out = checkFallbackError(400, "Bad request: invalid JSON", 0, null, "claude");
|
||||
assert.equal(out.shouldFallback, false);
|
||||
assert.equal(out.reason, RateLimitReason.UNKNOWN);
|
||||
});
|
||||
52
tests/unit/repro-9623.test.ts
Normal file
52
tests/unit/repro-9623.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// #9623: Failed connection test leaves testStatus=error with no recovery path.
|
||||
// Previously the route wrote testStatus:"error" + rateLimitedUntil:null, which the
|
||||
// lazy-recovery cooldown filter never matches (it only skips FUTURE rateLimitedUntil),
|
||||
// leaving the connection permanently unavailable after a transient outage.
|
||||
// Fix: non-terminal test failures now get a short future cooldown (30s) so they recover.
|
||||
|
||||
test("#9623 fix: non-terminal test failure sets a future rateLimitedUntil", () => {
|
||||
// Simulate the fixed updateData logic
|
||||
const now = Date.now();
|
||||
const terminalTestStatuses = new Set(["banned", "expired", "credits_exhausted"]);
|
||||
const valid = false;
|
||||
const diagnosis = { code: "network_error", type: "upstream" }; // non-terminal
|
||||
const isTerminalFailure = terminalTestStatuses.has(String(diagnosis.code).toLowerCase());
|
||||
const testFailureCooldownMs = 30_000;
|
||||
|
||||
const rateLimitedUntil =
|
||||
valid || isTerminalFailure
|
||||
? valid
|
||||
? null
|
||||
: null
|
||||
: new Date(now + testFailureCooldownMs).toISOString();
|
||||
|
||||
assert.ok(
|
||||
rateLimitedUntil !== null,
|
||||
"non-terminal failure should set a future rateLimitedUntil"
|
||||
);
|
||||
const cooldownTime = new Date(rateLimitedUntil as string).getTime();
|
||||
assert.ok(
|
||||
cooldownTime > now,
|
||||
"rateLimitedUntil must be in the future so the lazy-recovery path retries"
|
||||
);
|
||||
assert.ok(
|
||||
cooldownTime <= now + 30_000,
|
||||
"cooldown should be bounded (30s)"
|
||||
);
|
||||
});
|
||||
|
||||
test("#9623 guard: terminal failures stay terminal (no fake recovery)", () => {
|
||||
const terminalTestStatuses = new Set(["banned", "expired", "credits_exhausted"]);
|
||||
const diagnosis = { code: "banned", type: "terminal" };
|
||||
const isTerminalFailure = terminalTestStatuses.has(String(diagnosis.code).toLowerCase());
|
||||
assert.equal(isTerminalFailure, true, "banned must be terminal");
|
||||
});
|
||||
|
||||
test("#9623: success resets cooldown to null", () => {
|
||||
const valid = true;
|
||||
const rateLimitedUntil = valid ? null : new Date(Date.now() + 30_000).toISOString();
|
||||
assert.equal(rateLimitedUntil, null, "successful test clears cooldown");
|
||||
});
|
||||
52
tests/unit/repro-9624.test.ts
Normal file
52
tests/unit/repro-9624.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it } from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const INSTRUMENTATION_NODE_PATH = resolve(
|
||||
__dirname,
|
||||
"../../src/instrumentation-node.ts"
|
||||
);
|
||||
|
||||
describe("repro-9624: startCleanupScheduler wired in Next.js startup path", () => {
|
||||
it("should import startCleanupScheduler from cleanup", () => {
|
||||
const source = readFileSync(INSTRUMENTATION_NODE_PATH, "utf-8");
|
||||
|
||||
// instrumentation-node.ts loads all startup modules via dynamic imports in a
|
||||
// Promise.all destructure, e.g.:
|
||||
// const [{ startCleanupScheduler }, ...] = await Promise.all([
|
||||
// import("@/lib/db/cleanup"), ...
|
||||
// ]);
|
||||
// So the binding and the module import appear separately in the file.
|
||||
const cleanupModuleImported = /import\(\s*["']@\/lib\/db\/cleanup["']\s*\)/.test(
|
||||
source
|
||||
);
|
||||
const schedulerBound = /\bstartCleanupScheduler\b/.test(source);
|
||||
|
||||
assert.ok(
|
||||
cleanupModuleImported,
|
||||
"@/lib/db/cleanup should be imported (dynamic import) in instrumentation-node.ts"
|
||||
);
|
||||
assert.ok(
|
||||
schedulerBound,
|
||||
"startCleanupScheduler should be bound in instrumentation-node.ts"
|
||||
);
|
||||
});
|
||||
|
||||
it("should call startCleanupScheduler() during startup", () => {
|
||||
const source = readFileSync(INSTRUMENTATION_NODE_PATH, "utf-8");
|
||||
|
||||
// Check that startCleanupScheduler is called (as a function call).
|
||||
// It can be called directly or as part of a conditional.
|
||||
const hasCall = /\bstartCleanupScheduler\s*\(/.test(source);
|
||||
|
||||
assert.ok(
|
||||
hasCall,
|
||||
"startCleanupScheduler() should be called in instrumentation-node.ts"
|
||||
);
|
||||
});
|
||||
});
|
||||
92
tests/unit/repro-9625.test.ts
Normal file
92
tests/unit/repro-9625.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Issue #9625 — domain_cost_history cleanup cutoff unit mismatch.
|
||||
*
|
||||
* cleanupDomainCostHistory() computes the cutoff in epoch seconds
|
||||
* (Math.floor(Date.now() / 1000)) but the timestamp column stores
|
||||
* epoch milliseconds (Date.now()), as inserted by saveCostEntry().
|
||||
*
|
||||
* This test seeds data using the same format as the production code
|
||||
* (milliseconds), then asserts that cleanupDomainCostHistory() correctly
|
||||
* deletes rows older than the retention window.
|
||||
*
|
||||
* Before the fix, the cutoff in seconds was ~1000× smaller than the
|
||||
* stored timestamps, so the DELETE WHERE timestamp < cutoff would
|
||||
* never match old rows — the cleanup was effectively a no-op.
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-9625-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const { cleanupDomainCostHistory } = await import("../../src/lib/db/cleanup.ts");
|
||||
const { getDbInstance, resetDbInstance } = await import("../../src/lib/db/core.ts");
|
||||
|
||||
test.after(() => {
|
||||
resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const DAY_MS = 86_400_000; // milliseconds
|
||||
|
||||
test("#9625 cleanupDomainCostHistory: cutoff in ms matches production timestamps", async () => {
|
||||
const db = getDbInstance()!;
|
||||
const now = Date.now(); // milliseconds — same as saveCostEntry() default
|
||||
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO domain_cost_history (api_key_id, cost, timestamp) VALUES (?, ?, ?)"
|
||||
);
|
||||
|
||||
// Seed data using millisecond timestamps (production format).
|
||||
// 3 old rows: 40 days ago (should be deleted)
|
||||
// 2 recent rows: 5 days ago (should be kept)
|
||||
insert.run("key1", 1.0, now - 40 * DAY_MS);
|
||||
insert.run("key1", 2.0, now - 40 * DAY_MS);
|
||||
insert.run("key1", 3.0, now - 40 * DAY_MS);
|
||||
insert.run("key1", 4.0, now - 5 * DAY_MS);
|
||||
insert.run("key1", 5.0, now - 5 * DAY_MS);
|
||||
|
||||
const result = await cleanupDomainCostHistory();
|
||||
|
||||
// Before the fix, cutoff was in seconds (~1.7e9) while timestamps
|
||||
// are in milliseconds (~1.7e12). The comparison `WHERE ts < 1.7e9`
|
||||
// would never match rows with ts ~1.7e12, so nothing was deleted.
|
||||
assert.strictEqual(result.deleted, 3, "Should delete 3 old rows (40 days old)");
|
||||
assert.strictEqual(result.errors, 0);
|
||||
|
||||
const remaining = db.prepare("SELECT COUNT(*) as cnt FROM domain_cost_history").get() as {
|
||||
cnt: number;
|
||||
};
|
||||
assert.strictEqual(remaining.cnt, 2, "Should keep 2 recent rows (5 days old)");
|
||||
});
|
||||
|
||||
test("#9625 unit mismatch: seconds cutoff would NOT match ms timestamps", () => {
|
||||
// Demonstrate the arithmetic bug: a cutoff in seconds is ~1000×
|
||||
// smaller than a millisecond timestamp, so the WHERE clause never
|
||||
// matches production data.
|
||||
const nowMs = Date.now();
|
||||
const nowSec = Math.floor(nowMs / 1000);
|
||||
const retentionDays = 30;
|
||||
const cutoffSec = nowSec - retentionDays * 86_400; // seconds
|
||||
const cutoffMs = nowMs - retentionDays * 86_400_000; // milliseconds
|
||||
|
||||
// A row inserted 40 days ago with a millisecond timestamp:
|
||||
const oldRowMs = nowMs - 40 * 86_400_000; // ~1.7e12
|
||||
|
||||
// With seconds cutoff: oldRowMs (1.7e12) < cutoffSec (1.7e9) is FALSE
|
||||
// because 1.7e12 > 1.7e9 — the row is never matched.
|
||||
assert.ok(
|
||||
oldRowMs > cutoffSec,
|
||||
"Bug: ms timestamp is NOT less than seconds cutoff, so row is never deleted"
|
||||
);
|
||||
|
||||
// With milliseconds cutoff: oldRowMs (1.7e12) < cutoffMs (1.7e12) is TRUE
|
||||
assert.ok(
|
||||
oldRowMs < cutoffMs,
|
||||
"Fix: ms timestamp IS less than ms cutoff, so row is correctly deleted"
|
||||
);
|
||||
});
|
||||
27
tests/unit/repro-9633.test.ts
Normal file
27
tests/unit/repro-9633.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const pkg = JSON.parse(readFileSync("package.json", "utf-8"));
|
||||
const files = pkg.files || [];
|
||||
|
||||
// #9633: `build-next-isolated.mjs` is published (fixed in #1126), but three of
|
||||
// its sibling modules it imports were missing from the `files` whitelist, so
|
||||
// `npm run build` on a globally-installed package crashed with ERR_MODULE_NOT_FOUND.
|
||||
// The dynamic import of `build-tproxy-native.mjs` (~line 308) and the static
|
||||
// imports of `assembleStandalone.mjs` / `backendOnlyPages.mjs` must ship too.
|
||||
const NEEDED = [
|
||||
"scripts/build/assembleStandalone.mjs",
|
||||
"scripts/build/backendOnlyPages.mjs",
|
||||
"scripts/build/build-tproxy-native.mjs",
|
||||
"scripts/build/colocateOptionals.mjs",
|
||||
];
|
||||
|
||||
test("#9633: build-next-isolated.mjs sibling imports present in package.json files[]", () => {
|
||||
for (const needed of NEEDED) {
|
||||
assert.ok(
|
||||
files.some((f) => typeof f === "string" && f === needed),
|
||||
`${needed} is not in package.json files[]`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -452,7 +452,13 @@ test("v1 search POST returns 400 when auto-select finds no configured provider (
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(capturedUrl, "", "fallback-only SearXNG must not receive an upstream request");
|
||||
assert.ok(body.error?.message || body.error);
|
||||
assert.match(
|
||||
String(body.error?.message ?? body.error),
|
||||
/provider|configured/i,
|
||||
"the response must explain that no provider was selected"
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@ test.after(() => {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const DAY = 86_400; // seconds
|
||||
const DAY_SECONDS = 86_400;
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/** Ensure compression_run_telemetry table exists (created lazily in production). */
|
||||
function ensureTelemetryTable(): void {
|
||||
@@ -71,21 +72,34 @@ function ensureTelemetryTable(): void {
|
||||
`);
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
ensureTelemetryTable();
|
||||
const db = getDbInstance()!;
|
||||
for (const table of [
|
||||
"domain_cost_history",
|
||||
"compression_cache_stats",
|
||||
"xp_audit_log",
|
||||
"compression_run_telemetry",
|
||||
]) {
|
||||
db.exec(`DELETE FROM ${table}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
test("#6848 cleanupDomainCostHistory: deletes rows older than retention window", async () => {
|
||||
const db = getDbInstance()!;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const now = Date.now();
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO domain_cost_history (api_key_id, cost, timestamp) VALUES (?, ?, ?)"
|
||||
);
|
||||
|
||||
// 3 old (40 days ago), 2 recent (5 days ago)
|
||||
insert.run("key1", 1.0, now - 40 * DAY);
|
||||
insert.run("key1", 2.0, now - 40 * DAY);
|
||||
insert.run("key1", 3.0, now - 40 * DAY);
|
||||
insert.run("key1", 4.0, now - 5 * DAY);
|
||||
insert.run("key1", 5.0, now - 5 * DAY);
|
||||
insert.run("key1", 1.0, now - 40 * DAY_MS);
|
||||
insert.run("key1", 2.0, now - 40 * DAY_MS);
|
||||
insert.run("key1", 3.0, now - 40 * DAY_MS);
|
||||
insert.run("key1", 4.0, now - 5 * DAY_MS);
|
||||
insert.run("key1", 5.0, now - 5 * DAY_MS);
|
||||
|
||||
const result = await cleanupDomainCostHistory();
|
||||
|
||||
@@ -100,8 +114,8 @@ test("#6848 cleanupDomainCostHistory: deletes rows older than retention window",
|
||||
|
||||
test("#6848 cleanupCompressionCacheStats: deletes rows older than retention window", async () => {
|
||||
const db = getDbInstance()!;
|
||||
const oldDate = new Date(Date.now() - 40 * DAY * 1000).toISOString();
|
||||
const recentDate = new Date(Date.now() - 5 * DAY * 1000).toISOString();
|
||||
const oldDate = new Date(Date.now() - 40 * DAY_MS).toISOString();
|
||||
const recentDate = new Date(Date.now() - 5 * DAY_MS).toISOString();
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO compression_cache_stats (provider, compression_mode, created_at) VALUES (?, ?, ?)"
|
||||
);
|
||||
@@ -123,8 +137,8 @@ test("#6848 cleanupCompressionCacheStats: deletes rows older than retention wind
|
||||
|
||||
test("#6848 cleanupXpAuditLog: deletes rows older than retention window", async () => {
|
||||
const db = getDbInstance()!;
|
||||
const oldDate = new Date(Date.now() - 40 * DAY * 1000).toISOString();
|
||||
const recentDate = new Date(Date.now() - 5 * DAY * 1000).toISOString();
|
||||
const oldDate = new Date(Date.now() - 40 * DAY_MS).toISOString();
|
||||
const recentDate = new Date(Date.now() - 5 * DAY_MS).toISOString();
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO xp_audit_log (api_key_id, action, xp_earned, created_at) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
@@ -146,14 +160,15 @@ test("#6848 cleanupXpAuditLog: deletes rows older than retention window", async
|
||||
test("#6848 cleanupCompressionRunTelemetry: deletes rows older than retention window", async () => {
|
||||
ensureTelemetryTable();
|
||||
const db = getDbInstance()!;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const now = Date.now();
|
||||
const nowSeconds = Math.floor(now / 1000);
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO compression_run_telemetry (timestamp, tokens_before, tokens_after) VALUES (?, ?, ?)"
|
||||
);
|
||||
|
||||
insert.run(now - 40 * DAY, 1000, 500);
|
||||
insert.run(now - 40 * DAY, 2000, 800);
|
||||
insert.run(now - 5 * DAY, 1500, 600);
|
||||
insert.run(nowSeconds - 40 * DAY_SECONDS, 1000, 500);
|
||||
insert.run(nowSeconds - 40 * DAY_SECONDS, 2000, 800);
|
||||
insert.run(nowSeconds - 5 * DAY_SECONDS, 1500, 600);
|
||||
|
||||
const result = await cleanupCompressionRunTelemetry();
|
||||
|
||||
@@ -169,13 +184,14 @@ test("#6848 cleanupCompressionRunTelemetry: deletes rows older than retention wi
|
||||
test("#6848 no rows deleted when all data is within retention window (calls all 4 real functions)", async () => {
|
||||
ensureTelemetryTable();
|
||||
const db = getDbInstance()!;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const nowMilliseconds = Date.now();
|
||||
const recentISO = new Date().toISOString();
|
||||
|
||||
db.prepare("INSERT INTO domain_cost_history (api_key_id, cost, timestamp) VALUES (?, ?, ?)").run(
|
||||
"k",
|
||||
1,
|
||||
now - DAY
|
||||
nowMilliseconds - DAY_MS
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO compression_cache_stats (provider, compression_mode, created_at) VALUES (?, ?, ?)"
|
||||
@@ -185,7 +201,7 @@ test("#6848 no rows deleted when all data is within retention window (calls all
|
||||
).run("k", "a", 5, recentISO);
|
||||
db.prepare(
|
||||
"INSERT INTO compression_run_telemetry (timestamp, tokens_before, tokens_after) VALUES (?, ?, ?)"
|
||||
).run(now - DAY, 100, 50);
|
||||
).run(nowSeconds - DAY_SECONDS, 100, 50);
|
||||
|
||||
const r1 = await cleanupDomainCostHistory();
|
||||
const r2 = await cleanupCompressionCacheStats();
|
||||
|
||||
@@ -200,6 +200,16 @@ test("Claude -> Gemini omits unsigned functionCall instead of injecting a fake t
|
||||
false,
|
||||
"signature-less tool_use must not become a native functionCall"
|
||||
);
|
||||
assert.equal(
|
||||
JSON.stringify(result).includes('"thoughtSignature"'),
|
||||
false,
|
||||
"the translator must not synthesize a fake thought signature"
|
||||
);
|
||||
assert.equal(
|
||||
JSON.stringify(result).includes("read_file"),
|
||||
false,
|
||||
"the omitted unsigned call must not leak its tool payload elsewhere"
|
||||
);
|
||||
});
|
||||
|
||||
test("Claude -> Gemini sanitizes long tool names and exposes a restore map", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { openaiResponsesToOpenAIRequest } from "../../open-sse/translator/request/openai-responses.ts";
|
||||
import { detectSupportedThinkingEfforts } from "../../src/lib/providerModels/modelDiscovery.ts";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value as Record<string, unknown>;
|
||||
@@ -42,7 +43,6 @@ test("non-GPT-5.6 models still get max downgraded to xhigh", () => {
|
||||
)
|
||||
);
|
||||
assert.equal(translated.reasoning_effort, "xhigh");
|
||||
<<<<<<< HEAD
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -61,12 +61,9 @@ test("#9142 Anthropic top-level system prompts must trigger background detection
|
||||
"system_prompt_pattern"
|
||||
);
|
||||
});
|
||||
=======
|
||||
|
||||
// #9140 — VS Code routes filter out built-in auto models
|
||||
const { isUsableChatModel } = await import(
|
||||
"../../src/app/api/v1/vscode/[token]/usableChatModel.ts"
|
||||
);
|
||||
const { isUsableChatModel } =
|
||||
await import("../../src/app/api/v1/vscode/[token]/usableChatModel.ts");
|
||||
|
||||
test("#9140 VS Code listing must accept built-in auto routing entries", () => {
|
||||
assert.equal(
|
||||
@@ -79,14 +76,9 @@ 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({
|
||||
@@ -103,4 +95,4 @@ test("#9160 capabilities.effort_tiers with duplicate and synonym", () => {
|
||||
}),
|
||||
["low", "xhigh"]
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
@@ -22,16 +22,12 @@ const extract = extractCiGates as (
|
||||
) => { id: string; job: string; args: string[]; env?: Record<string, string> }[];
|
||||
|
||||
test("eslintCounts sums errors + warnings across files", () => {
|
||||
const parsed = [
|
||||
{ errorCount: 2, warningCount: 5 },
|
||||
{ errorCount: 0, warningCount: 3 },
|
||||
{},
|
||||
];
|
||||
const parsed = [{ errorCount: 2, warningCount: 5 }, { errorCount: 0, warningCount: 3 }, {}];
|
||||
assert.deepEqual(eslintCounts(parsed), { errors: 2, warnings: 8 });
|
||||
});
|
||||
|
||||
test("parseEslintJson tolerates a leading non-JSON banner", () => {
|
||||
const out = "npm warn something\n[{\"errorCount\":0,\"warningCount\":1}]";
|
||||
const out = 'npm warn something\n[{"errorCount":0,"warningCount":1}]';
|
||||
assert.deepEqual(parseEslintJson(out), [{ errorCount: 0, warningCount: 1 }]);
|
||||
assert.equal(parseEslintJson("no json here"), null);
|
||||
});
|
||||
@@ -52,8 +48,14 @@ test("parseEslintJson tolerates ESLint's trailing unpruned-suppressions stderr s
|
||||
});
|
||||
|
||||
test("parseCognitiveCount reads the gate's count (en + pt)", () => {
|
||||
assert.equal(parseCognitiveCount("[cognitive-complexity] 797 function(s) exceed the threshold (15)."), 797);
|
||||
assert.equal(parseCognitiveCount("[cognitive-complexity] REGRESSÃO — 801 violações > baseline 797"), 801);
|
||||
assert.equal(
|
||||
parseCognitiveCount("[cognitive-complexity] 797 function(s) exceed the threshold (15)."),
|
||||
797
|
||||
);
|
||||
assert.equal(
|
||||
parseCognitiveCount("[cognitive-complexity] REGRESSÃO — 801 violações > baseline 797"),
|
||||
801
|
||||
);
|
||||
assert.equal(parseCognitiveCount("no number"), null);
|
||||
});
|
||||
|
||||
@@ -175,8 +177,16 @@ test("pre-flight wires the test-masking PR-context gate against origin/main (v3.
|
||||
);
|
||||
// run() must honor a per-gate env override so GITHUB_BASE_REF actually reaches the child
|
||||
// (routed through buildGateEnv since the --hermetic scrub was added).
|
||||
assert.match(src, /env:\s*buildGateEnv\(opts\.env\)/, "run() must merge opts.env into the child env");
|
||||
assert.match(src, /\.\.\.\(extra \|\| \{\}\)/, "buildGateEnv must spread the per-gate env override");
|
||||
assert.match(
|
||||
src,
|
||||
/env:\s*buildGateEnv\(opts\.env\)/,
|
||||
"run() must merge opts.env into the child env"
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/\.\.\.\(extra \|\| \{\}\)/,
|
||||
"buildGateEnv must spread the per-gate env override"
|
||||
);
|
||||
});
|
||||
|
||||
test("pre-flight --hermetic scrubs the live-test trigger vars (2026-07-05 false-positive fix)", async () => {
|
||||
@@ -214,6 +224,27 @@ test("pre-flight runs the slow suites CONCURRENTLY (v3.8.45 perf — was ~1h ser
|
||||
assert.match(src, /slow\.forEach\([\s\S]*?saveGateLog\(g\.id/, "each slow gate persists its log");
|
||||
});
|
||||
|
||||
test("pre-flight runs tarball boot only after the package artifact builder completes", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const src = fs.readFileSync(
|
||||
new URL("../../scripts/quality/validate-release-green.mjs", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const parallelWave = src.indexOf("const slowResults = await Promise.all");
|
||||
const packBoot = src.indexOf('id: "pack-boot"');
|
||||
|
||||
assert.ok(parallelWave >= 0, "the parallel slow-gate wave must exist");
|
||||
assert.ok(
|
||||
packBoot > parallelWave,
|
||||
"pack-boot must be declared after the parallel artifact build"
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/packArtifactResult[\s\S]*?check:pack-boot/,
|
||||
"pack-boot must be explicitly sequenced from the package-artifact result"
|
||||
);
|
||||
});
|
||||
|
||||
// ─── --full-ci gate extraction (P0, v3.8.46 post-mortem) ─────────────────────
|
||||
|
||||
const CI_FIXTURE = `
|
||||
@@ -259,7 +290,11 @@ test("extractCiGates: pulls npm-run gate steps from the ci.yml gate jobs only",
|
||||
assert.ok(ids.includes("check:docs-all") && ids.includes("check:docs-symbols"), "multi-line run");
|
||||
// …and NON-gate steps + jobs outside the gate set are ignored.
|
||||
assert.ok(!ids.includes("build") && !ids.some((i) => i.startsWith("test:")), "no build/test-run");
|
||||
assert.equal(gates.find((g) => g.job === "test-unit"), undefined, "test-unit job is not scanned");
|
||||
assert.equal(
|
||||
gates.find((g) => g.job === "test-unit"),
|
||||
undefined,
|
||||
"test-unit job is not scanned"
|
||||
);
|
||||
});
|
||||
|
||||
test("extractCiGates: preserves `-- <args>` so ratchet flags reach the script", () => {
|
||||
@@ -272,7 +307,10 @@ test("extractCiGates: preserves `-- <args>` so ratchet flags reach the script",
|
||||
test("extractCiGates: skips the non-local gates (pr-evidence, codeql-ratchet)", () => {
|
||||
const ids = extract(CI_FIXTURE).map((g) => g.id);
|
||||
assert.ok(!ids.includes("check:pr-evidence"), "pr-evidence needs a PR body — skipped");
|
||||
assert.ok(!ids.includes("check:codeql-ratchet"), "codeql-ratchet is a remote-main check — skipped");
|
||||
assert.ok(
|
||||
!ids.includes("check:codeql-ratchet"),
|
||||
"codeql-ratchet is a remote-main check — skipped"
|
||||
);
|
||||
assert.ok(FULL_CI_SKIP.has("check:pr-evidence") && FULL_CI_SKIP.has("check:codeql-ratchet"));
|
||||
});
|
||||
|
||||
@@ -295,10 +333,7 @@ test("extractCiGates: attaches GITHUB_BASE_REF=main env to test-masking + de-dup
|
||||
|
||||
test("extractCiGates: the REAL ci.yml yields the base-reds that leaked in v3.8.46", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const yaml = fs.readFileSync(
|
||||
new URL("../../.github/workflows/ci.yml", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const yaml = fs.readFileSync(new URL("../../.github/workflows/ci.yml", import.meta.url), "utf8");
|
||||
const ids = new Set(extract(yaml).map((g) => g.id));
|
||||
// The exact gates that leaked to the v3.8.46 release PR because the pre-flight
|
||||
// never ran them — --full-ci now reproduces every one.
|
||||
|
||||
Reference in New Issue
Block a user