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.
This commit is contained in:
eval
2026-07-18 20:09:53 +02:00
committed by can1357
parent 157e940d5e
commit 3a33b2bb53
2 changed files with 104 additions and 5 deletions
@@ -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<typeof getBundledModels>[0]) as Model<Api>[]).map(
bundledModel => [bundledModel.id, bundledModel],
),
)
: undefined;
const models: ModelSpec<Api>[] = [];
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),
});
@@ -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);
}
});
});