Merge PR #6286: fix(catalog): restore cached Copilot 1M models via requestModelId (@roboomp)
This commit is contained in:
@@ -46,6 +46,9 @@
|
||||
### Fixed
|
||||
|
||||
- Fixed Codex (`openai-codex`) catalog discovery hiding models available only through a second configured OAuth account: discovery fetched one account's `/models` catalog and, being authoritative, pruned every model the other accounts exposed. `openaiCodexModelManagerOptions` now takes a `resolveAccounts` callback, fetches each configured account's catalog independently, and unions them by id before the authoritative merge (bundled models are retained when every account fetch fails) ([#6265](https://github.com/can1357/oh-my-pi/issues/6265)).
|
||||
### 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
|
||||
|
||||
|
||||
@@ -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<TApi extends Api = Api> {
|
||||
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<TApi extends Api>(
|
||||
updatedAt: row.updated_at,
|
||||
headerOmittedModelIds,
|
||||
unrestorableHeaderModelIds,
|
||||
legacyHeaderRestoreMarkers: row.header_restore_version < HEADER_RESTORE_VERSION,
|
||||
staticFingerprint: row.static_fingerprint ?? "",
|
||||
};
|
||||
} finally {
|
||||
@@ -226,7 +238,13 @@ export function writeModelCache<TApi extends Api>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -235,8 +253,9 @@ export function writeModelCache<TApi extends Api>(
|
||||
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,
|
||||
@@ -245,6 +264,7 @@ export function writeModelCache<TApi extends Api>(
|
||||
staticFingerprint,
|
||||
JSON.stringify(headerOmittedModelIds),
|
||||
JSON.stringify(unrestorableHeaderModelIds),
|
||||
HEADER_RESTORE_VERSION,
|
||||
JSON.stringify(cachedModels),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -108,15 +108,21 @@ interface CachedHeaderRestoreResult<TApi extends Api> {
|
||||
/**
|
||||
* 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). 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<TApi extends Api>(
|
||||
cachedModels: readonly ModelSpec<TApi>[],
|
||||
staticModels: readonly Model<TApi>[],
|
||||
headerOmittedModelIds: readonly string[],
|
||||
unrestorableHeaderModelIds: readonly string[],
|
||||
legacyHeaderRestoreMarkers: boolean,
|
||||
): CachedHeaderRestoreResult<TApi> {
|
||||
const models = passModelList<TApi>(cachedModels);
|
||||
if (headerOmittedModelIds.length === 0) {
|
||||
@@ -128,11 +134,15 @@ function restoreCachedModelHeaders<TApi extends Api>(
|
||||
const unresolvedModelIds = new Set<string>();
|
||||
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);
|
||||
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;
|
||||
@@ -165,6 +175,7 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
|
||||
staticModels,
|
||||
cache?.headerOmittedModelIds ?? [],
|
||||
cache?.unrestorableHeaderModelIds ?? [],
|
||||
cache?.legacyHeaderRestoreMarkers ?? false,
|
||||
);
|
||||
const usableCachedModels = restoredCache.models.filter(model => !restoredCache.unresolvedModelIds.has(model.id));
|
||||
const cacheHasUnresolvedHeaders = restoredCache.unresolvedModelIds.size > 0;
|
||||
@@ -245,6 +256,7 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
|
||||
staticModels,
|
||||
latestCache?.headerOmittedModelIds ?? cache?.headerOmittedModelIds ?? [],
|
||||
latestCache?.unrestorableHeaderModelIds ?? cache?.unrestorableHeaderModelIds ?? [],
|
||||
latestCache?.legacyHeaderRestoreMarkers ?? cache?.legacyHeaderRestoreMarkers ?? false,
|
||||
);
|
||||
const latestUsableCacheModels = latestRestoredCache.models.filter(
|
||||
model => !latestRestoredCache.unresolvedModelIds.has(model.id),
|
||||
|
||||
@@ -664,6 +664,122 @@ 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("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.
|
||||
// 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 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 },
|
||||
"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", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
- Fixed `scripts/install.sh` installing an x86_64 build on Apple Silicon when an x86_64 `bun` runs under Rosetta. The default path only checked whether `bun` existed; it now compares `bun`'s `process.arch` to the host (detected via `sysctl -in hw.optional.arm64` so Rosetta can't spoof it) and falls back to the prebuilt native binary on a mismatch, while `--source` errors with an actionable message. `install_binary` also derives the arch from the real host instead of the Rosetta-translated `uname -m` ([#6268](https://github.com/can1357/oh-my-pi/issues/6268)).
|
||||
- Fixed the model picker hiding Codex models available only through a second configured ChatGPT/Codex OAuth account. Catalog discovery now resolves every stored `openai-codex` OAuth account and unions their `/models` catalogs instead of surfacing only the discovery preflight account's list ([#6265](https://github.com/can1357/oh-my-pi/issues/6265)).
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1159,8 +1159,18 @@ export class ModelRegistry {
|
||||
models.push(spec);
|
||||
continue;
|
||||
}
|
||||
if (unrestorableHeaderIds.has(spec.id)) continue;
|
||||
const bundledHeaders = bundledById?.get(spec.id)?.headers;
|
||||
// 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 = (
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -6,7 +7,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";
|
||||
@@ -2198,4 +2200,56 @@ 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 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);
|
||||
|
||||
const restored = registry.find("github-copilot", "gpt-5.6-sol-1m");
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user