@oh-my-pi/pi-catalog 17.1.3 → 17.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.1.4] - 2026-07-26
6
+
7
+ ### Added
8
+
9
+ - Added Claude Opus 5 model entries for Amazon Bedrock: `anthropic.claude-opus-5` plus its `us.`, `eu.`, `au.`, and `global.` regional/geo IDs.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed `alibaba-token-plan` locking out China (Beijing) 百炼 Token Plan subscribers: the provider hardcoded the international Singapore endpoint, so Beijing-issued `sk-sp-` keys got `401 invalid_api_key`. The wire credential now carries an optional region base URL, and model discovery targets the credential's region ([#6682](https://github.com/can1357/oh-my-pi/issues/6682)).
14
+ - Fixed forced `tool_choice` 400s (`tool_choice 'specified' is incompatible with thinking enabled`) on Kimi Code's Anthropic-compatible endpoint for the `kimi-for-coding`, `kimi-for-coding-highspeed`, and `k3` aliases: the Anthropic-surface compat matcher only recognised Moonshot's native `kimi-k2.7-code*` ids, so thinking-locked kimi-code models kept `supportsForcedToolChoice: true` and the forced selector was sent to a host that always thinks. These models now resolve `requiresThinkingEnabled`, keeping thinking on and downgrading forced choices to `auto`.
15
+ - Retried empty successful provider discovery responses after the short non-authoritative interval instead of caching them for the full catalog TTL ([#6620](https://github.com/can1357/oh-my-pi/issues/6620)).
16
+ - Fixed GitHub Copilot Claude models with no bundled catalog reference (e.g. a freshly served `claude-opus-5`) discovering with `reasoning: false`/`thinking: null` and no effort dial, and disappearing along with their synthesized `-1m` sibling on offline reads: reference-less Copilot models on the anthropic-messages proxy now derive the adaptive reasoning ladder from the model id, and the cache restores their compile-time `COPILOT_API_HEADERS` by value instead of dropping them as unrestorable ([#6664](https://github.com/can1357/oh-my-pi/issues/6664)).
17
+
5
18
  ## [17.1.3] - 2026-07-24
6
19
 
7
20
  ### Fixed
@@ -119,6 +119,15 @@ export declare const isGlmVisionModelId: (modelId: string) => boolean;
119
119
  * collapsed onto the single vendor token; use {@link parseKnownModel} for finer breakdowns.
120
120
  */
121
121
  export declare const modelFamilyToken: (modelId: string) => string;
122
+ /**
123
+ * True for Claude generations that support extended thinking: Sonnet/Opus 3.7+,
124
+ * every 4.x/5+ Opus/Sonnet, and the Fable/Mythos generation. Pre-thinking
125
+ * models (Claude 3.5 and older) are excluded so no thinking effort dial is
126
+ * fabricated for a model that rejects thinking parameters. Classifier-based, so
127
+ * dotted and dashed version forms both match; ids the classifier does not parse
128
+ * (e.g. Haiku, bare dated ids) return false.
129
+ */
130
+ export declare const anthropicModelSupportsThinking: (modelId: string) => boolean;
122
131
  /**
123
132
  * Adaptive thinking `display` is supported starting with Claude Opus 4.7+,
124
133
  * Sonnet 5+, and the Claude Fable/Mythos 5 generation. Older adaptive-thinking
@@ -19,5 +19,5 @@ interface CacheEntry<TApi extends Api = Api> {
19
19
  staticFingerprint: string;
20
20
  }
21
21
  export declare function readModelCache<TApi extends Api>(providerId: string, ttlMs: number, now: () => number, dbPath?: string): CacheEntry<TApi> | null;
22
- export declare function writeModelCache<TApi extends Api>(providerId: string, updatedAt: number, models: Model<TApi>[], authoritative: boolean, staticFingerprint: string, dbPath?: string, staticHeaderSources?: readonly Model<TApi>[]): void;
22
+ export declare function writeModelCache<TApi extends Api>(providerId: string, updatedAt: number, models: Model<TApi>[], authoritative: boolean, staticFingerprint: string, dbPath?: string, staticHeaderSources?: readonly Model<TApi>[], restorableHeaderFallback?: Record<string, string>): void;
23
23
  export {};
@@ -30,6 +30,14 @@ export interface ModelManagerOptions<TApi extends Api = Api, TModelsDevPayload =
30
30
  dynamicModelsAuthoritative?: boolean;
31
31
  /** Cached model ids to ignore when the cache was written against a different static catalog fingerprint. */
32
32
  dropCachedModelIdsOnStaticMismatch?: readonly string[];
33
+ /**
34
+ * Trusted, provider-wide request headers (compile-time constants, never
35
+ * credentials) that the cache may restore by value for any model whose live
36
+ * headers matched them at write time. Lets header-bearing dynamic models
37
+ * without a bundled static entry survive offline reads instead of being
38
+ * dropped as unrestorable (e.g. GitHub Copilot's User-Agent + API version).
39
+ */
40
+ restorableHeaderFallback?: Record<string, string>;
33
41
  /** Optional dynamic endpoint fetcher. */
34
42
  fetchDynamicModels?: () => Promise<readonly ModelSpec<TApi>[] | null>;
35
43
  /** Optional models.dev fallback hook. */
@@ -41,7 +49,8 @@ export interface ModelManagerOptions<TApi extends Api = Api, TModelsDevPayload =
41
49
  * Resolution result.
42
50
  *
43
51
  * `stale` is false when the resolved catalog is authoritative for the selected provider:
44
- * - dynamic endpoint data was fetched in this call,
52
+ * - a dynamic endpoint fetch succeeded in this call (an empty catalog is still
53
+ * authoritative for the cycle, so downstream pruning of removed models runs),
45
54
  * - a still-fresh authoritative cache was reused in `online-if-uncached` mode, or
46
55
  * - the provider has no dynamic fetcher configured.
47
56
  */
@@ -1,6 +1,7 @@
1
1
  import type { ModelManagerOptions } from "../model-manager.js";
2
2
  import { getBundledModels } from "../models.js";
3
3
  import type { Api, FetchImpl, ModelSpec, Provider } from "../types.js";
4
+ import { ALIBABA_TOKEN_PLAN_BASE_URL } from "../wire/alibaba-token-plan.js";
4
5
  export interface ModelsDevModel {
5
6
  id?: string;
6
7
  name?: string;
@@ -299,7 +300,7 @@ export interface AlibabaCodingPlanModelManagerConfig {
299
300
  fetch?: FetchImpl;
300
301
  }
301
302
  export declare function alibabaCodingPlanModelManagerOptions(config?: AlibabaCodingPlanModelManagerConfig): ModelManagerOptions<"openai-completions">;
302
- export declare const ALIBABA_TOKEN_PLAN_BASE_URL = "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1";
303
+ export { ALIBABA_TOKEN_PLAN_BASE_URL };
303
304
  export declare const ALIBABA_TOKEN_PLAN_STATIC_MODELS: readonly ModelSpec<"openai-completions">[];
304
305
  export interface AlibabaTokenPlanModelManagerConfig {
305
306
  apiKey?: string;
@@ -506,4 +507,3 @@ export interface ModelsDevProviderDescriptor {
506
507
  export declare function mapModelsDevToModels(data: Record<string, unknown>, descriptors: readonly ModelsDevProviderDescriptor[]): ModelSpec<Api>[];
507
508
  /** All provider descriptors for models.dev data mapping in generate-models.ts. */
508
509
  export declare const MODELS_DEV_PROVIDER_DESCRIPTORS: readonly ModelsDevProviderDescriptor[];
509
- export {};
@@ -686,6 +686,8 @@ export interface Model<TApi extends Api = Api> {
686
686
  supportsTools?: boolean;
687
687
  /** Whether this model accepts the GA OpenAI Responses `{ type: "computer" }` native tool. */
688
688
  supportsComputerUse?: boolean;
689
+ /** Verbatim explicit computer-use support from the spec; undefined when `buildModel` inferred the runtime value. */
690
+ supportsComputerUseConfig?: boolean;
689
691
  /** GitLab Duo Workflow root namespace selected during catalog discovery. */
690
692
  gitlabDuoWorkflowRootNamespaceId?: string;
691
693
  /** Cursor `max_mode` request flag returned by `GetUsableModels` for premium models that require max mode. */
@@ -770,7 +772,7 @@ export interface Model<TApi extends Api = Api> {
770
772
  * vocabulary of `buildModel`. Identical to `Model` except `compat` carries the
771
773
  * sparse override shape and nothing is resolved yet.
772
774
  */
773
- export interface ModelSpec<TApi extends Api = Api> extends Omit<Model<TApi>, "compat" | "compatConfig"> {
775
+ export interface ModelSpec<TApi extends Api = Api> extends Omit<Model<TApi>, "compat" | "compatConfig" | "supportsComputerUseConfig"> {
774
776
  /** Sparse compatibility overrides; resolved into `Model.compat` by `buildModel`. */
775
777
  compat?: CompatConfigOf<TApi>;
776
778
  }
@@ -1,6 +1,22 @@
1
+ /**
2
+ * International (Singapore) Token Plan endpoint. Default region; keys issued by
3
+ * the international product authenticate only here.
4
+ */
5
+ export declare const ALIBABA_TOKEN_PLAN_BASE_URL = "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1";
6
+ /**
7
+ * China (Beijing) Token Plan endpoint (百炼 Token Plan). Keys are region-locked:
8
+ * a Beijing-issued key is rejected by the international endpoint with
9
+ * `invalid_api_key`, and vice versa (#6682).
10
+ */
11
+ export declare const ALIBABA_TOKEN_PLAN_CN_BASE_URL = "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1";
1
12
  export interface AlibabaTokenPlanCredential {
2
13
  token: string;
3
14
  cookie?: string;
15
+ /**
16
+ * Region base URL the key authenticates against. Absent means the default
17
+ * international endpoint ({@link ALIBABA_TOKEN_PLAN_BASE_URL}).
18
+ */
19
+ baseUrl?: string;
4
20
  }
5
21
  export declare function parseAlibabaTokenPlanCredential(value: string): AlibabaTokenPlanCredential | null;
6
- export declare function serializeAlibabaTokenPlanCredential(token: string, cookie: string): string;
22
+ export declare function serializeAlibabaTokenPlanCredential(token: string, cookie: string, baseUrl?: string): string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-catalog",
4
- "version": "17.1.3",
4
+ "version": "17.1.4",
5
5
  "description": "Model catalog for omp: bundled model database, provider discovery descriptors, model identity, classification, and equivalence",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -34,12 +34,12 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@bufbuild/protobuf": "^2.12.1",
37
- "@oh-my-pi/pi-utils": "17.1.3",
37
+ "@oh-my-pi/pi-utils": "17.1.4",
38
38
  "arktype": "2.2.3",
39
39
  "zod": "^4"
40
40
  },
41
41
  "devDependencies": {
42
- "@oh-my-pi/pi-ai": "17.1.3",
42
+ "@oh-my-pi/pi-ai": "17.1.4",
43
43
  "@types/bun": "^1.3.14"
44
44
  },
45
45
  "engines": {
package/src/build.ts CHANGED
@@ -20,28 +20,53 @@ import { cleanModelName } from "./utils";
20
20
 
21
21
  const OPENAI_GA_COMPUTER_MODEL_RE = /^gpt-5\.(?:[4-9]|[1-9]\d)(?:[.-]|$)/i;
22
22
 
23
- function supportsOpenAIGAComputerUse(spec: ModelSpec<Api>): boolean {
24
- if (spec.supportsComputerUse !== undefined) return spec.supportsComputerUse;
25
- if (
26
- spec.api !== "openai-responses" &&
27
- spec.api !== "openai-codex-responses" &&
28
- spec.api !== "azure-openai-responses"
29
- ) {
23
+ function isDirectOpenAIResponsesEndpoint(spec: ModelSpec<Api>): boolean {
24
+ if (spec.api === "openai-responses") {
25
+ if (spec.provider !== "openai") return false;
26
+ if (!spec.baseUrl) return true;
27
+ try {
28
+ const url = new URL(spec.baseUrl);
29
+ return url.protocol === "https:" && url.hostname === "api.openai.com";
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+ if (spec.api !== "azure-openai-responses" || (spec.provider !== "azure" && spec.provider !== "azure-openai")) {
30
35
  return false;
31
36
  }
32
- if (spec.api !== "azure-openai-responses" && spec.provider !== "openai" && spec.provider !== "openai-codex") {
37
+ if (!spec.baseUrl) return true;
38
+ try {
39
+ const url = new URL(spec.baseUrl);
40
+ return (
41
+ url.protocol === "https:" &&
42
+ (url.hostname.endsWith(".openai.azure.com") || url.hostname === "models.inference.ai.azure.com")
43
+ );
44
+ } catch {
33
45
  return false;
34
46
  }
47
+ }
48
+
49
+ function explicitComputerUseConfig(spec: ModelSpec<Api>): boolean | undefined {
50
+ return "supportsComputerUseConfig" in spec
51
+ ? (spec as Model<Api>).supportsComputerUseConfig
52
+ : spec.supportsComputerUse;
53
+ }
54
+
55
+ function supportsOpenAIGAComputerUse(spec: ModelSpec<Api>, explicitSupport: boolean | undefined): boolean {
56
+ if (explicitSupport !== undefined) return explicitSupport;
57
+ if (!isDirectOpenAIResponsesEndpoint(spec)) return false;
35
58
  return OPENAI_GA_COMPUTER_MODEL_RE.test(spec.requestModelId ?? spec.id);
36
59
  }
37
60
 
38
61
  export function buildModel<TApi extends Api>(spec: ModelSpec<TApi>): Model<TApi> {
39
62
  const compat = buildCompat(spec) as CompatOf<TApi>;
63
+ const supportsComputerUseConfig = explicitComputerUseConfig(spec);
40
64
  return {
41
65
  ...spec,
42
66
  name: cleanModelName(spec.name),
43
67
  thinking: resolveModelThinking(spec, compat),
44
- supportsComputerUse: supportsOpenAIGAComputerUse(spec),
68
+ supportsComputerUse: supportsOpenAIGAComputerUse(spec, supportsComputerUseConfig),
69
+ supportsComputerUseConfig,
45
70
  compat,
46
71
  compatConfig: spec.compat,
47
72
  } as Model<TApi>;
@@ -8,6 +8,7 @@ import { modelMatchesHost } from "../hosts";
8
8
  import {
9
9
  hasOpus47ApiRestrictions,
10
10
  isAnthropicFableOrMythosModel,
11
+ isKimiK3ModelId,
11
12
  supportsMidConversationSystemMessages,
12
13
  } from "../identity/family";
13
14
  import type { ModelSpec, ResolvedAnthropicCompat } from "../types";
@@ -28,12 +29,25 @@ export function isOfficialAnthropicApiUrl(baseUrl?: string): boolean {
28
29
  return lower === OFFICIAL_ANTHROPIC_URL || lower.startsWith(`${OFFICIAL_ANTHROPIC_URL}/`);
29
30
  }
30
31
 
31
- /** Mirrors `compat/openai.ts`; native-only host gating is the caller's responsibility. */
32
+ /**
33
+ * Kimi models whose native endpoints (api.kimi.com, api.moonshot.ai) keep
34
+ * thinking enabled server-side no matter what the request carries: they
35
+ * reject disabled thinking (#3852) AND reject forced tool_choice
36
+ * (`tool_choice 'specified' is incompatible with thinking enabled`), so compat
37
+ * must keep thinking on and downgrade forced choices to `auto`. Covers the
38
+ * K2.7 Code family — public id, its Highspeed variant, and the kimi-code
39
+ * `kimi-for-coding[-highspeed]` aliases — plus K3 (`kimi-k3`, bare `k3` on
40
+ * kimi-code). Broader than the `compat/openai.ts` mirror: the OpenAI surface
41
+ * has its own per-dialect policies (K3 forced-choice guard, explicit disable
42
+ * shapes), while this surface has no working way to force a tool.
43
+ * Native-only host gating is the caller's responsibility.
44
+ */
32
45
  const KIMI_K27_CODE_MODEL_PATTERN = /(?:^|\/)kimi[-._]?k2(?:[._-]?|p)7[-._]?code(?:[-._]?highspeed)?$/i;
33
46
 
34
- function matchesKimiK27CodeFamily(spec: ModelSpec<"anthropic-messages">): boolean {
47
+ function matchesKimiMandatoryThinkingModel(spec: ModelSpec<"anthropic-messages">): boolean {
35
48
  if (KIMI_K27_CODE_MODEL_PATTERN.test(spec.id)) return true;
36
- return spec.id === "kimi-for-coding" && /k2\.?7 code/i.test(spec.name ?? "");
49
+ if (spec.id === "kimi-for-coding" || spec.id === "kimi-for-coding-highspeed") return true;
50
+ return isKimiK3ModelId(spec.id) || spec.id === "k3";
37
51
  }
38
52
 
39
53
  const CLOUDFLARE_ANTHROPIC_GATEWAY_URL_MARKER = /gateway\.ai\.cloudflare\.com\/.+\/anthropic(?:\/|$)/i;
@@ -98,7 +112,7 @@ export function buildAnthropicCompat(spec: ModelSpec<"anthropic-messages">): Res
98
112
  // signature-enforcing Anthropic — same failure class as GitHub Copilot #2851
99
113
  // (issue #4192).
100
114
  const isZenmux = modelMatchesHost(spec, "zenmux");
101
- const requiresThinkingEnabled = modelMatchesHost(spec, "moonshotNative") && matchesKimiK27CodeFamily(spec);
115
+ const requiresThinkingEnabled = modelMatchesHost(spec, "moonshotNative") && matchesKimiMandatoryThinkingModel(spec);
102
116
  const isVertex = isVertexAnthropicRoute(baseUrl);
103
117
  const isBedrock = isBedrockAnthropicRoute(baseUrl);
104
118
  const isAzure = isAzureAnthropicRoute(baseUrl);
@@ -42,6 +42,15 @@ const EXPLICIT_CHECKPOINTS_4096_1H: ResolvedBedrockCompat = {
42
42
  promptCacheMaximumCheckpoints: 4,
43
43
  };
44
44
 
45
+ // AWS モデルカード: 512 トークン、最大 4 個のキャッシュチェックポイント、5 分と 1 時間の TTL。
46
+ // https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-opus-5.html
47
+ const EXPLICIT_CHECKPOINTS_512_1H: ResolvedBedrockCompat = {
48
+ promptCacheMode: "explicit",
49
+ supportsLongPromptCacheRetention: true,
50
+ promptCacheMinimumTokens: 512,
51
+ promptCacheMaximumCheckpoints: 4,
52
+ };
53
+
45
54
  /**
46
55
  * Explicit Nova cache points complement Bedrock's automatic prefix caching:
47
56
  * AWS recommends them for consistent cache hits and input-cost savings. Keep
@@ -82,6 +91,9 @@ function detectedBedrockCompat(modelId: string): ResolvedBedrockCompat {
82
91
  ) {
83
92
  return EXPLICIT_CHECKPOINTS_4096_1H;
84
93
  }
94
+ if (id.includes("anthropic.claude-opus-5")) {
95
+ return EXPLICIT_CHECKPOINTS_512_1H;
96
+ }
85
97
  if (id.includes("anthropic.claude-opus-4-6")) {
86
98
  return EXPLICIT_CHECKPOINTS_4096_5M;
87
99
  }
@@ -261,6 +261,19 @@ export const modelFamilyToken = memo((modelId: string): string => {
261
261
  return "";
262
262
  });
263
263
 
264
+ /**
265
+ * True for Claude generations that support extended thinking: Sonnet/Opus 3.7+,
266
+ * every 4.x/5+ Opus/Sonnet, and the Fable/Mythos generation. Pre-thinking
267
+ * models (Claude 3.5 and older) are excluded so no thinking effort dial is
268
+ * fabricated for a model that rejects thinking parameters. Classifier-based, so
269
+ * dotted and dashed version forms both match; ids the classifier does not parse
270
+ * (e.g. Haiku, bare dated ids) return false.
271
+ */
272
+ export const anthropicModelSupportsThinking = memo((modelId: string): boolean => {
273
+ const parsed = parseAnthropicModel(bareModelId(modelId));
274
+ return parsed !== null && semverGte(parsed.version, "3.7");
275
+ });
276
+
264
277
  /**
265
278
  * Adaptive thinking `display` is supported starting with Claude Opus 4.7+,
266
279
  * Sonnet 5+, and the Claude Fable/Mythos 5 generation. Older adaptive-thinking
@@ -9,9 +9,9 @@ import type { Api, Model, ModelSpec } from "./types";
9
9
  // Rows persist ModelSpec JSON (sparse `compat`, never the resolved record);
10
10
  // the model manager rebuilds via `buildModel` on load. Request headers are
11
11
  // intentionally omitted: arbitrary provider-defined header names can carry
12
- // credentials. v10 deletes rows that may contain persisted headers and records
13
- // which model ids lost headers and which cannot be rebuilt from static inputs,
14
- // so the manager can restore the safe subset or refetch dynamic-only headers;
12
+ // credentials. v11 invalidates rows that may persist derived computer-use
13
+ // support without provenance; v10 deletes rows that may contain persisted
14
+ // headers and records which model ids lost headers or cannot be rebuilt.
15
15
  // v9 invalidated Kimi Code rows predating live effort and protocol metadata;
16
16
  // v8 invalidated Codex discovery rows predating provider-native V2 compaction
17
17
  // metadata; v7 invalidated rows predating the Antigravity Gemini budget-mode
@@ -20,7 +20,7 @@ import type { Api, Model, ModelSpec } from "./types";
20
20
  // retired unknown-limit sentinels (222222/8888); v5 invalidated rows predating
21
21
  // effort-tier variant collapsing (raw `-low`/`-high`/`-thinking` member ids);
22
22
  // v4 dropped the pre-efforts ThinkingConfig shape.
23
- const CACHE_SCHEMA_VERSION = 10;
23
+ const CACHE_SCHEMA_VERSION = 11;
24
24
  const HEADER_RESTORE_VERSION = 1;
25
25
 
26
26
  interface CacheRow {
@@ -204,8 +204,8 @@ function hasModelHeaders(model: Model<Api>): boolean {
204
204
  * headers and reject/refetch dynamic-only cached models that need live headers.
205
205
  */
206
206
  function toCachedModelSpec<TApi extends Api>(model: Model<TApi>): ModelSpec<TApi> {
207
- const { headers: _headers, compatConfig, ...rest } = model;
208
- return { ...rest, compat: compatConfig };
207
+ const { headers: _headers, compatConfig, supportsComputerUseConfig, ...rest } = model;
208
+ return { ...rest, supportsComputerUse: supportsComputerUseConfig, compat: compatConfig };
209
209
  }
210
210
 
211
211
  /** Whether two in-memory header records are byte-for-byte equivalent. */
@@ -228,6 +228,7 @@ export function writeModelCache<TApi extends Api>(
228
228
  staticFingerprint: string,
229
229
  dbPath?: string,
230
230
  staticHeaderSources: readonly Model<TApi>[] = [],
231
+ restorableHeaderFallback?: Record<string, string>,
231
232
  ): void {
232
233
  try {
233
234
  withModelCacheDb(dbPath, db => {
@@ -244,7 +245,14 @@ export function writeModelCache<TApi extends Api>(
244
245
  // unrestorable and dropped on the next offline read (#6037, #6284).
245
246
  const staticHeaderSource =
246
247
  staticById.get(model.id) ?? (model.requestModelId ? staticById.get(model.requestModelId) : undefined);
247
- if (!headersEqual(model.headers, staticHeaderSource?.headers)) {
248
+ // A model with no static source is still restorable when its live
249
+ // headers equal the provider's trusted constant (a compile-time,
250
+ // non-credential value the reader can reattach by value). This keeps
251
+ // reference-less Copilot models (e.g. claude-opus-5) alive offline.
252
+ const matchesStatic = staticHeaderSource
253
+ ? headersEqual(model.headers, staticHeaderSource.headers)
254
+ : headersEqual(model.headers, restorableHeaderFallback);
255
+ if (!matchesStatic) {
248
256
  unrestorableHeaderModelIds.push(model.id);
249
257
  }
250
258
  }
@@ -41,6 +41,14 @@ export interface ModelManagerOptions<TApi extends Api = Api, TModelsDevPayload =
41
41
  dynamicModelsAuthoritative?: boolean;
42
42
  /** Cached model ids to ignore when the cache was written against a different static catalog fingerprint. */
43
43
  dropCachedModelIdsOnStaticMismatch?: readonly string[];
44
+ /**
45
+ * Trusted, provider-wide request headers (compile-time constants, never
46
+ * credentials) that the cache may restore by value for any model whose live
47
+ * headers matched them at write time. Lets header-bearing dynamic models
48
+ * without a bundled static entry survive offline reads instead of being
49
+ * dropped as unrestorable (e.g. GitHub Copilot's User-Agent + API version).
50
+ */
51
+ restorableHeaderFallback?: Record<string, string>;
44
52
  /** Optional dynamic endpoint fetcher. */
45
53
  fetchDynamicModels?: () => Promise<readonly ModelSpec<TApi>[] | null>;
46
54
  /** Optional models.dev fallback hook. */
@@ -53,7 +61,8 @@ export interface ModelManagerOptions<TApi extends Api = Api, TModelsDevPayload =
53
61
  * Resolution result.
54
62
  *
55
63
  * `stale` is false when the resolved catalog is authoritative for the selected provider:
56
- * - dynamic endpoint data was fetched in this call,
64
+ * - a dynamic endpoint fetch succeeded in this call (an empty catalog is still
65
+ * authoritative for the cycle, so downstream pruning of removed models runs),
57
66
  * - a still-fresh authoritative cache was reused in `online-if-uncached` mode, or
58
67
  * - the provider has no dynamic fetcher configured.
59
68
  */
@@ -123,6 +132,7 @@ function restoreCachedModelHeaders<TApi extends Api>(
123
132
  headerOmittedModelIds: readonly string[],
124
133
  unrestorableHeaderModelIds: readonly string[],
125
134
  legacyHeaderRestoreMarkers: boolean,
135
+ restorableHeaderFallback: Record<string, string> | undefined,
126
136
  ): CachedHeaderRestoreResult<TApi> {
127
137
  const models = passModelList<TApi>(cachedModels);
128
138
  if (headerOmittedModelIds.length === 0) {
@@ -144,6 +154,13 @@ function restoreCachedModelHeaders<TApi extends Api>(
144
154
  : undefined
145
155
  : (staticById.get(model.id) ?? (model.requestModelId ? staticById.get(model.requestModelId) : undefined));
146
156
  if (!staticModel?.headers) {
157
+ // A non-unrestorable row whose static source is gone was cached with
158
+ // headers matching the provider's trusted constant (e.g. a Copilot
159
+ // model with no bundled entry). Reattach the constant by value instead
160
+ // of dropping the model on this offline read.
161
+ if (!unrestorable && restorableHeaderFallback) {
162
+ return { ...model, headers: { ...restorableHeaderFallback } };
163
+ }
147
164
  unresolvedModelIds.add(model.id);
148
165
  return model;
149
166
  }
@@ -166,6 +183,7 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
166
183
  const now = options.now ?? Date.now;
167
184
  const ttlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
168
185
  const dbPath = options.cacheDbPath;
186
+ const restorableHeaderFallback = options.restorableHeaderFallback;
169
187
  const staticModels = options.staticModels
170
188
  ? passModelList<TApi>(options.staticModels)
171
189
  : (getBundledModels(options.providerId as GeneratedProvider) as Model<TApi>[]);
@@ -176,6 +194,7 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
176
194
  cache?.headerOmittedModelIds ?? [],
177
195
  cache?.unrestorableHeaderModelIds ?? [],
178
196
  cache?.legacyHeaderRestoreMarkers ?? false,
197
+ restorableHeaderFallback,
179
198
  );
180
199
  const usableCachedModels = restoredCache.models.filter(model => !restoredCache.unresolvedModelIds.has(model.id));
181
200
  const cacheHasUnresolvedHeaders = restoredCache.unresolvedModelIds.size > 0;
@@ -226,6 +245,12 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
226
245
  options.dropCachedModelIdsOnStaticMismatch,
227
246
  );
228
247
  const dynamicModels = fetchedDynamicModels ?? [];
248
+ // A successful empty result stays authoritative for THIS cycle (so an
249
+ // intentional catalog emptying still prunes removed models downstream), but
250
+ // is NOT pinned into the cache as authoritative — that would suppress the
251
+ // short retry that recovers a transient empty response (#6620). The two
252
+ // concerns are deliberately separate: result authority vs. cache retry.
253
+ const dynamicCacheAuthoritative = dynamicFetchSucceeded && dynamicModels.length > 0;
229
254
  const mergedWithCache = mergeDynamicModels(mergeModelSources(staticModels, modelsDevModels), cacheModels);
230
255
  const mergedModels = mergeDynamicModels(mergedWithCache, dynamicModels);
231
256
  const models = collapseBuiltModelVariants(
@@ -242,10 +267,11 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
242
267
  cacheProviderId,
243
268
  now(),
244
269
  collapseBuiltModelVariants(snapshotModels),
245
- true,
270
+ dynamicCacheAuthoritative,
246
271
  staticFingerprint,
247
272
  dbPath,
248
273
  staticModels,
274
+ restorableHeaderFallback,
249
275
  );
250
276
  } else {
251
277
  // Dynamic fetch failed — update cache with a non-authoritative snapshot so
@@ -257,28 +283,31 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
257
283
  latestCache?.headerOmittedModelIds ?? cache?.headerOmittedModelIds ?? [],
258
284
  latestCache?.unrestorableHeaderModelIds ?? cache?.unrestorableHeaderModelIds ?? [],
259
285
  latestCache?.legacyHeaderRestoreMarkers ?? cache?.legacyHeaderRestoreMarkers ?? false,
286
+ restorableHeaderFallback,
260
287
  );
261
288
  const latestUsableCacheModels = latestRestoredCache.models.filter(
262
289
  model => !latestRestoredCache.unresolvedModelIds.has(model.id),
263
290
  );
291
+ const fallbackSnapshotModels = collapseBuiltModelVariants(
292
+ mergeDynamicModels(
293
+ mergeModelSources(staticModels, modelsDevModels),
294
+ prepareCacheModelsForStaticMismatch(
295
+ latestUsableCacheModels,
296
+ staticModels,
297
+ cacheFingerprintMatches,
298
+ options.dropCachedModelIdsOnStaticMismatch,
299
+ ),
300
+ ),
301
+ );
264
302
  writeModelCache(
265
303
  cacheProviderId,
266
304
  now(),
267
- collapseBuiltModelVariants(
268
- mergeDynamicModels(
269
- mergeModelSources(staticModels, modelsDevModels),
270
- prepareCacheModelsForStaticMismatch(
271
- latestUsableCacheModels,
272
- staticModels,
273
- cacheFingerprintMatches,
274
- options.dropCachedModelIdsOnStaticMismatch,
275
- ),
276
- ),
277
- ),
305
+ fallbackSnapshotModels,
278
306
  false,
279
307
  staticFingerprint,
280
308
  dbPath,
281
309
  staticModels,
310
+ restorableHeaderFallback,
282
311
  );
283
312
  }
284
313
  }