diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index f00acf9416..4c0d022397 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -5556,9 +5556,17 @@ export function createOmniRouteConfigHook( if (modelsFetchThrew && wantDiskCache && !warmSnapshot) { const snapshot = await diskSnapshotReader(resolved.providerId, snapshotFingerprint); if (snapshot && snapshot.rawModels.length > 0) { + // Report snapshot age like the warm-startup path already does: + // "stale" alone reads as a transient blip, so a week-old catalog + // is indistinguishable from a five-minute-old one. + const snapshotAge = snapshot.writtenAt; + const snapshotAgeLabel = + typeof snapshotAge === "number" + ? `${Math.round((Date.now() - snapshotAge) / 3_600_000)}h` + : "unknown"; logAt( "warn", - `config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models)` + `config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models, age ${snapshotAgeLabel})` ); localRawModels = snapshot.rawModels; localRawCombos = snapshot.rawCombos; diff --git a/@omniroute/opencode-plugin/tests/config-shim.test.ts b/@omniroute/opencode-plugin/tests/config-shim.test.ts index 7072341398..959dd4a86c 100644 --- a/@omniroute/opencode-plugin/tests/config-shim.test.ts +++ b/@omniroute/opencode-plugin/tests/config-shim.test.ts @@ -481,10 +481,7 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m ]; assert.ok(entry); const ids = Object.keys(entry.models).sort(); - assert.deepEqual(ids, [ - "claude-sonnet-4-6", - "gemini-3-flash", - ]); + assert.deepEqual(ids, ["claude-sonnet-4-6", "gemini-3-flash"]); assert.equal(entry.models["claude-tier"], undefined, "no combo entry"); assert.ok( logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")), @@ -1041,11 +1038,7 @@ test("config: features.enrichment=false skips enrichment fetch + keeps raw-id na ]; assert.ok(entry); assert.equal(enrichmentFetcher.callCount(), 0, "enrichment fetch suppressed by feature flag"); - assert.equal( - entry.models["claude-sonnet-4-6"].name, - "claude-sonnet-4-6", - "raw id retained" - ); + assert.equal(entry.models["claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id retained"); }); test("config: enrichment fetcher throws → soft-fail (warn + raw-id static catalog)", async () => { @@ -1068,11 +1061,7 @@ test("config: enrichment fetcher throws → soft-fail (warn + raw-id static cata "opencode-omniroute" ]; assert.ok(entry, "static block still published on enrichment failure"); - assert.equal( - entry.models["claude-sonnet-4-6"].name, - "claude-sonnet-4-6", - "raw id retained" - ); + assert.equal(entry.models["claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id retained"); assert.equal(enrichmentFetcher.callCount(), 1); assert.ok( logger.entries.some((e) => String(e[0]).includes("/api/pricing/models fetch failed")), @@ -1270,10 +1259,7 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async ( const entry = (input as { provider: Record }).provider[ "opencode-omniroute" ]; - assert.ok( - entry.models["claude-sonnet-4-6"], - "stale snapshot hydrated into static block" - ); + assert.ok(entry.models["claude-sonnet-4-6"], "stale snapshot hydrated into static block"); assert.equal( entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6 (cached)", @@ -1281,14 +1267,95 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async ( ); assert.equal(writes, 0, "disk write skipped when live fetch failed"); assert.ok( - logger.entries.some((e) => - String(e[0]).includes("using stale disk cache") || - String(e[0]).includes("warm startup from disk snapshot") + logger.entries.some( + (e) => + String(e[0]).includes("using stale disk cache") || + String(e[0]).includes("warm startup from disk snapshot") ), "disk-cache hydration breadcrumb emitted" ); }); +// The stale-fallback branch (`modelsFetchThrew && wantDiskCache && !warmSnapshot`) +// only runs when the warm-startup read found nothing — a snapshot can appear on +// disk between that first read and the live fetch failing (e.g. another OC +// process instance wrote one concurrently). A stateful reader simulates that: +// empty on the warm-startup read, populated by the time the fallback re-reads. +function emptyThenSnapshotReader( + snapshot: Omit< + Awaited> & object, + never + > +): typeof import("../src/index.js").defaultDiskSnapshotReader { + let calls = 0; + return (async () => { + calls++; + return calls === 1 ? undefined : snapshot; + }) as typeof import("../src/index.js").defaultDiskSnapshotReader; +} + +test("config: stale-fallback warning reports the disk snapshot age in hours", async () => { + const readAuthJson = stubReadAuthJson({ + "opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, + }); + const fetcher = throwingModelsFetcher(); + const combosFetcher = stubCombosFetcher([]); + const logger = captureWarn(); + + const writtenAt = Date.now() - 2 * 3_600_000; // 2h old + const diskSnapshotReader = emptyThenSnapshotReader({ + rawModels: [MODEL_CLAUDE], + rawCombos: [], + rawEnrichment: new Map(), + rawCompressionCombos: [], + rawConnections: [], + writtenAt, + }); + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute", features: { diskCache: true } }, + { readAuthJson, fetcher, combosFetcher, diskSnapshotReader, logger } + ); + + await hook(makeInput()); + + assert.ok( + logger.entries.some((e) => String(e[0]).includes("using stale disk cache (1 models, age 2h)")), + "stale-fallback warning includes the computed snapshot age" + ); +}); + +test('config: stale-fallback warning falls back to "unknown" age without writtenAt', async () => { + const readAuthJson = stubReadAuthJson({ + "opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, + }); + const fetcher = throwingModelsFetcher(); + const combosFetcher = stubCombosFetcher([]); + const logger = captureWarn(); + + const diskSnapshotReader = emptyThenSnapshotReader({ + rawModels: [MODEL_CLAUDE], + rawCombos: [], + rawEnrichment: new Map(), + rawCompressionCombos: [], + rawConnections: [], + }); + + const hook = createOmniRouteConfigHook( + { providerId: "omniroute", features: { diskCache: true } }, + { readAuthJson, fetcher, combosFetcher, diskSnapshotReader, logger } + ); + + await hook(makeInput()); + + assert.ok( + logger.entries.some((e) => + String(e[0]).includes("using stale disk cache (1 models, age unknown)") + ), + 'stale-fallback warning falls back to "unknown" when writtenAt is absent' + ); +}); + test("config: cached rawEnrichment from earlier provider hook is reused (no refetch)", async () => { const readAuthJson = stubReadAuthJson({ "opencode-omniroute": { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" }, @@ -1376,10 +1443,7 @@ test("config: providerTag (default-on) prepends ' - ' to enriched raw- "opencode-omniroute" ]; assert.ok(entry); - assert.equal( - entry.models["claude-sonnet-4-6"].name, - "Claude - Claude Sonnet 4.6" - ); + assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6"); assert.equal(entry.models["gemini-3-flash"].name, "Gemini - Gemini 3 Flash"); // Combos stay untouched — `Combo: ` prefix already conveys multi-upstream. assert.equal(entry.models["claude-tier"].name, "Claude Tier"); @@ -1495,10 +1559,7 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff const entryA = (inputA as { provider: Record }).provider[ "opencode-omniroute" ]; - assert.equal( - entryA.models["claude-sonnet-4-6"].name, - "Claude - Claude Sonnet 4.6" - ); + assert.equal(entryA.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6"); // Second invocation (cache hit) — name must still be single-suffixed. const inputB = makeInput(); @@ -1506,10 +1567,7 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff const entryB = (inputB as { provider: Record }).provider[ "opencode-omniroute" ]; - assert.equal( - entryB.models["claude-sonnet-4-6"].name, - "Claude - Claude Sonnet 4.6" - ); + assert.equal(entryB.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6"); }); // ──────────────────────────────────────────────────────────────────────────── diff --git a/changelog.d/fixes/13426-opencode-plugin-stale-cache-age.md b/changelog.d/fixes/13426-opencode-plugin-stale-cache-age.md new file mode 100644 index 0000000000..5c86b7d0c5 --- /dev/null +++ b/changelog.d/fixes/13426-opencode-plugin-stale-cache-age.md @@ -0,0 +1 @@ +- **fix(opencode-plugin):** The stale disk-cache fallback warning now reports the snapshot's age (`using stale disk cache (N models, age 168h)`), matching the existing warm-startup log. Previously a week-old catalog was indistinguishable from a five-minute-old one, so silent model drift went unnoticed. (#13426)