From d9bab7ce8b034a35f6db679cc693994cbb32a063 Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 22 Jul 2026 10:57:47 +0000 Subject: [PATCH 1/2] fix(catalog): restore cached request-model variants via requestModelId Copilot -1m long-context variants are synthesized with transport headers and a requestModelId to a bundled base. The v10 cache omits headers; the writer only matched a same-id static entry, so these variants were flagged unrestorable and dropped on the next offline read, vanishing from the picker with a "Could not restore model" warning. The startup registry loader dropped them the same way. Restore/match headers through requestModelId in the cache writer, the model-manager restore path, and the coding-agent startup loader, and bypass a stale unrestorable marker written by the old id-only writer. Fixes #6284 --- packages/catalog/CHANGELOG.md | 4 + packages/catalog/src/model-cache.ts | 8 +- packages/catalog/src/model-manager.ts | 23 ++++-- packages/catalog/test/build.test.ts | 76 +++++++++++++++++++ packages/coding-agent/CHANGELOG.md | 4 + .../coding-agent/src/config/model-registry.ts | 12 ++- .../coding-agent/test/model-discovery.test.ts | 32 +++++++- 7 files changed, 147 insertions(+), 12 deletions(-) diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 50018ad2e..fbbddaa50 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed cached models that reuse a bundled request model — including GitHub Copilot `-1m` long-context variants — being flagged unrestorable and dropped after a restart. Header restoration now matches the `requestModelId` base and bypasses a stale `unrestorable` marker written by the old id-only cache writer. ([#6037](https://github.com/can1357/oh-my-pi/issues/6037), [#6284](https://github.com/can1357/oh-my-pi/issues/6284)) + ## [17.0.6] - 2026-07-20 ### Added diff --git a/packages/catalog/src/model-cache.ts b/packages/catalog/src/model-cache.ts index b6587e75e..cb68c24f5 100644 --- a/packages/catalog/src/model-cache.ts +++ b/packages/catalog/src/model-cache.ts @@ -226,7 +226,13 @@ export function writeModelCache( for (const model of models) { if (hasModelHeaders(model)) { headerOmittedModelIds.push(model.id); - if (!headersEqual(model.headers, staticById.get(model.id)?.headers)) { + // Synthesized variants (e.g. Copilot `-1m`) have no same-id static + // entry; their headers come from the `requestModelId` base. Match + // against that source too, else they are wrongly flagged + // unrestorable and dropped on the next offline read (#6037, #6284). + const staticHeaderSource = + staticById.get(model.id) ?? (model.requestModelId ? staticById.get(model.requestModelId) : undefined); + if (!headersEqual(model.headers, staticHeaderSource?.headers)) { unrestorableHeaderModelIds.push(model.id); } } diff --git a/packages/catalog/src/model-manager.ts b/packages/catalog/src/model-manager.ts index f9ff67017..0792bc688 100644 --- a/packages/catalog/src/model-manager.ts +++ b/packages/catalog/src/model-manager.ts @@ -108,9 +108,15 @@ interface CachedHeaderRestoreResult { /** * Restore cache-omitted headers from the current static source. * - * Dynamic-only header-bearing models cannot be reconstructed safely without - * persisting arbitrary credential values; callers must refetch them online or - * omit them from an offline result rather than return a broken model. + * A same-id static match is trusted only when the row did not flag the model + * unrestorable (its live headers matched static when cached). Synthesized + * variants (e.g. Copilot `-1m`) instead recover headers through + * `requestModelId`, whose static source is where their headers came from — + * honoured even past a stale `unrestorable` marker written by the old id-only + * writer (#6037, #6284). Header-bearing models without either source cannot be + * reconstructed safely without persisting arbitrary credential values; + * callers must refetch them online or omit them rather than return a broken + * model. */ function restoreCachedModelHeaders( cachedModels: readonly ModelSpec[], @@ -128,11 +134,12 @@ function restoreCachedModelHeaders( const unresolvedModelIds = new Set(); const restored = models.map(model => { if (!omittedIds.has(model.id)) return model; - if (unrestorableIds.has(model.id)) { - unresolvedModelIds.add(model.id); - return model; - } - const staticModel = staticById.get(model.id); + // A same-id static match is trusted only when the row did not flag the + // model unrestorable. A `requestModelId` source is always trusted: it is + // where a synthesized variant's headers came from. + const staticModel = + (unrestorableIds.has(model.id) ? undefined : staticById.get(model.id)) ?? + (model.requestModelId ? staticById.get(model.requestModelId) : undefined); if (!staticModel?.headers) { unresolvedModelIds.add(model.id); return model; diff --git a/packages/catalog/test/build.test.ts b/packages/catalog/test/build.test.ts index 4e89e2269..0fa790c46 100644 --- a/packages/catalog/test/build.test.ts +++ b/packages/catalog/test/build.test.ts @@ -664,6 +664,82 @@ describe("model cache spec round trip", () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + + it("keeps a synthesized request-model variant across an offline restart", async () => { + // Regression for #6037/#6284: Copilot `-1m` long-context variants are + // synthesized dynamically with transport headers and a `requestModelId` + // pointing at a same-provider base. Their headers are omitted from the + // cache but recoverable from the base's static headers, so they must NOT + // be flagged unrestorable and dropped on the next offline read. + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-catalog-request-model-variant-")); + const dbPath = path.join(tempDir, "models.db"); + const headers = { "X-GitHub-Api-Version": "2026-06-01" }; + const base = completionsSpec({ id: "sol", provider: "variant-cache-test", headers }); + const variant = completionsSpec({ + id: "sol-1m", + provider: "variant-cache-test", + requestModelId: "sol", + headers, + contextWindow: 1_000_000, + }); + const options = { + providerId: "variant-cache-test", + staticModels: [base], + cacheDbPath: dbPath, + }; + try { + const online = await resolveProviderModels<"openai-completions">( + { ...options, fetchDynamicModels: async () => [base, variant] }, + "online", + ); + expect(online.models.find(candidate => candidate.id === "sol-1m")).toBeDefined(); + + const offline = await resolveProviderModels<"openai-completions">( + { ...options, fetchDynamicModels: async () => null }, + "offline", + ); + const restored = offline.models.find(candidate => candidate.id === "sol-1m"); + expect(restored).toBeDefined(); + expect(restored?.headers).toEqual(headers); + expect(offline.models.find(candidate => candidate.id === "sol")?.headers).toEqual(headers); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("recovers a legacy stale-marked request-model variant via requestModelId", async () => { + // Legacy cache rows (written by the old id-only writer) flag `-1m` + // variants unrestorable because it never matched their base's headers. + // The restore path must still recover them through `requestModelId`. + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-catalog-legacy-variant-")); + const dbPath = path.join(tempDir, "models.db"); + const headers = { "X-GitHub-Api-Version": "2026-06-01" }; + const base = completionsSpec({ id: "sol", provider: "variant-cache-test", headers }); + const variant = buildModel( + completionsSpec({ + id: "sol-1m", + provider: "variant-cache-test", + requestModelId: "sol", + headers, + contextWindow: 1_000_000, + }), + ); + try { + // Emulate a legacy write: no static header source, so the variant is + // flagged unrestorable even though its base carries the headers. + writeModelCache("variant-cache-test", Date.now(), [variant], true, "", dbPath); + + const offline = await resolveProviderModels<"openai-completions">( + { providerId: "variant-cache-test", staticModels: [base], cacheDbPath: dbPath }, + "offline", + ); + const restored = offline.models.find(candidate => candidate.id === "sol-1m"); + expect(restored).toBeDefined(); + expect(restored?.headers).toEqual(headers); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); }); describe("isOfficialAnthropicApiUrl", () => { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 945a4ed1c..a3c15c736 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed GitHub Copilot 1M-context models (e.g. `github-copilot/gpt-5.6-sol-1m`) disappearing from the model picker on restart, with a `Could not restore model` warning, until discovery was manually refreshed. The startup cache loader now restores their transport headers from the bundled base via `requestModelId`. ([#6037](https://github.com/can1357/oh-my-pi/issues/6037), [#6284](https://github.com/can1357/oh-my-pi/issues/6284)) + ## [17.0.7] - 2026-07-21 ### Fixed diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index bec1c4ca9..af2c809e8 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -1143,8 +1143,16 @@ export class ModelRegistry { models.push(spec); continue; } - if (unrestorableHeaderIds.has(spec.id)) continue; - const bundledHeaders = bundledById?.get(spec.id)?.headers; + // A same-id bundled match is trusted only when the row did not flag + // the model unrestorable. Synthesized variants (Copilot `-1m`) + // recover through `requestModelId`, whose bundled source is where + // their headers came from — honoured even past a stale + // `unrestorable` marker written before the fallback existed + // (#6037, #6284). + const bundledHeaders = ( + (unrestorableHeaderIds.has(spec.id) ? undefined : bundledById?.get(spec.id)) ?? + (spec.requestModelId ? bundledById?.get(spec.requestModelId) : undefined) + )?.headers; if (!bundledHeaders) continue; models.push({ ...spec, headers: bundledHeaders }); } diff --git a/packages/coding-agent/test/model-discovery.test.ts b/packages/coding-agent/test/model-discovery.test.ts index 310c550d5..9fa9a7ac2 100644 --- a/packages/coding-agent/test/model-discovery.test.ts +++ b/packages/coding-agent/test/model-discovery.test.ts @@ -6,7 +6,8 @@ import { Effort, type FetchImpl, type Model } from "@oh-my-pi/pi-ai"; import type { OAuthCredentials } from "@oh-my-pi/pi-ai/oauth/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { writeModelCache } from "@oh-my-pi/pi-catalog/model-cache"; -import type { OpenAICompat } from "@oh-my-pi/pi-catalog/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +import type { ModelSpec, OpenAICompat } from "@oh-my-pi/pi-catalog/types"; import { applyLlamaCppQwenThinking } from "@oh-my-pi/pi-coding-agent/config/model-discovery"; import { kNoAuth, ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { resetSettingsForTest } from "@oh-my-pi/pi-coding-agent/config/settings"; @@ -2133,4 +2134,33 @@ describe("ModelRegistry runtime discovery", () => { expect(registry.find("litellm-test", "team-gpt")?.contextWindow).toBe(200_000); expect(registry.find("litellm-test", "deployment-id")).toBeUndefined(); }); + + test("startup restores a legacy stale-marked Copilot -1m variant via requestModelId", () => { + // Regression for #6037/#6284: a synthesized Copilot `-1m` long-context + // variant keeps the base model's transport headers via `requestModelId`. + // The v10 cache omits headers, and legacy rows written by the old id-only + // writer flag the variant unrestorable (its base is a different id). The + // startup loader must still recover the headers from the bundled base and + // keep the model selectable instead of dropping it. + const bundledBase = getBundledModel("github-copilot", "gpt-5.6-sol"); + if (!bundledBase?.headers) { + throw new Error("Expected bundled Copilot base to carry transport headers"); + } + const cachedVariant = buildModel({ + ...(bundledBase as ModelSpec<"openai-responses">), + id: "gpt-5.6-sol-1m", + name: "GPT-5.6 Sol (1M)", + requestModelId: "gpt-5.6-sol", + contextWindow: 1_050_000, + }); + // Emulate a legacy write: the variant has no same-id static header source, + // so it is flagged unrestorable even though its base carries the headers. + writeModelCache("github-copilot", Date.now(), [cachedVariant], true, "", cacheDbPath); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + const restored = registry.find("github-copilot", "gpt-5.6-sol-1m"); + expect(restored).toBeDefined(); + expect(restored?.headers).toEqual(bundledBase.headers); + }); }); From b823c1883702504f72fcdca98087b4c8746f7ad4 Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 22 Jul 2026 11:10:23 +0000 Subject: [PATCH 2/2] fix(catalog): guarded request-model header recovery Versioned request-header restoration metadata inside v10 cache rows so only markers written by the old id-only matcher can bypass an unrestorable marker through requestModelId. Current aliases whose live headers differ from their static base remain unresolved and are refetched or dropped. Added catalog and startup-registry regressions for custom-header aliases while preserving legacy Copilot -1m cache recovery. Fixes #6284 --- packages/catalog/src/model-cache.ts | 18 ++++++++- packages/catalog/src/model-manager.ts | 33 ++++++++------- packages/catalog/test/build.test.ts | 40 +++++++++++++++++++ .../coding-agent/src/config/model-registry.ts | 18 +++++---- .../coding-agent/test/model-discovery.test.ts | 24 +++++++++++ 5 files changed, 109 insertions(+), 24 deletions(-) diff --git a/packages/catalog/src/model-cache.ts b/packages/catalog/src/model-cache.ts index cb68c24f5..b426530aa 100644 --- a/packages/catalog/src/model-cache.ts +++ b/packages/catalog/src/model-cache.ts @@ -21,6 +21,7 @@ import type { Api, Model, ModelSpec } from "./types"; // effort-tier variant collapsing (raw `-low`/`-high`/`-thinking` member ids); // v4 dropped the pre-efforts ThinkingConfig shape. const CACHE_SCHEMA_VERSION = 10; +const HEADER_RESTORE_VERSION = 1; interface CacheRow { provider_id: string; @@ -31,6 +32,7 @@ interface CacheRow { models: string; header_omitted_model_ids: string; unrestorable_header_model_ids: string; + header_restore_version: number; } interface TableInfoRow { @@ -46,6 +48,8 @@ interface CacheEntry { headerOmittedModelIds: readonly string[]; /** Header-bearing model ids that cannot be rebuilt from the static source. */ unrestorableHeaderModelIds: readonly string[]; + /** Whether unrestorable markers predate request-model header matching. */ + legacyHeaderRestoreMarkers: boolean; /** * Hash of the static catalog slice that was merged into `models` when this * row was written. `resolveProviderModels` compares against the current @@ -77,6 +81,7 @@ function openDb(resolvedPath: string): Database { static_fingerprint TEXT NOT NULL DEFAULT '', header_omitted_model_ids TEXT NOT NULL DEFAULT '[]', unrestorable_header_model_ids TEXT NOT NULL DEFAULT '[]', + header_restore_version INTEGER NOT NULL DEFAULT 0, models TEXT NOT NULL ) `); @@ -121,6 +126,12 @@ function migrateCacheSchema(db: Database): void { if (!columns.some(column => column.name === "unrestorable_header_model_ids")) { db.run("ALTER TABLE model_cache ADD COLUMN unrestorable_header_model_ids TEXT NOT NULL DEFAULT '[]'"); } + if (!columns.some(column => column.name === "header_restore_version")) { + // Existing v10 rows get 0, distinguishing markers produced by the + // old id-only header matcher from rows written after request-model + // header matching was introduced. + db.run("ALTER TABLE model_cache ADD COLUMN header_restore_version INTEGER NOT NULL DEFAULT 0"); + } } finally { stmt.finalize(); } @@ -164,6 +175,7 @@ export function readModelCache( updatedAt: row.updated_at, headerOmittedModelIds, unrestorableHeaderModelIds, + legacyHeaderRestoreMarkers: row.header_restore_version < HEADER_RESTORE_VERSION, staticFingerprint: row.static_fingerprint ?? "", }; } finally { @@ -241,8 +253,9 @@ export function writeModelCache( db.run( `INSERT OR REPLACE INTO model_cache ( provider_id, version, updated_at, authoritative, static_fingerprint, - header_omitted_model_ids, unrestorable_header_model_ids, models - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + header_omitted_model_ids, unrestorable_header_model_ids, + header_restore_version, models + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ providerId, CACHE_SCHEMA_VERSION, @@ -251,6 +264,7 @@ export function writeModelCache( staticFingerprint, JSON.stringify(headerOmittedModelIds), JSON.stringify(unrestorableHeaderModelIds), + HEADER_RESTORE_VERSION, JSON.stringify(cachedModels), ], ); diff --git a/packages/catalog/src/model-manager.ts b/packages/catalog/src/model-manager.ts index 0792bc688..1fa5a3b7c 100644 --- a/packages/catalog/src/model-manager.ts +++ b/packages/catalog/src/model-manager.ts @@ -109,20 +109,20 @@ interface CachedHeaderRestoreResult { * Restore cache-omitted headers from the current static source. * * A same-id static match is trusted only when the row did not flag the model - * unrestorable (its live headers matched static when cached). Synthesized - * variants (e.g. Copilot `-1m`) instead recover headers through - * `requestModelId`, whose static source is where their headers came from — - * honoured even past a stale `unrestorable` marker written by the old id-only - * writer (#6037, #6284). Header-bearing models without either source cannot be - * reconstructed safely without persisting arbitrary credential values; - * callers must refetch them online or omit them rather than return a broken - * model. + * unrestorable (its live headers matched static when cached). Request-model + * fallback also honors that marker for current rows. Only legacy rows written + * before request-model header matching may bypass it: their id-only writer + * necessarily marked every synthesized variant unrestorable (#6037, #6284). + * Header-bearing models without a trusted source cannot be reconstructed + * safely without persisting arbitrary credential values; callers must refetch + * them online or omit them rather than return a broken model. */ function restoreCachedModelHeaders( cachedModels: readonly ModelSpec[], staticModels: readonly Model[], headerOmittedModelIds: readonly string[], unrestorableHeaderModelIds: readonly string[], + legacyHeaderRestoreMarkers: boolean, ): CachedHeaderRestoreResult { const models = passModelList(cachedModels); if (headerOmittedModelIds.length === 0) { @@ -134,12 +134,15 @@ function restoreCachedModelHeaders( const unresolvedModelIds = new Set(); const restored = models.map(model => { if (!omittedIds.has(model.id)) return model; - // A same-id static match is trusted only when the row did not flag the - // model unrestorable. A `requestModelId` source is always trusted: it is - // where a synthesized variant's headers came from. - const staticModel = - (unrestorableIds.has(model.id) ? undefined : staticById.get(model.id)) ?? - (model.requestModelId ? staticById.get(model.requestModelId) : undefined); + const unrestorable = unrestorableIds.has(model.id); + // Current unrestorable markers prove that neither same-id nor request-model + // static headers matched the live model. Only the old id-only writer's + // markers may recover a synthesized variant through `requestModelId`. + const staticModel = unrestorable + ? legacyHeaderRestoreMarkers && model.requestModelId + ? staticById.get(model.requestModelId) + : undefined + : (staticById.get(model.id) ?? (model.requestModelId ? staticById.get(model.requestModelId) : undefined)); if (!staticModel?.headers) { unresolvedModelIds.add(model.id); return model; @@ -172,6 +175,7 @@ export async function resolveProviderModels !restoredCache.unresolvedModelIds.has(model.id)); const cacheHasUnresolvedHeaders = restoredCache.unresolvedModelIds.size > 0; @@ -252,6 +256,7 @@ export async function resolveProviderModels !latestRestoredCache.unresolvedModelIds.has(model.id), diff --git a/packages/catalog/test/build.test.ts b/packages/catalog/test/build.test.ts index 0fa790c46..0524f933d 100644 --- a/packages/catalog/test/build.test.ts +++ b/packages/catalog/test/build.test.ts @@ -707,6 +707,43 @@ describe("model cache spec round trip", () => { } }); + it("refetches a current request-model alias whose headers differ from its static base", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-catalog-custom-alias-")); + const dbPath = path.join(tempDir, "models.db"); + const baseHeaders = { "X-Route": "static" }; + const customHeaders = { "X-Route": "tenant-specific" }; + const base = completionsSpec({ id: "base", provider: "alias-cache-test", headers: baseHeaders }); + const aliasSpec = completionsSpec({ + id: "custom-alias", + provider: "alias-cache-test", + requestModelId: "base", + headers: customHeaders, + }); + const alias = buildModel(aliasSpec); + let fetches = 0; + const options = { + providerId: "alias-cache-test", + staticModels: [base], + cacheDbPath: dbPath, + fetchDynamicModels: async () => { + fetches++; + return [aliasSpec]; + }, + }; + try { + writeModelCache("alias-cache-test", Date.now(), [alias], true, "", dbPath, [buildModel(base)]); + + const refreshed = await resolveProviderModels<"openai-completions">(options, "online-if-uncached"); + expect(fetches).toBe(1); + expect(refreshed.models.find(candidate => candidate.id === alias.id)?.headers).toEqual(customHeaders); + + const offline = await resolveProviderModels<"openai-completions">(options, "offline"); + expect(offline.models.find(candidate => candidate.id === alias.id)).toBeUndefined(); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it("recovers a legacy stale-marked request-model variant via requestModelId", async () => { // Legacy cache rows (written by the old id-only writer) flag `-1m` // variants unrestorable because it never matched their base's headers. @@ -728,6 +765,9 @@ describe("model cache spec round trip", () => { // Emulate a legacy write: no static header source, so the variant is // flagged unrestorable even though its base carries the headers. writeModelCache("variant-cache-test", Date.now(), [variant], true, "", dbPath); + const db = new Database(dbPath); + db.run("UPDATE model_cache SET header_restore_version = 0 WHERE provider_id = ?", ["variant-cache-test"]); + db.close(); const offline = await resolveProviderModels<"openai-completions">( { providerId: "variant-cache-test", staticModels: [base], cacheDbPath: dbPath }, diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index af2c809e8..4ed409d58 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -1143,15 +1143,17 @@ export class ModelRegistry { models.push(spec); continue; } - // A same-id bundled match is trusted only when the row did not flag - // the model unrestorable. Synthesized variants (Copilot `-1m`) - // recover through `requestModelId`, whose bundled source is where - // their headers came from — honoured even past a stale - // `unrestorable` marker written before the fallback existed - // (#6037, #6284). + // Current unrestorable markers prove that neither same-id nor + // request-model bundled headers matched the live model. Only markers + // from the old id-only writer may recover through `requestModelId`. + const unrestorable = unrestorableHeaderIds.has(spec.id); const bundledHeaders = ( - (unrestorableHeaderIds.has(spec.id) ? undefined : bundledById?.get(spec.id)) ?? - (spec.requestModelId ? bundledById?.get(spec.requestModelId) : undefined) + unrestorable + ? cache.legacyHeaderRestoreMarkers && spec.requestModelId + ? bundledById?.get(spec.requestModelId) + : undefined + : (bundledById?.get(spec.id) ?? + (spec.requestModelId ? bundledById?.get(spec.requestModelId) : undefined)) )?.headers; if (!bundledHeaders) continue; models.push({ ...spec, headers: bundledHeaders }); diff --git a/packages/coding-agent/test/model-discovery.test.ts b/packages/coding-agent/test/model-discovery.test.ts index 9fa9a7ac2..b0b2ae61d 100644 --- a/packages/coding-agent/test/model-discovery.test.ts +++ b/packages/coding-agent/test/model-discovery.test.ts @@ -1,3 +1,4 @@ +import { Database } from "bun:sqlite"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; @@ -2156,6 +2157,9 @@ describe("ModelRegistry runtime discovery", () => { // Emulate a legacy write: the variant has no same-id static header source, // so it is flagged unrestorable even though its base carries the headers. writeModelCache("github-copilot", Date.now(), [cachedVariant], true, "", cacheDbPath); + const db = new Database(cacheDbPath); + db.run("UPDATE model_cache SET header_restore_version = 0 WHERE provider_id = ?", ["github-copilot"]); + db.close(); const registry = new ModelRegistry(authStorage, modelsJsonPath); @@ -2163,4 +2167,24 @@ describe("ModelRegistry runtime discovery", () => { expect(restored).toBeDefined(); expect(restored?.headers).toEqual(bundledBase.headers); }); + + test("startup drops a current Copilot alias whose headers differ from its bundled base", () => { + const bundledBase = getBundledModel("github-copilot", "gpt-5.6-sol"); + if (!bundledBase?.headers) { + throw new Error("Expected bundled Copilot base to carry transport headers"); + } + const cachedAlias = buildModel({ + ...(bundledBase as ModelSpec<"openai-responses">), + id: "gpt-5.6-sol-custom", + name: "GPT-5.6 Sol Custom Route", + requestModelId: "gpt-5.6-sol", + headers: { "X-Tenant-Route": "tenant-a" }, + }); + writeModelCache("github-copilot", Date.now(), [cachedAlias], true, "", cacheDbPath, [bundledBase]); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + expect(registry.find("github-copilot", cachedAlias.id)).toBeUndefined(); + expect(registry.find("github-copilot", bundledBase.id)?.headers).toEqual(bundledBase.headers); + }); });