fix(catalog): refreshed endpoint-migration caches
Fold provider cache-drop policies into the catalog fingerprint and force online-if-uncached discovery when an affected cached model is present. Seed the Copilot migration regression with the real bundled fingerprint and verify stale Grok and MAI completion routes are rewritten through Responses. Fixes #7096
This commit is contained in:
@@ -39,7 +39,7 @@ export interface ModelManagerOptions<TApi extends Api = Api, TModelsDevPayload =
|
||||
cacheTtlMs?: number;
|
||||
/** When true, a successful dynamic fetch is the complete provider catalog and prunes static-only models. */
|
||||
dynamicModelsAuthoritative?: boolean;
|
||||
/** Cached model ids to ignore when the cache was written against a different static catalog fingerprint. */
|
||||
/** Cached model ids whose presence forces refresh when the static or migration-policy fingerprint changes. */
|
||||
dropCachedModelIdsOnStaticMismatch?: readonly string[];
|
||||
/**
|
||||
* Trusted, provider-wide request headers (compile-time constants, never
|
||||
@@ -199,10 +199,24 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
|
||||
const usableCachedModels = restoredCache.models.filter(model => !restoredCache.unresolvedModelIds.has(model.id));
|
||||
const cacheHasUnresolvedHeaders = restoredCache.unresolvedModelIds.size > 0;
|
||||
const dynamicModelsAuthoritative = options.dynamicModelsAuthoritative ?? false;
|
||||
const staticFingerprint = fingerprintStatic(staticModels, dynamicModelsAuthoritative);
|
||||
const cacheDropIds = options.dropCachedModelIdsOnStaticMismatch;
|
||||
const staticCatalogFingerprint = fingerprintStatic(staticModels, dynamicModelsAuthoritative);
|
||||
// Endpoint-migration policy is cache identity: adding an id must invalidate
|
||||
// matching-static-catalog caches written by the prior resolver.
|
||||
const staticFingerprint =
|
||||
cacheDropIds && cacheDropIds.length > 0
|
||||
? `${staticCatalogFingerprint}:drop:${Bun.hash(cacheDropIds.join("\0")).toString(36)}`
|
||||
: staticCatalogFingerprint;
|
||||
const cacheFingerprintMatches = cache?.staticFingerprint === staticFingerprint && staticFingerprint.length > 0;
|
||||
const cacheNeedsModelMigration =
|
||||
!cacheFingerprintMatches &&
|
||||
cacheDropIds !== undefined &&
|
||||
usableCachedModels.some(model => cacheDropIds.includes(model.id));
|
||||
const hasUsableFreshCache =
|
||||
(cache?.fresh ?? false) && !cacheHasUnresolvedHeaders && (!dynamicModelsAuthoritative || cacheFingerprintMatches);
|
||||
(cache?.fresh ?? false) &&
|
||||
!cacheHasUnresolvedHeaders &&
|
||||
!cacheNeedsModelMigration &&
|
||||
(!dynamicModelsAuthoritative || cacheFingerprintMatches);
|
||||
const dynamicFetcher = options.fetchDynamicModels;
|
||||
const hasDynamicFetcher = typeof dynamicFetcher === "function";
|
||||
const hasAuthoritativeCache = ((cache?.authoritative ?? false) && hasUsableFreshCache) || !hasDynamicFetcher;
|
||||
|
||||
@@ -334,10 +334,10 @@ describe("github copilot model limits mapping", () => {
|
||||
expect(model?.api).toBe("openai-responses");
|
||||
});
|
||||
for (const migration of [
|
||||
{ id: "mai-code-1-flash-picker", name: "MAI-Code-1-Flash", expectedApi: "openai-responses" },
|
||||
{ id: "grok-4.5", name: "Grok 4.5", expectedApi: undefined },
|
||||
{ id: "mai-code-1-flash-picker", name: "MAI-Code-1-Flash" },
|
||||
{ id: "grok-4.5", name: "Grok 4.5" },
|
||||
]) {
|
||||
it(`invalidates a cached ${migration.name} completion route after the endpoint migration`, async () => {
|
||||
it(`refreshes a cached ${migration.name} completion route after the endpoint migration`, async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), `pi-ai-copilot-${migration.id}-cache-`));
|
||||
const cacheDbPath = path.join(tempDir, "models.db");
|
||||
const cacheProviderId = `github-copilot-${migration.id}-cache-test`;
|
||||
@@ -346,7 +346,6 @@ describe("github copilot model limits mapping", () => {
|
||||
providerId: "github-copilot",
|
||||
cacheProviderId,
|
||||
cacheDbPath,
|
||||
staticModels: [],
|
||||
fetchDynamicModels: async () => [
|
||||
{
|
||||
id: migration.id,
|
||||
@@ -365,7 +364,15 @@ describe("github copilot model limits mapping", () => {
|
||||
await oldManager.refresh("online");
|
||||
|
||||
const fetchMock = vi.fn(async () => {
|
||||
throw new Error("a fresh cache must avoid discovery");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [{ id: migration.id, name: migration.name }],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
});
|
||||
const manager = createModelManager({
|
||||
...githubCopilotModelManagerOptions({ apiKey: "copilot-test-key", fetch: fetchMock }),
|
||||
@@ -375,8 +382,8 @@ describe("github copilot model limits mapping", () => {
|
||||
const { models } = await manager.refresh("online-if-uncached");
|
||||
const model = models.find(candidate => candidate.id === migration.id);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(model?.api).toBe(migration.expectedApi);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(model?.api).toBe("openai-responses");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user