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:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user