From 3a33b2bb53ecef7c4601eb71f8a33d0582805a1b Mon Sep 17 00:00:00 2001 From: eval Date: Sat, 18 Jul 2026 20:09:53 +0200 Subject: [PATCH] fix(coding-agent): restore cache-omitted headers in registry startup loaders and preserve unrelated runtime discoveries in scoped refresh The v10 model cache never persists request headers (#5780). The registry's startup cache readers (#loadCachedStandardProviderModels / #loadCachedDiscoverableModels) read cache.models directly, so cached rows replaced bundled models (github-copilot, kimi-code, nanogpt) with header-less copies for the cache TTL after every restart. Restore bundled static headers, drop unrestorable rows so bundled fallbacks win the merge, and mark discoverable providers stale when header-bearing models were excluded. refreshProvider's #reloadStaticModels could also evict models discovered by other runtime providers; re-merge them from cache with online-if-uncached. --- .../coding-agent/src/config/model-registry.ts | 58 +++++++++++++++++-- .../test/model-registry-cache-headers.test.ts | 51 ++++++++++++++++ 2 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 packages/coding-agent/test/model-registry-cache-headers.test.ts diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index c7f16ed9c..a9ade1f40 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -857,6 +857,18 @@ export class ModelRegistry { } } await this.#refreshRuntimeDiscoveries(strategy, new Set([providerId])); + // #reloadStaticModels above may have rebuilt #models from static sources, + // dropping models previously discovered by OTHER runtime providers (their + // fetchDynamicModels results live only in #models + the SQLite cache, not + // in #loadModels' static inputs). Restore them from cache with the default + // online-if-uncached strategy: no network while their cached row is + // fresh, so the scoped refresh above stays the only forced fetch. + const otherRuntimeProviderIds = new Set( + [...this.#runtimeModelManagers.keys()].filter(runtimeId => runtimeId !== providerId), + ); + if (otherRuntimeProviderIds.size > 0) { + await this.#refreshRuntimeDiscoveries("online-if-uncached", otherRuntimeProviderIds); + } } /** @@ -1105,9 +1117,33 @@ export class ModelRegistry { if (cache.fresh && cache.authoritative) { authoritativeFreshProviders.add(providerId); } - const models = cache.models.map(model => - model.provider === providerId ? model : { ...model, provider: providerId }, - ); + // The v10 model cache never persists request headers (#5780): restore + // them from the bundled static catalog, and drop cached rows whose + // headers cannot be rebuilt so the bundled fallback (which still + // carries its headers) wins the startup merge instead of a cached + // model with required transport headers missing. + const omittedHeaderIds = new Set(cache.headerOmittedModelIds); + const unrestorableHeaderIds = new Set(cache.unrestorableHeaderModelIds); + const bundledById = + omittedHeaderIds.size > 0 + ? new Map( + (getBundledModels(providerId as Parameters[0]) as Model[]).map( + bundledModel => [bundledModel.id, bundledModel], + ), + ) + : undefined; + const models: ModelSpec[] = []; + for (const cachedModel of cache.models) { + const spec = cachedModel.provider === providerId ? cachedModel : { ...cachedModel, provider: providerId }; + if (!omittedHeaderIds.has(spec.id)) { + models.push(spec); + continue; + } + if (unrestorableHeaderIds.has(spec.id)) continue; + const bundledHeaders = bundledById?.get(spec.id)?.headers; + if (!bundledHeaders) continue; + models.push({ ...spec, headers: bundledHeaders }); + } const providerOverride = this.#providerOverrides.get(providerId); const withTransport = providerOverride ? models.map(model => this.#applyProviderTransportOverride(model, providerOverride)) @@ -1145,13 +1181,20 @@ export class ModelRegistry { continue; } const configStale = this.#isDiscoveryCacheOlderThanModelsConfig(cache.updatedAt); + // Cached rows never persist headers (#5780); models that had live + // headers cannot be rebuilt here, so exclude them and mark the + // discovery stale to force a refetch instead of returning models + // missing required transport headers. + const omittedHeaderIds = new Set(cache.headerOmittedModelIds); + const usableCacheModels = + omittedHeaderIds.size > 0 ? cache.models.filter(model => !omittedHeaderIds.has(model.id)) : cache.models; const models = this.#applyProviderModelOverrides( providerConfig.provider, this.#normalizeDiscoverableModels( providerConfig, this.#applyProviderCompat( providerConfig.compat, - cache.models.map(model => buildModel(model)), + usableCacheModels.map(model => buildModel(model)), ), ), ); @@ -1160,7 +1203,12 @@ export class ModelRegistry { provider: providerConfig.provider, status: "cached", optional: providerConfig.optional ?? false, - stale: providerConfig.discovery.type === "llama.cpp" || !cache.fresh || !cache.authoritative || configStale, + stale: + providerConfig.discovery.type === "llama.cpp" || + !cache.fresh || + !cache.authoritative || + configStale || + omittedHeaderIds.size > 0, fetchedAt: cache.updatedAt, models: models.map(model => model.id), }); diff --git a/packages/coding-agent/test/model-registry-cache-headers.test.ts b/packages/coding-agent/test/model-registry-cache-headers.test.ts new file mode 100644 index 000000000..b8c830217 --- /dev/null +++ b/packages/coding-agent/test/model-registry-cache-headers.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { writeModelCache } from "@oh-my-pi/pi-catalog/model-cache"; +import { getBundledModels } from "@oh-my-pi/pi-catalog/models"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { removeSyncWithRetries } from "@oh-my-pi/pi-utils"; + +describe("startup model cache header restoration (#5780)", () => { + let tempDir: string; + let authStorage: AuthStorage; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-test-cache-headers-")); + authStorage = await AuthStorage.create(path.join(tempDir, "auth.db")); + }); + + afterEach(() => { + authStorage.close(); + if (tempDir && fs.existsSync(tempDir)) removeSyncWithRetries(tempDir); + }); + + test("cached standard-provider models regain bundled static headers on registry startup", () => { + const dbPath = path.join(tempDir, "models.db"); + const bundled = getBundledModels("github-copilot"); + const withHeaders = bundled.filter(model => model.headers && Object.keys(model.headers).length > 0); + expect(withHeaders.length).toBeGreaterThan(0); + + // Prior process: cache the live copilot catalog. v10 never persists headers. + writeModelCache("github-copilot", Date.now(), bundled, true, "fp-test", dbPath, bundled); + const raw = fs.readFileSync(dbPath).toString("latin1"); + for (const model of withHeaders) { + for (const value of Object.values(model.headers ?? {})) { + expect(raw.includes(value)).toBe(false); + } + } + + // Next process start: the registry's startup cache loader must restore the + // bundled static headers instead of serving header-less cached models. + const registry = new ModelRegistry(authStorage, path.join(tempDir, "models.json"), { + fetch: () => Promise.reject(new Error("offline")), + }); + for (const model of withHeaders) { + const live = registry.find("github-copilot", model.id); + if (!live) continue; + expect(live.headers).toEqual(model.headers); + } + }); +});