@linxiraos/pi-catalog 1.1.5 → 1.1.6

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,10 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.1.6] - 2026-08-30
6
+
7
+ - 同步上游 OMP v18.0.9(`cc14e04f075d`)。
8
+
5
9
  ## [1.1.5] - 2026-08-26
6
10
 
7
11
  - 同步上游 OMP v18.0.5 / v18.0.6:新增 DeepInfra / Yolo-Auto 提供商标识。
@@ -145,24 +145,28 @@ export declare const supportsCodexReasoningSummary: (modelId: string) => boolean
145
145
  export declare const isOpenAISamplingRestrictedModelId: (modelId: string) => boolean;
146
146
  /**
147
147
  * Reasoning-capable GLM coding SKUs: glm-4.5 and up on the base / `-air` /
148
- * `-turbo` lines. Excludes the vision (`…v`) shape, the non-reasoning
149
- * `-flash`/`-flashx`/`-preview` variants, and pre-4.5 ids. Matching the family
150
- * keeps newly-bumped integers (`glm-5.3`, `glm-6`, …) covered without a per-id
151
- * allowlist.
148
+ * `-turbo` lines, plus `-flash` from GLM-5.3-Flash on. Excludes the vision
149
+ * (`…v`) shape, the non-reasoning `-flashx`/`-preview` variants, and pre-4.5
150
+ * ids. Matching the family keeps newly-bumped integers (`glm-5.4`, `glm-6`, …)
151
+ * covered without a per-id allowlist.
152
152
  */
153
153
  export declare const isReasoningGlmModelId: (modelId: string) => boolean;
154
154
  /** GLM-5.2+ coding SKUs accept `reasoning_effort` in addition to binary thinking. */
155
155
  export declare const isGlm52ReasoningEffortModelId: (modelId: string) => boolean;
156
156
  /**
157
- * GLM-5.3+ coding SKUs. Unlike GLM-5.2 (whose reasoning_effort dialect is
158
- * host-specific), GLM-5.3+ exposes a uniform wire-exact `low`/`high`/`max`
159
- * ladder on every host, and thinking can no longer be disabled —
160
- * `thinking.type` must always be `enabled`. Matching the family keeps future
161
- * bumps (`glm-5.4`, `glm-6`, …) covered while excluding the vision (`…v`)
162
- * shape and the non-reasoning `-flash`/`-flashx`/`-preview` variants.
157
+ * GLM-5.3+ reasoning-effort SKUs. Unlike GLM-5.2 (whose dialect is
158
+ * host-specific), the base, `-air`, `-turbo`, and `-flash` variants expose a
159
+ * uniform wire-exact `low`/`high`/`max` ladder on every host, and thinking can
160
+ * no longer be disabled — `thinking.type` must always be `enabled`. Matching
161
+ * the family keeps future bumps (`glm-5.4`, `glm-6`, …) covered while excluding
162
+ * the vision (`…v`) shape and the non-reasoning `-flashx`/`-preview` variants.
163
163
  */
164
164
  export declare const isGlm53ReasoningEffortModelId: (modelId: string) => boolean;
165
- /** GLM vision SKUs — the `v` that attaches to the version (`glm-4v`, `glm-4.5v`). */
165
+ /**
166
+ * GLM SKUs that accept image input: the `v` that attaches to the version
167
+ * (`glm-4v`, `glm-4.5v`) and the natively multimodal `-flash` line from
168
+ * GLM-5.3-Flash on, whose id carries no `v` marker.
169
+ */
166
170
  export declare const isGlmVisionModelId: (modelId: string) => boolean;
167
171
  /**
168
172
  * Coarse vendor-lineage token for "are two models the same family?" checks
@@ -11,6 +11,8 @@ export interface ModelsDevFallback<TApi extends Api = Api, TPayload = unknown> {
11
11
  fetch(): Promise<TPayload>;
12
12
  /** Maps payload into provider models. */
13
13
  map(payload: TPayload, providerId: Provider): readonly ModelSpec<TApi>[];
14
+ /** When true, mapped rows can add model ids but cannot replace static metadata. */
15
+ additiveOnly?: boolean;
14
16
  }
15
17
  /**
16
18
  * Configuration for provider model resolution.
@@ -45,18 +47,25 @@ export interface ModelManagerOptions<TApi extends Api = Api, TModelsDevPayload =
45
47
  /** Clock override for deterministic tests. */
46
48
  now?: () => number;
47
49
  }
50
+ /**
51
+ * Catalog source that most recently refreshed the resolved provider snapshot.
52
+ */
53
+ export type ModelResolutionSource = "bundled" | "cache" | "models.dev" | "provider";
48
54
  /**
49
55
  * Resolution result.
50
56
  *
51
57
  * `stale` is false when the resolved catalog is authoritative for the selected provider:
52
- * - a dynamic endpoint fetch succeeded in this call (an empty catalog is still
58
+ * - a provider endpoint fetch succeeded in this call (an empty catalog is still
53
59
  * authoritative for the cycle, so downstream pruning of removed models runs),
60
+ * - a models.dev fetch succeeded for a provider without endpoint discovery,
54
61
  * - a still-fresh authoritative cache was reused in `online-if-uncached` mode, or
55
- * - the provider has no dynamic fetcher configured.
62
+ * - the provider has no remote fetcher configured.
56
63
  */
57
64
  export interface ModelResolutionResult<TApi extends Api = Api> {
58
65
  models: Model<TApi>[];
59
66
  stale: boolean;
67
+ source: ModelResolutionSource;
68
+ updatedAt?: number;
60
69
  }
61
70
  /**
62
71
  * Stateful facade over provider model resolution.
@@ -70,8 +79,15 @@ export interface ModelManager<TApi extends Api = Api> {
70
79
  export declare function createModelManager<TApi extends Api = Api, TModelsDevPayload = unknown>(options: ModelManagerOptions<TApi, TModelsDevPayload>): ModelManager<TApi>;
71
80
  /**
72
81
  * Resolves provider models with source precedence:
73
- * static -> stencil.so -> cache -> dynamic.
82
+ * static -> cached fallback -> stencil.so -> dynamic.
74
83
  *
75
- * Later sources override earlier ones by model id.
84
+ * Later sources override earlier ones by model id. Cached rows participate only
85
+ * when at least one configured remote source did not refresh successfully.
76
86
  */
77
87
  export declare function resolveProviderModels<TApi extends Api = Api, TModelsDevPayload = unknown>(options: ModelManagerOptions<TApi, TModelsDevPayload>, strategy?: ModelRefreshStrategy): Promise<ModelResolutionResult<TApi>>;
88
+ /**
89
+ * Return the versioned, low-collision model-cache identity for a static provider
90
+ * slice. Results are cached by array reference so repeat cold-start paths skip
91
+ * the JSON serialization and hash.
92
+ */
93
+ export declare function fingerprintStaticModels<TApi extends Api>(models: readonly (ModelSpec<TApi> | Model<TApi>)[], dynamicModelsAuthoritative?: boolean): string;
@@ -0,0 +1,8 @@
1
+ import type { Api, ModelSpec } from "../types.js";
2
+ /**
3
+ * Remove models.dev rows that OMP cannot route successfully.
4
+ *
5
+ * Generation and runtime refresh share this policy so a live catalog cannot
6
+ * reintroduce selectors deliberately excluded from the bundled catalog.
7
+ */
8
+ export declare function filterModelsDevCatalogRows<TApi extends Api>(models: readonly ModelSpec<TApi>[]): ModelSpec<TApi>[];
@@ -1,4 +1,4 @@
1
- import type { ModelManagerOptions } from "../model-manager.js";
1
+ import type { ModelManagerOptions, ModelsDevFallback } from "../model-manager.js";
2
2
  import { getBundledModels } from "../models.js";
3
3
  import type { Api, FetchImpl, ModelSpec, Provider } from "../types.js";
4
4
  import { ALIBABA_TOKEN_PLAN_BASE_URL } from "../wire/alibaba-token-plan.js";
@@ -32,9 +32,12 @@ export interface ModelsDevModel {
32
32
  * The frame magic is sniffed rather than trusting content-type so plain-JSON
33
33
  * responses (test stubs, fallback mirrors) parse identically.
34
34
  *
35
- * Fetched fully once per process: concurrent callers share the in-flight
36
- * request, repeat callers send a conditional GET that the server answers
37
- * (and deliberately does not log) with `304`.
35
+ * Fetched fully once per fetch context: concurrent callers sharing a fetch
36
+ * implementation reuse one transport request, while repeat callers send a
37
+ * conditional GET that the server answers with `304`. Each subscriber may stop
38
+ * waiting independently; the shared transport retains its own hard deadline.
39
+ * Transient failures reuse the last in-memory payload for callers that only
40
+ * need best-effort metadata.
38
41
  */
39
42
  export declare function fetchWellKnownModels(fetchImpl?: FetchImpl, signal?: AbortSignal): Promise<unknown>;
40
43
  /**
@@ -685,3 +688,16 @@ export interface ModelsDevProviderDescriptor {
685
688
  export declare function mapModelsDevToModels(data: Record<string, unknown>, descriptors: readonly ModelsDevProviderDescriptor[]): ModelSpec<Api>[];
686
689
  /** All provider descriptors for models.dev data mapping in generate-models.ts. */
687
690
  export declare const MODELS_DEV_PROVIDER_DESCRIPTORS: readonly ModelsDevProviderDescriptor[];
691
+ /** Providers whose bundled catalog can receive additive models.dev updates at runtime. */
692
+ export declare const MODELS_DEV_CATALOG_PROVIDER_IDS: readonly string[];
693
+ /**
694
+ * Build the shared models.dev fallback for one known provider.
695
+ *
696
+ * Provider managers sharing one fetch implementation reuse its conditional
697
+ * catalog session. Each mapped provider slice is persisted independently so
698
+ * startup can restore it without parsing the full catalog.
699
+ *
700
+ * `timeoutMs` bounds the catalog request. It is configurable for callers with a
701
+ * stricter startup budget and for deterministic timeout tests.
702
+ */
703
+ export declare function modelsDevCatalogFallback(providerId: string, fetchImpl?: FetchImpl, timeoutMs?: number): ModelsDevFallback<Api> | undefined;
@@ -0,0 +1,18 @@
1
+ /** Provider-native AI Gateway prefix before the upstream route segment. */
2
+ export declare const CLOUDFLARE_AI_GATEWAY_BASE_URL = "https://gateway.ai.cloudflare.com/v1/<account>/<gateway>";
3
+ /** Anthropic Messages passthrough endpoint. */
4
+ export declare const CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL = "https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic";
5
+ /** OpenAI Responses passthrough endpoint. */
6
+ export declare const CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL = "https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/openai";
7
+ /** OpenAI-compatible Workers AI endpoint. */
8
+ export declare const CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL = "https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/compat";
9
+ /** Opaque API-key credential payload persisted by Cloudflare AI Gateway login. */
10
+ export interface CloudflareAiGatewayCredential {
11
+ token: string;
12
+ accountId?: string;
13
+ gatewayId?: string;
14
+ }
15
+ /** Parse both structured login credentials and legacy plain gateway tokens. */
16
+ export declare function parseCloudflareAiGatewayCredential(value: string): CloudflareAiGatewayCredential | null;
17
+ /** Serialize the gateway token with the routing identifiers collected during login. */
18
+ export declare function serializeCloudflareAiGatewayCredential(token: string, accountId: string, gatewayId: string): string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@linxiraos/pi-catalog",
4
- "version": "1.1.5",
4
+ "version": "1.1.6",
5
5
  "description": "Model catalog for omp: bundled model database, provider discovery descriptors, model identity, classification, and equivalence",
6
6
  "homepage": "https://linxira-os.github.io/zeta/",
7
7
  "author": "Can Boluk",
@@ -34,11 +34,11 @@
34
34
  "gen:proto": "bun scripts/generate-protocols.ts"
35
35
  },
36
36
  "dependencies": {
37
- "@linxiraos/pi-omptype": "1.1.5",
38
- "@linxiraos/pi-utils": "1.1.5"
37
+ "@linxiraos/pi-omptype": "1.1.6",
38
+ "@linxiraos/pi-utils": "1.1.6"
39
39
  },
40
40
  "devDependencies": {
41
- "@linxiraos/pi-ai": "1.1.5",
41
+ "@linxiraos/pi-ai": "1.1.6",
42
42
  "@types/bun": "^1.3.14"
43
43
  },
44
44
  "engines": {
@@ -9,6 +9,7 @@
9
9
 
10
10
  import {
11
11
  bareModelId,
12
+ type GlmModel,
12
13
  isAnthropicAdaptiveGenAtLeast,
13
14
  isFableOrMythos,
14
15
  parseAnthropicModel,
@@ -283,19 +284,35 @@ export const isOpenAISamplingRestrictedModelId = memo((modelId: string): boolean
283
284
  return isOpenAIWireGen5Plus(modelId) || O_SERIES_REASONING_RE.test(bare);
284
285
  });
285
286
 
287
+ /**
288
+ * GLM SKU shapes that carry the reasoning ladders. `base`/`air`/`turbo` have
289
+ * always been reasoning lines; `flash` joins at GLM-5.3-Flash, the first flash
290
+ * SKU whose thinking is mandatory (pre-5.3 `-flash` SKUs are non-reasoning).
291
+ * `flashx` and `preview` stay out.
292
+ */
293
+ function isGlmReasoningVariant(glm: GlmModel): boolean {
294
+ switch (glm.variant) {
295
+ case "base":
296
+ case "air":
297
+ case "turbo":
298
+ return true;
299
+ case "flash":
300
+ return semverGte(glm.version, "5.3");
301
+ default:
302
+ return false;
303
+ }
304
+ }
305
+
286
306
  /**
287
307
  * Reasoning-capable GLM coding SKUs: glm-4.5 and up on the base / `-air` /
288
- * `-turbo` lines. Excludes the vision (`…v`) shape, the non-reasoning
289
- * `-flash`/`-flashx`/`-preview` variants, and pre-4.5 ids. Matching the family
290
- * keeps newly-bumped integers (`glm-5.3`, `glm-6`, …) covered without a per-id
291
- * allowlist.
308
+ * `-turbo` lines, plus `-flash` from GLM-5.3-Flash on. Excludes the vision
309
+ * (`…v`) shape, the non-reasoning `-flashx`/`-preview` variants, and pre-4.5
310
+ * ids. Matching the family keeps newly-bumped integers (`glm-5.4`, `glm-6`, …)
311
+ * covered without a per-id allowlist.
292
312
  */
293
313
  export const isReasoningGlmModelId = memo((modelId: string): boolean => {
294
314
  const glm = parseGlmModel(bareModelId(modelId));
295
- if (!glm || glm.vision) {
296
- return false;
297
- }
298
- if (glm.variant !== "base" && glm.variant !== "air" && glm.variant !== "turbo") {
315
+ if (!glm || glm.vision || !isGlmReasoningVariant(glm)) {
299
316
  return false;
300
317
  }
301
318
  return semverGte(glm.version, "4.5");
@@ -304,37 +321,39 @@ export const isReasoningGlmModelId = memo((modelId: string): boolean => {
304
321
  /** GLM-5.2+ coding SKUs accept `reasoning_effort` in addition to binary thinking. */
305
322
  export const isGlm52ReasoningEffortModelId = memo((modelId: string): boolean => {
306
323
  const glm = parseGlmModel(bareModelId(modelId));
307
- if (!glm || glm.vision) {
308
- return false;
309
- }
310
- if (glm.variant !== "base" && glm.variant !== "air" && glm.variant !== "turbo") {
324
+ if (!glm || glm.vision || !isGlmReasoningVariant(glm)) {
311
325
  return false;
312
326
  }
313
327
  return semverGte(glm.version, "5.2");
314
328
  });
315
329
 
316
330
  /**
317
- * GLM-5.3+ coding SKUs. Unlike GLM-5.2 (whose reasoning_effort dialect is
318
- * host-specific), GLM-5.3+ exposes a uniform wire-exact `low`/`high`/`max`
319
- * ladder on every host, and thinking can no longer be disabled —
320
- * `thinking.type` must always be `enabled`. Matching the family keeps future
321
- * bumps (`glm-5.4`, `glm-6`, …) covered while excluding the vision (`…v`)
322
- * shape and the non-reasoning `-flash`/`-flashx`/`-preview` variants.
331
+ * GLM-5.3+ reasoning-effort SKUs. Unlike GLM-5.2 (whose dialect is
332
+ * host-specific), the base, `-air`, `-turbo`, and `-flash` variants expose a
333
+ * uniform wire-exact `low`/`high`/`max` ladder on every host, and thinking can
334
+ * no longer be disabled — `thinking.type` must always be `enabled`. Matching
335
+ * the family keeps future bumps (`glm-5.4`, `glm-6`, …) covered while excluding
336
+ * the vision (`…v`) shape and the non-reasoning `-flashx`/`-preview` variants.
323
337
  */
324
338
  export const isGlm53ReasoningEffortModelId = memo((modelId: string): boolean => {
325
339
  const glm = parseGlmModel(bareModelId(modelId));
326
- if (!glm || glm.vision) {
327
- return false;
328
- }
329
- if (glm.variant !== "base" && glm.variant !== "air" && glm.variant !== "turbo") {
340
+ if (!glm || glm.vision || !isGlmReasoningVariant(glm)) {
330
341
  return false;
331
342
  }
332
343
  return semverGte(glm.version, "5.3");
333
344
  });
334
345
 
335
- /** GLM vision SKUs — the `v` that attaches to the version (`glm-4v`, `glm-4.5v`). */
346
+ /**
347
+ * GLM SKUs that accept image input: the `v` that attaches to the version
348
+ * (`glm-4v`, `glm-4.5v`) and the natively multimodal `-flash` line from
349
+ * GLM-5.3-Flash on, whose id carries no `v` marker.
350
+ */
336
351
  export const isGlmVisionModelId = memo((modelId: string): boolean => {
337
- return parseGlmModel(bareModelId(modelId))?.vision === true;
352
+ const glm = parseGlmModel(bareModelId(modelId));
353
+ if (!glm) {
354
+ return false;
355
+ }
356
+ return glm.vision || (glm.variant === "flash" && semverGte(glm.version, "5.3"));
338
357
  });
339
358
 
340
359
  /**
@@ -21,6 +21,8 @@ export interface ModelsDevFallback<TApi extends Api = Api, TPayload = unknown> {
21
21
  fetch(): Promise<TPayload>;
22
22
  /** Maps payload into provider models. */
23
23
  map(payload: TPayload, providerId: Provider): readonly ModelSpec<TApi>[];
24
+ /** When true, mapped rows can add model ids but cannot replace static metadata. */
25
+ additiveOnly?: boolean;
24
26
  }
25
27
 
26
28
  /**
@@ -57,18 +59,26 @@ export interface ModelManagerOptions<TApi extends Api = Api, TModelsDevPayload =
57
59
  now?: () => number;
58
60
  }
59
61
 
62
+ /**
63
+ * Catalog source that most recently refreshed the resolved provider snapshot.
64
+ */
65
+ export type ModelResolutionSource = "bundled" | "cache" | "models.dev" | "provider";
66
+
60
67
  /**
61
68
  * Resolution result.
62
69
  *
63
70
  * `stale` is false when the resolved catalog is authoritative for the selected provider:
64
- * - a dynamic endpoint fetch succeeded in this call (an empty catalog is still
71
+ * - a provider endpoint fetch succeeded in this call (an empty catalog is still
65
72
  * authoritative for the cycle, so downstream pruning of removed models runs),
73
+ * - a models.dev fetch succeeded for a provider without endpoint discovery,
66
74
  * - a still-fresh authoritative cache was reused in `online-if-uncached` mode, or
67
- * - the provider has no dynamic fetcher configured.
75
+ * - the provider has no remote fetcher configured.
68
76
  */
69
77
  export interface ModelResolutionResult<TApi extends Api = Api> {
70
78
  models: Model<TApi>[];
71
79
  stale: boolean;
80
+ source: ModelResolutionSource;
81
+ updatedAt?: number;
72
82
  }
73
83
 
74
84
  /**
@@ -171,9 +181,10 @@ function restoreCachedModelHeaders<TApi extends Api>(
171
181
 
172
182
  /**
173
183
  * Resolves provider models with source precedence:
174
- * static -> stencil.so -> cache -> dynamic.
184
+ * static -> cached fallback -> stencil.so -> dynamic.
175
185
  *
176
- * Later sources override earlier ones by model id.
186
+ * Later sources override earlier ones by model id. Cached rows participate only
187
+ * when at least one configured remote source did not refresh successfully.
177
188
  */
178
189
  export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPayload = unknown>(
179
190
  options: ModelManagerOptions<TApi, TModelsDevPayload>,
@@ -187,6 +198,10 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
187
198
  const staticModels = options.staticModels
188
199
  ? passModelList<TApi>(options.staticModels)
189
200
  : (getBundledModels(options.providerId as GeneratedProvider) as Model<TApi>[]);
201
+ const additiveStaticModelIds =
202
+ options.modelsDev?.additiveOnly && staticModels.length > 0
203
+ ? new Set(staticModels.map(model => model.id))
204
+ : undefined;
190
205
  const cache = readModelCache<TApi>(cacheProviderId, ttlMs, now, dbPath);
191
206
  const restoredCache = restoreCachedModelHeaders(
192
207
  cache?.models ?? [],
@@ -200,7 +215,7 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
200
215
  const cacheHasUnresolvedHeaders = restoredCache.unresolvedModelIds.size > 0;
201
216
  const dynamicModelsAuthoritative = options.dynamicModelsAuthoritative ?? false;
202
217
  const cacheDropIds = options.dropCachedModelIdsOnStaticMismatch;
203
- const staticCatalogFingerprint = fingerprintStatic(staticModels, dynamicModelsAuthoritative);
218
+ const staticCatalogFingerprint = fingerprintStaticModels(staticModels, dynamicModelsAuthoritative);
204
219
  // Endpoint-migration policy is cache identity: adding an id must invalidate
205
220
  // matching-static-catalog caches written by the prior resolver.
206
221
  const staticFingerprint =
@@ -219,20 +234,18 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
219
234
  (!dynamicModelsAuthoritative || cacheFingerprintMatches);
220
235
  const dynamicFetcher = options.fetchDynamicModels;
221
236
  const hasDynamicFetcher = typeof dynamicFetcher === "function";
222
- const hasAuthoritativeCache = ((cache?.authoritative ?? false) && hasUsableFreshCache) || !hasDynamicFetcher;
237
+ const hasModelsDevFetcher = options.modelsDev !== undefined;
238
+ const hasRemoteFetcher = hasDynamicFetcher || hasModelsDevFetcher;
239
+ const hasAuthoritativeCache = ((cache?.authoritative ?? false) && hasUsableFreshCache) || !hasRemoteFetcher;
223
240
  const cacheAgeMs = cache ? now() - cache.updatedAt : Number.POSITIVE_INFINITY;
224
- const shouldFetchFromNetwork = shouldFetchRemoteSources(
225
- strategy,
226
- hasUsableFreshCache,
227
- hasAuthoritativeCache,
228
- cacheAgeMs,
229
- );
241
+ const shouldFetchFromNetwork =
242
+ hasRemoteFetcher && shouldFetchRemoteSources(strategy, hasUsableFreshCache, hasAuthoritativeCache, cacheAgeMs);
230
243
 
231
244
  // Cold-start fast path: when a fresh, authoritative cache exists, the network
232
245
  // fetch is skipped, AND the static catalog slice is byte-identical to what
233
246
  // was merged in last time, the cache row IS the authoritative merge result.
234
- // Re-running `mergeDynamicModels(static, cache)` would just rebuild the same
235
- // objects (~800ms in the steady-state cold-start profile for `omp -p hi`).
247
+ // Additive caches still need same-id rows stripped because an older binary
248
+ // may have written the snapshot before additive semantics were enabled.
236
249
  if (
237
250
  !shouldFetchFromNetwork &&
238
251
  cache?.fresh &&
@@ -240,17 +253,40 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
240
253
  cacheFingerprintMatches &&
241
254
  !cacheHasUnresolvedHeaders
242
255
  ) {
243
- return { models: collapseBuiltModelVariants(restoredCache.models), stale: false };
256
+ const cacheContribution = additiveStaticModelIds
257
+ ? restoredCache.models.filter(model => !additiveStaticModelIds.has(model.id))
258
+ : restoredCache.models;
259
+ const cachedModels = additiveStaticModelIds
260
+ ? mergeDynamicModels(staticModels, cacheContribution)
261
+ : restoredCache.models;
262
+ const source: ModelResolutionSource = cacheContribution.length > 0 ? "cache" : "bundled";
263
+ return {
264
+ models: collapseBuiltModelVariants(cachedModels),
265
+ stale: false,
266
+ source,
267
+ ...(source === "cache" ? { updatedAt: cache.updatedAt } : {}),
268
+ };
244
269
  }
245
270
 
246
271
  const [fetchedModelsDevModels, fetchedDynamicModels] = shouldFetchFromNetwork
247
272
  ? await Promise.all([fetchModelsDev(options), dynamicFetcher ? fetchDynamicModels(dynamicFetcher) : null])
248
273
  : [null, null];
249
- const modelsDevModels = normalizeModelList<TApi>(fetchedModelsDevModels ?? []);
274
+ const modelsDevFetchSucceeded = fetchedModelsDevModels !== null;
275
+ const normalizedModelsDevModels = normalizeModelList<TApi>(fetchedModelsDevModels ?? []);
276
+ const modelsDevModels = additiveStaticModelIds
277
+ ? normalizedModelsDevModels.filter(model => !additiveStaticModelIds.has(model.id))
278
+ : normalizedModelsDevModels;
250
279
  const shouldUseFreshCacheAsAuthoritative =
251
280
  strategy === "online-if-uncached" && hasUsableFreshCache && hasAuthoritativeCache;
252
281
  const dynamicFetchSucceeded = fetchedDynamicModels !== null;
253
- const cacheModels = dynamicFetchSucceeded
282
+ const anyRemoteFetchSucceeded = modelsDevFetchSucceeded || dynamicFetchSucceeded;
283
+ const allConfiguredRemoteFetchesSucceeded =
284
+ hasRemoteFetcher &&
285
+ (!hasModelsDevFetcher || modelsDevFetchSucceeded) &&
286
+ (!hasDynamicFetcher || dynamicFetchSucceeded);
287
+ const authoritativeDynamicFetchSucceeded = dynamicModelsAuthoritative && dynamicFetchSucceeded;
288
+ const remoteResolutionComplete = authoritativeDynamicFetchSucceeded || allConfiguredRemoteFetchesSucceeded;
289
+ const preparedCacheModels = remoteResolutionComplete
254
290
  ? []
255
291
  : prepareCacheModelsForStaticMismatch(
256
292
  usableCachedModels,
@@ -258,37 +294,44 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
258
294
  cacheFingerprintMatches,
259
295
  options.dropCachedModelIdsOnStaticMismatch,
260
296
  );
297
+ // Additive shared-catalog rows may only introduce IDs. Apply that boundary
298
+ // to cache fallback too, including snapshots written by an older binary.
299
+ const cacheModels = additiveStaticModelIds
300
+ ? preparedCacheModels.filter(model => !additiveStaticModelIds.has(model.id))
301
+ : preparedCacheModels;
261
302
  const dynamicModels = fetchedDynamicModels ?? [];
262
- // A successful empty result stays authoritative for THIS cycle (so an
303
+ // A successful empty endpoint result stays authoritative for THIS cycle (so an
263
304
  // intentional catalog emptying still prunes removed models downstream), but
264
305
  // is NOT pinned into the cache as authoritative — that would suppress the
265
- // short retry that recovers a transient empty response (#6620). The two
266
- // concerns are deliberately separate: result authority vs. cache retry.
267
- const dynamicCacheAuthoritative = dynamicFetchSucceeded && dynamicModels.length > 0;
268
- const mergedWithCache = mergeDynamicModels(mergeModelSources(staticModels, modelsDevModels), cacheModels);
269
- const mergedModels = mergeDynamicModels(mergedWithCache, dynamicModels);
306
+ // short retry that recovers a transient empty response (#6620). Shared
307
+ // models.dev snapshots may be empty for one provider and remain authoritative.
308
+ const cacheAuthoritative = hasDynamicFetcher
309
+ ? dynamicFetchSucceeded &&
310
+ dynamicModels.length > 0 &&
311
+ (dynamicModelsAuthoritative || !hasModelsDevFetcher || modelsDevFetchSucceeded)
312
+ : modelsDevFetchSucceeded;
313
+ const mergedWithCache = mergeDynamicModels(staticModels, cacheModels);
314
+ const mergedWithModelsDev = mergeDynamicModels(mergedWithCache, modelsDevModels);
315
+ const mergedModels = mergeDynamicModels(mergedWithModelsDev, dynamicModels);
270
316
  const models = collapseBuiltModelVariants(
271
- dynamicModelsAuthoritative && dynamicFetchSucceeded ? retainModelIds(mergedModels, dynamicModels) : mergedModels,
317
+ authoritativeDynamicFetchSucceeded ? retainModelIds(mergedModels, dynamicModels) : mergedModels,
272
318
  );
273
- const dynamicAuthoritative = !hasDynamicFetcher || dynamicFetchSucceeded || shouldUseFreshCacheAsAuthoritative;
319
+ const resolutionAuthoritative = !hasRemoteFetcher || remoteResolutionComplete || shouldUseFreshCacheAsAuthoritative;
320
+ const remoteUpdatedAt = anyRemoteFetchSucceeded ? now() : undefined;
274
321
  if (shouldFetchFromNetwork) {
275
- if (dynamicFetchSucceeded) {
276
- const mergedSnapshot = mergeDynamicModels(mergeModelSources(staticModels, modelsDevModels), dynamicModels);
277
- const snapshotModels = dynamicModelsAuthoritative
278
- ? retainModelIds(mergedSnapshot, dynamicModels)
279
- : mergedSnapshot;
322
+ if (anyRemoteFetchSucceeded) {
280
323
  writeModelCache(
281
324
  cacheProviderId,
282
- now(),
283
- collapseBuiltModelVariants(snapshotModels),
284
- dynamicCacheAuthoritative,
325
+ remoteUpdatedAt!,
326
+ models,
327
+ cacheAuthoritative,
285
328
  staticFingerprint,
286
329
  dbPath,
287
330
  staticModels,
288
331
  restorableHeaderFallback,
289
332
  );
290
333
  } else {
291
- // Dynamic fetch failed — update cache with a non-authoritative snapshot so
334
+ // Remote fetch failed — update cache with a non-authoritative snapshot so
292
335
  // stale state remains visible while retry backoff still applies.
293
336
  const latestCache = readModelCache<TApi>(cacheProviderId, ttlMs, now, dbPath);
294
337
  const latestRestoredCache = restoreCachedModelHeaders(
@@ -302,16 +345,17 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
302
345
  const latestUsableCacheModels = latestRestoredCache.models.filter(
303
346
  model => !latestRestoredCache.unresolvedModelIds.has(model.id),
304
347
  );
348
+ const preparedLatestCacheModels = prepareCacheModelsForStaticMismatch(
349
+ latestUsableCacheModels,
350
+ staticModels,
351
+ cacheFingerprintMatches,
352
+ options.dropCachedModelIdsOnStaticMismatch,
353
+ );
354
+ const latestCacheModels = additiveStaticModelIds
355
+ ? preparedLatestCacheModels.filter(model => !additiveStaticModelIds.has(model.id))
356
+ : preparedLatestCacheModels;
305
357
  const fallbackSnapshotModels = collapseBuiltModelVariants(
306
- mergeDynamicModels(
307
- mergeModelSources(staticModels, modelsDevModels),
308
- prepareCacheModelsForStaticMismatch(
309
- latestUsableCacheModels,
310
- staticModels,
311
- cacheFingerprintMatches,
312
- options.dropCachedModelIdsOnStaticMismatch,
313
- ),
314
- ),
358
+ mergeDynamicModels(mergeDynamicModels(staticModels, latestCacheModels), modelsDevModels),
315
359
  );
316
360
  writeModelCache(
317
361
  cacheProviderId,
@@ -325,9 +369,23 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
325
369
  );
326
370
  }
327
371
  }
372
+ const cacheContributed = cacheModels.length > 0;
373
+ const source: ModelResolutionSource = dynamicFetchSucceeded
374
+ ? "provider"
375
+ : modelsDevFetchSucceeded
376
+ ? "models.dev"
377
+ : cacheContributed
378
+ ? "cache"
379
+ : "bundled";
328
380
  return {
329
381
  models,
330
- stale: !dynamicAuthoritative,
382
+ stale: !resolutionAuthoritative,
383
+ source,
384
+ ...(remoteUpdatedAt !== undefined
385
+ ? { updatedAt: remoteUpdatedAt }
386
+ : cacheContributed && cache
387
+ ? { updatedAt: cache.updatedAt }
388
+ : {}),
331
389
  };
332
390
  }
333
391
 
@@ -409,23 +467,6 @@ function prepareCacheModelsForStaticMismatch<TApi extends Api>(
409
467
  return sanitizedModels;
410
468
  }
411
469
 
412
- function mergeModelSources<TApi extends Api>(...sources: readonly (readonly Model<TApi>[])[]): Model<TApi>[] {
413
- // Strip out empty/missing sources up front. The hot path is `(static, [])`
414
- // (modelsDev disabled / failed) — a single non-empty source means we can
415
- // skip the Map churn entirely and just hand back the array.
416
- const nonEmpty = sources.filter(source => source.length > 0);
417
- if (nonEmpty.length === 0) return [];
418
- if (nonEmpty.length === 1) return [...nonEmpty[0]];
419
- const merged = new Map<string, Model<TApi>>();
420
- for (const source of nonEmpty) {
421
- for (const model of source) {
422
- if (!model?.id) continue;
423
- merged.set(model.id, model);
424
- }
425
- }
426
- return Array.from(merged.values());
427
- }
428
-
429
470
  function mergeDynamicModels<TApi extends Api>(
430
471
  baseModels: readonly Model<TApi>[],
431
472
  dynamicModels: readonly Model<TApi>[],
@@ -459,22 +500,22 @@ function retainModelIds<TApi extends Api>(
459
500
  return models.filter(model => retainedIds.has(model.id));
460
501
  }
461
502
 
462
- /**
463
- * Stable, low-collision fingerprint of a static catalog slice. Cached by
464
- * reference so repeat calls in the same process (e.g. multiple cold-start
465
- * arms calling `resolveProviderModels` with the same `staticModels` array)
466
- * skip the JSON+hash work after the first call.
467
- */
468
503
  const MODEL_CACHE_FINGERPRINT_VERSION = "merge-v3";
469
504
  const kStaticFingerprint = Symbol("model-manager.staticFingerprint");
470
- type ModelArrayWithFingerprint = readonly Model<Api>[] & { [kStaticFingerprint]?: string };
471
- function fingerprintStatic<TApi extends Api>(
472
- models: readonly Model<TApi>[],
505
+ type ModelArrayWithFingerprint = readonly (ModelSpec<Api> | Model<Api>)[] & { [kStaticFingerprint]?: string };
506
+
507
+ /**
508
+ * Return the versioned, low-collision model-cache identity for a static provider
509
+ * slice. Results are cached by array reference so repeat cold-start paths skip
510
+ * the JSON serialization and hash.
511
+ */
512
+ export function fingerprintStaticModels<TApi extends Api>(
513
+ models: readonly (ModelSpec<TApi> | Model<TApi>)[],
473
514
  dynamicModelsAuthoritative = false,
474
515
  ): string {
475
516
  if (models.length === 0) return `${MODEL_CACHE_FINGERPRINT_VERSION}:empty`;
476
517
  if (dynamicModelsAuthoritative)
477
- return `${MODEL_CACHE_FINGERPRINT_VERSION}:authoritative:${fingerprintStatic(models)}`;
518
+ return `${MODEL_CACHE_FINGERPRINT_VERSION}:authoritative:${fingerprintStaticModels(models)}`;
478
519
  const tagged = models as ModelArrayWithFingerprint;
479
520
  const cached = tagged[kStaticFingerprint];
480
521
  if (cached !== undefined) return cached;
@@ -777,6 +777,14 @@ function inferThinkingControlMode<TApi extends Api>(
777
777
  return "anthropic-budget-effort";
778
778
  }
779
779
  }
780
+ // Bedrock serves the GPT-5.x models through OpenAI's own request
781
+ // schema, which rejects Anthropic's budget block outright:
782
+ // `unknown_parameter: 'thinking'`. It takes `reasoning.effort`
783
+ // instead. gpt-oss parses as `unknown` (no `gpt-<digits>`), so it
784
+ // keeps the budget path it ships with today.
785
+ if (parsedModel.family === "openai") {
786
+ return "effort";
787
+ }
780
788
  return "budget";
781
789
 
782
790
  default:
package/src/models.json CHANGED
@@ -28688,7 +28688,7 @@
28688
28688
  "contextWindow": 1050000,
28689
28689
  "maxTokens": 128000,
28690
28690
  "thinking": {
28691
- "mode": "budget",
28691
+ "mode": "effort",
28692
28692
  "efforts": [
28693
28693
  "low",
28694
28694
  "medium",
@@ -28727,7 +28727,7 @@
28727
28727
  "contextWindow": 1050000,
28728
28728
  "maxTokens": 128000,
28729
28729
  "thinking": {
28730
- "mode": "budget",
28730
+ "mode": "effort",
28731
28731
  "efforts": [
28732
28732
  "low",
28733
28733
  "medium",
@@ -28766,7 +28766,7 @@
28766
28766
  "contextWindow": 1050000,
28767
28767
  "maxTokens": 128000,
28768
28768
  "thinking": {
28769
- "mode": "budget",
28769
+ "mode": "effort",
28770
28770
  "efforts": [
28771
28771
  "low",
28772
28772
  "medium",
@@ -64887,38 +64887,6 @@
64887
64887
  "requiresGlyphTokenization": false,
64888
64888
  "supportsComputerUse": false
64889
64889
  },
64890
- "gemini-3.7-flash-tiered": {
64891
- "id": "gemini-3.7-flash-tiered",
64892
- "name": "gemini-3.7-flash-tiered",
64893
- "api": "google-gemini-cli",
64894
- "provider": "google-antigravity",
64895
- "baseUrl": "https://daily-cloudcode-pa.googleapis.com",
64896
- "reasoning": true,
64897
- "input": [
64898
- "text",
64899
- "image"
64900
- ],
64901
- "cost": {
64902
- "input": 0,
64903
- "output": 0,
64904
- "cacheRead": 0,
64905
- "cacheWrite": 0
64906
- },
64907
- "contextWindow": 1048576,
64908
- "maxTokens": 65536,
64909
- "thinking": {
64910
- "mode": "google-level",
64911
- "efforts": [
64912
- "minimal",
64913
- "low",
64914
- "medium",
64915
- "high"
64916
- ],
64917
- "requiresEffort": true
64918
- },
64919
- "requiresGlyphTokenization": false,
64920
- "supportsComputerUse": false
64921
- },
64922
64890
  "gpt-oss-120b": {
64923
64891
  "id": "gpt-oss-120b",
64924
64892
  "name": "GPT OSS 120B",
@@ -294589,6 +294557,55 @@
294589
294557
  },
294590
294558
  "supportsComputerUseConfig": false
294591
294559
  },
294560
+ "glm-5.3-flash": {
294561
+ "id": "glm-5.3-flash",
294562
+ "name": "GLM-5.3-Flash",
294563
+ "api": "anthropic-messages",
294564
+ "provider": "zai",
294565
+ "baseUrl": "https://api.z.ai/api/anthropic",
294566
+ "reasoning": true,
294567
+ "input": [
294568
+ "text",
294569
+ "image"
294570
+ ],
294571
+ "cost": {
294572
+ "input": 0.15,
294573
+ "output": 0.5,
294574
+ "cacheRead": 0.03,
294575
+ "cacheWrite": 0
294576
+ },
294577
+ "contextWindow": 1000000,
294578
+ "maxTokens": 131072,
294579
+ "thinking": {
294580
+ "mode": "anthropic-budget-effort",
294581
+ "efforts": [
294582
+ "low",
294583
+ "high",
294584
+ "max"
294585
+ ],
294586
+ "defaultLevel": "max",
294587
+ "requiresEffort": true
294588
+ },
294589
+ "requiresGlyphTokenization": false,
294590
+ "tokenizer": "glm5",
294591
+ "supportsComputerUse": false,
294592
+ "compat": {
294593
+ "officialEndpoint": false,
294594
+ "signingEndpoint": false,
294595
+ "disableStrictTools": false,
294596
+ "disableAdaptiveThinking": false,
294597
+ "allowAnthropicHeaderOverrides": false,
294598
+ "supportsEagerToolInputStreaming": false,
294599
+ "supportsLongCacheRetention": false,
294600
+ "supportsMidConversationSystem": false,
294601
+ "supportsForcedToolChoice": true,
294602
+ "supportsSamplingParams": true,
294603
+ "requiresToolResultId": true,
294604
+ "requiresThinkingEnabled": false,
294605
+ "replayUnsignedThinking": true,
294606
+ "escapeBuiltinToolNames": false
294607
+ }
294608
+ },
294592
294609
  "glm-5v-turbo": {
294593
294610
  "id": "glm-5v-turbo",
294594
294611
  "name": "GLM-5V-Turbo",
@@ -312261,4 +312278,4 @@
312261
312278
  }
312262
312279
  }
312263
312280
  }
312264
- }
312281
+ }
@@ -60,7 +60,10 @@ export function resolveModelCacheProviderId(providerId: string, options: ModelCa
60
60
  return "cursor:default-effort-v4";
61
61
  case "litellm": {
62
62
  const baseUrl = options.baseUrl ?? getDefaultModelDiscoveryBaseUrl(providerId)!;
63
- return `litellm:rich-v6:${Bun.hash(baseUrl).toString(36)}`;
63
+ // rich-v8 invalidates rows whose `compatConfig` retained a colliding
64
+ // bundled model's provider-specific transport (e.g. Fireworks
65
+ // `wireModelIdMode`) before that leak was fixed (issue #9938).
66
+ return `litellm:rich-v8:${Bun.hash(baseUrl).toString(36)}`;
64
67
  }
65
68
  case "opencode-go":
66
69
  case "opencode-zen": {
@@ -0,0 +1,38 @@
1
+ import type { Api, ModelSpec } from "../types";
2
+
3
+ const BEDROCK_MANTLE_OPENAI_MODEL_IDS: Readonly<Record<string, true>> = {
4
+ "openai.gpt-5.4": true,
5
+ "openai.gpt-5.5": true,
6
+ "openai.gpt-5.6-luna": true,
7
+ "openai.gpt-5.6-sol": true,
8
+ "openai.gpt-5.6-terra": true,
9
+ };
10
+
11
+ /**
12
+ * Remove models.dev rows that OMP cannot route successfully.
13
+ *
14
+ * Generation and runtime refresh share this policy so a live catalog cannot
15
+ * reintroduce selectors deliberately excluded from the bundled catalog.
16
+ */
17
+ export function filterModelsDevCatalogRows<TApi extends Api>(models: readonly ModelSpec<TApi>[]): ModelSpec<TApi>[] {
18
+ return models.filter(model => {
19
+ if (model.provider === "amazon-bedrock") {
20
+ // AWS does not document the jp. Opus 5 profile, and the openai.gpt-5.x
21
+ // rows are Mantle-only ids that Bedrock rejects or misroutes.
22
+ return model.id !== "jp.anthropic.claude-opus-5" && !BEDROCK_MANTLE_OPENAI_MODEL_IDS[model.id];
23
+ }
24
+ if (model.provider === "zai") {
25
+ // [1m] is a Claude Code selector convention, not an inference id.
26
+ return !model.id.endsWith("[1m]");
27
+ }
28
+ if (model.provider === "fireworks" || model.provider === "firepass") {
29
+ // Control-plane resource ids are not accepted by the inference API.
30
+ return !model.id.startsWith("accounts/fireworks/");
31
+ }
32
+ if (model.provider === "xiaomi" || model.provider.startsWith("xiaomi-token-plan-")) {
33
+ // Text-chat transports cannot serve Xiaomi's audio-only models.
34
+ return !model.id.includes("-tts") && !model.id.includes("-asr");
35
+ }
36
+ return true;
37
+ });
38
+ }
@@ -21,7 +21,7 @@ import {
21
21
  isReasoningGlmModelId,
22
22
  } from "../identity/family";
23
23
  import { resolveModelReference } from "../identity/reference";
24
- import type { ModelManagerOptions } from "../model-manager";
24
+ import type { ModelManagerOptions, ModelsDevFallback } from "../model-manager";
25
25
  import { type GeneratedProvider, getBundledModels } from "../models";
26
26
  import { OPENAI_GPT_56_CYBER_STANDARD_COST, OPENAI_GPT_56_SOL_STANDARD_COST } from "../openai-pricing";
27
27
  import type {
@@ -47,6 +47,7 @@ import {
47
47
  import { createBundledReferenceMap, createReferenceResolver, toModelSpec } from "./bundled-references";
48
48
  import { getDefaultModelDiscoveryBaseUrl, resolveModelCacheProviderId } from "./cache-provider-id";
49
49
  import type { ModelManagerConfig } from "./descriptor-types";
50
+ import { filterModelsDevCatalogRows } from "./models-dev-policies";
50
51
 
51
52
  const MODELS_DEV_URL = "https://catalog.stencil.so/models.json.zstd";
52
53
 
@@ -114,17 +115,40 @@ function toInputCapabilities(value: unknown): ("text" | "image")[] {
114
115
  }
115
116
 
116
117
  /**
117
- * Process-wide catalog session: the first call downloads the payload (the one
118
- * request the server logs); later calls revalidate with `If-None-Match` and
119
- * reuse the decoded payload on `304`. Failure after a successful load falls
120
- * back to the session copy.
118
+ * Catalog sessions are scoped to the fetch implementation that owns their
119
+ * network and authentication context. Callers sharing one fetch reuse the same
120
+ * conditional request state; isolated registries cannot observe each other's
121
+ * payloads, ETags, or in-flight requests.
121
122
  */
122
- const catalogSession: {
123
+ interface CatalogSession {
123
124
  inflight: Promise<unknown> | null;
124
125
  payload: unknown;
125
126
  etag: string | null;
126
127
  hasPayload: boolean;
127
- } = { inflight: null, payload: undefined, etag: null, hasPayload: false };
128
+ }
129
+
130
+ const defaultCatalogSession: CatalogSession = { inflight: null, payload: undefined, etag: null, hasPayload: false };
131
+ const catalogSessionsByFetch = new WeakMap<FetchImpl, CatalogSession>();
132
+
133
+ function getCatalogSession(fetchImpl: FetchImpl | undefined): CatalogSession {
134
+ if (!fetchImpl) return defaultCatalogSession;
135
+ const existing = catalogSessionsByFetch.get(fetchImpl);
136
+ if (existing) return existing;
137
+ const created: CatalogSession = { inflight: null, payload: undefined, etag: null, hasPayload: false };
138
+ catalogSessionsByFetch.set(fetchImpl, created);
139
+ return created;
140
+ }
141
+
142
+ function waitForCatalogRequest<T>(request: Promise<T>, signal?: AbortSignal): Promise<T> {
143
+ if (!signal) return request;
144
+ if (signal.aborted) return Promise.reject(signal.reason);
145
+ const aborted = Promise.withResolvers<never>();
146
+ const rejectAborted = () => aborted.reject(signal.reason);
147
+ signal.addEventListener("abort", rejectAborted, { once: true });
148
+ return Promise.race([request, aborted.promise]).finally(() => {
149
+ signal.removeEventListener("abort", rejectAborted);
150
+ });
151
+ }
128
152
 
129
153
  const CATALOG_USER_AGENT = USER_AGENT;
130
154
 
@@ -134,52 +158,66 @@ const CATALOG_USER_AGENT = USER_AGENT;
134
158
  * The frame magic is sniffed rather than trusting content-type so plain-JSON
135
159
  * responses (test stubs, fallback mirrors) parse identically.
136
160
  *
137
- * Fetched fully once per process: concurrent callers share the in-flight
138
- * request, repeat callers send a conditional GET that the server answers
139
- * (and deliberately does not log) with `304`.
161
+ * Fetched fully once per fetch context: concurrent callers sharing a fetch
162
+ * implementation reuse one transport request, while repeat callers send a
163
+ * conditional GET that the server answers with `304`. Each subscriber may stop
164
+ * waiting independently; the shared transport retains its own hard deadline.
165
+ * Transient failures reuse the last in-memory payload for callers that only
166
+ * need best-effort metadata.
140
167
  */
141
168
  export function fetchWellKnownModels(fetchImpl?: FetchImpl, signal?: AbortSignal): Promise<unknown> {
142
- if (!catalogSession.inflight) {
143
- catalogSession.inflight = fetchCatalogPayload(fetchImpl ?? discoveryFetch(), signal).finally(() => {
144
- catalogSession.inflight = null;
169
+ const session = getCatalogSession(fetchImpl);
170
+ return fetchRevalidatedWellKnownModels(fetchImpl, signal).catch(error => {
171
+ if (session.hasPayload) return session.payload;
172
+ throw error;
173
+ });
174
+ }
175
+
176
+ function fetchRevalidatedWellKnownModels(fetchImpl?: FetchImpl, signal?: AbortSignal): Promise<unknown> {
177
+ const session = getCatalogSession(fetchImpl);
178
+ if (!session.inflight) {
179
+ session.inflight = withCatalogDiscoveryTimeout(DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS, transportSignal =>
180
+ fetchCatalogPayload(fetchImpl ?? discoveryFetch(), session, transportSignal),
181
+ ).finally(() => {
182
+ session.inflight = null;
145
183
  });
146
184
  }
147
- return catalogSession.inflight;
185
+ return waitForCatalogRequest(session.inflight, signal);
148
186
  }
149
187
 
150
- async function fetchCatalogPayload(fetchImpl: FetchImpl, signal?: AbortSignal): Promise<unknown> {
188
+ function fetchRevalidatedWellKnownModelsWithTimeout(
189
+ fetchImpl?: FetchImpl,
190
+ timeoutMs = DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS,
191
+ ): Promise<unknown> {
192
+ return withCatalogDiscoveryTimeout(timeoutMs, signal => fetchRevalidatedWellKnownModels(fetchImpl, signal));
193
+ }
194
+
195
+ async function fetchCatalogPayload(
196
+ fetchImpl: FetchImpl,
197
+ session: CatalogSession,
198
+ signal?: AbortSignal,
199
+ ): Promise<unknown> {
151
200
  const headers: Record<string, string> = {
152
201
  Accept: "application/zstd, application/json",
153
202
  "User-Agent": CATALOG_USER_AGENT,
154
203
  };
155
- if (catalogSession.hasPayload && catalogSession.etag) {
156
- headers["If-None-Match"] = catalogSession.etag;
157
- }
158
- let response: Response;
159
- try {
160
- response = await fetchImpl(MODELS_DEV_URL, { method: "GET", headers, signal });
161
- } catch (error) {
162
- if (catalogSession.hasPayload) {
163
- return catalogSession.payload;
164
- }
165
- throw error;
204
+ if (session.hasPayload && session.etag) {
205
+ headers["If-None-Match"] = session.etag;
166
206
  }
167
- if (response.status === 304 && catalogSession.hasPayload) {
168
- return catalogSession.payload;
207
+ const response = await fetchImpl(MODELS_DEV_URL, { method: "GET", headers, signal });
208
+ if (response.status === 304 && session.hasPayload) {
209
+ return session.payload;
169
210
  }
170
211
  if (!response.ok) {
171
- if (catalogSession.hasPayload) {
172
- return catalogSession.payload;
173
- }
174
212
  throw new Error(`models catalog fetch failed: ${response.status}`);
175
213
  }
176
214
  const bytes = new Uint8Array(await response.arrayBuffer());
177
215
  const isZstd = bytes.length >= 4 && new DataView(bytes.buffer, bytes.byteOffset).getUint32(0, true) === ZSTD_MAGIC;
178
216
  const text = new TextDecoder().decode(isZstd ? await Bun.zstdDecompress(bytes) : bytes);
179
217
  const payload: unknown = JSON.parse(text);
180
- catalogSession.payload = payload;
181
- catalogSession.etag = response.headers.get("etag");
182
- catalogSession.hasPayload = true;
218
+ session.payload = payload;
219
+ session.etag = response.headers.get("etag");
220
+ session.hasPayload = true;
183
221
  return payload;
184
222
  }
185
223
 
@@ -2785,6 +2823,9 @@ const OPENCODE_GO_API_ID_OVERRIDES: Readonly<Record<string, Api>> = {
2785
2823
  "qwen3.5-plus": "openai-completions",
2786
2824
  "qwen3.6-plus": "openai-completions",
2787
2825
  };
2826
+ // Runtime-discovered rows cached before model-identity corrections retain
2827
+ // stale capability metadata until the authoritative catalog TTL expires.
2828
+ const OPENCODE_CACHE_MIGRATION_MODEL_IDS = ["glm-5.3-flash"] as const;
2788
2829
 
2789
2830
  // Billing-variant suffixes the OpenCode gateways append to a base model id
2790
2831
  // without changing its transport (`deepseek-v4-flash-free`,
@@ -2845,14 +2886,12 @@ function openCodeModelManagerOptions(
2845
2886
  providerId,
2846
2887
  cacheProviderId: resolveModelCacheProviderId(providerId, { apiKey, baseUrl: discoveryBaseUrl }),
2847
2888
  dynamicModelsAuthoritative: true,
2848
- // The per-id API pins are cache identity: without this, rows cached
2849
- // before a pin was added keep the wrong endpoint until TTL expiry
2850
- // (#8957 — 17.3.7 caches held muse-spark-1.2[-contributor] on chat
2851
- // completions after the pin shipped). Sibling-catalog drift is bounded
2852
- // by the 2h cache TTL instead.
2853
- dropCachedModelIdsOnStaticMismatch: Object.keys(apiOverrides),
2889
+ // Per-id route pins and capability migrations are cache identity:
2890
+ // without this, rows cached before a correction keep the stale route or
2891
+ // thinking surface until TTL expiry (#8957, #9960).
2892
+ dropCachedModelIdsOnStaticMismatch: [...Object.keys(apiOverrides), ...OPENCODE_CACHE_MIGRATION_MODEL_IDS],
2854
2893
  modelsDev: {
2855
- fetch: () => fetchWellKnownModels(config?.fetch),
2894
+ fetch: () => fetchRevalidatedWellKnownModelsWithTimeout(config?.fetch),
2856
2895
  map: payload => {
2857
2896
  if (!isRecord(payload)) return [];
2858
2897
  return mapModelsDevToModels(payload, OPENCODE_MODELS_DEV_DESCRIPTORS)
@@ -5054,6 +5093,7 @@ type LiteLLMRichEndpointModel<TApi extends Api> = {
5054
5093
  hasToolMetadata: boolean;
5055
5094
  hasSupportedOpenAIParams: boolean;
5056
5095
  hasCost: boolean;
5096
+ reportedCost: Partial<ModelSpec<Api>["cost"]>;
5057
5097
  };
5058
5098
  type LiteLLMRichEndpointFailure = {
5059
5099
  endpoint: string;
@@ -5178,30 +5218,36 @@ function getLiteLLMParams(entry: LiteLLMRichModelEntry): LiteLLMRichModelEntry |
5178
5218
  function getLiteLLMMetadataValue(entry: LiteLLMRichModelEntry, key: string): unknown {
5179
5219
  return entry[key] ?? getLiteLLMModelInfo(entry)?.[key];
5180
5220
  }
5181
-
5182
- /** Per-million USD cost from a `*_per_token` LiteLLM field, or `undefined` when absent/non-positive. */
5221
+ /** Per-million USD cost from a positive `*_per_token` LiteLLM field. */
5183
5222
  function getLiteLLMPerMillionCost(entry: LiteLLMRichModelEntry, key: string): number | undefined {
5184
5223
  const perToken = toNumber(getLiteLLMMetadataValue(entry, key));
5185
5224
  return perToken !== undefined && perToken > 0 ? perToken * 1_000_000 : undefined;
5186
5225
  }
5187
5226
 
5188
- /**
5189
- * Map LiteLLM's per-token pricing (`input_cost_per_token`, `output_cost_per_token`,
5190
- * cache costs) onto {@link ModelSpec.cost} in $/million tokens. Returns `undefined`
5191
- * when LiteLLM reports neither an input nor an output price so callers keep the
5192
- * bundled reference cost.
5193
- */
5194
- function getLiteLLMCost(entry: LiteLLMRichModelEntry): ModelSpec<Api>["cost"] | undefined {
5227
+ /** Map positive LiteLLM per-token prices onto their per-million cost fields. */
5228
+ function getLiteLLMReportedCost(entry: LiteLLMRichModelEntry): Partial<ModelSpec<Api>["cost"]> {
5195
5229
  const input = getLiteLLMPerMillionCost(entry, "input_cost_per_token");
5196
5230
  const output = getLiteLLMPerMillionCost(entry, "output_cost_per_token");
5197
- if (input === undefined && output === undefined) {
5231
+ const cacheRead = getLiteLLMPerMillionCost(entry, "cache_read_input_token_cost");
5232
+ const cacheWrite = getLiteLLMPerMillionCost(entry, "cache_creation_input_token_cost");
5233
+ return {
5234
+ ...(input !== undefined ? { input } : {}),
5235
+ ...(output !== undefined ? { output } : {}),
5236
+ ...(cacheRead !== undefined ? { cacheRead } : {}),
5237
+ ...(cacheWrite !== undefined ? { cacheWrite } : {}),
5238
+ };
5239
+ }
5240
+
5241
+ function getLiteLLMCost(entry: LiteLLMRichModelEntry): ModelSpec<Api>["cost"] | undefined {
5242
+ const cost = getLiteLLMReportedCost(entry);
5243
+ if (cost.input === undefined && cost.output === undefined) {
5198
5244
  return undefined;
5199
5245
  }
5200
5246
  return {
5201
- input: input ?? 0,
5202
- output: output ?? 0,
5203
- cacheRead: getLiteLLMPerMillionCost(entry, "cache_read_input_token_cost") ?? 0,
5204
- cacheWrite: getLiteLLMPerMillionCost(entry, "cache_creation_input_token_cost") ?? 0,
5247
+ input: cost.input ?? 0,
5248
+ output: cost.output ?? 0,
5249
+ cacheRead: cost.cacheRead ?? 0,
5250
+ cacheWrite: cost.cacheWrite ?? 0,
5205
5251
  };
5206
5252
  }
5207
5253
 
@@ -5352,12 +5398,29 @@ function mapLiteLLMRichEntry<TApi extends Api>(
5352
5398
  ["tools", "tool_choice", "functions", "function_call"].includes(param),
5353
5399
  )
5354
5400
  : reference?.supportsTools;
5401
+ // Enrich from the bundled reference with provider-INDEPENDENT reasoning
5402
+ // hints only. The reference is resolved against the global bundled catalog,
5403
+ // so a custom endpoint exposing an alias that collides with a bundled model
5404
+ // (a LiteLLM proxy serving `kimi-k3`, which matches Fireworks' bundled
5405
+ // `kimi-k3`) must not inherit that provider's transport compat. Spreading
5406
+ // the resolved `reference.compat` wholesale leaked `wireModelIdMode`,
5407
+ // `toolSchemaFlavor`, `thinkingFormat`, etc. across the provider boundary —
5408
+ // rewriting the wire id to `accounts/fireworks/models/kimi-k3` for a
5409
+ // non-Fireworks endpoint (issue #9938). `buildModel` re-derives every
5410
+ // transport field from the discovered provider and model id, so only the
5411
+ // effort vocabulary flows through here. Mirrors `discoverOpenAIModelsList`.
5412
+ const referenceCompat = reference?.compat as OpenAICompat | undefined;
5355
5413
  const compat: OpenAICompat = {
5356
- ...(reference?.compat ?? {}),
5357
5414
  supportsStore: false,
5358
5415
  supportsDeveloperRole: false,
5359
5416
  ...(supportedOpenAIParams !== undefined
5360
5417
  ? { supportsReasoningEffort: supportedOpenAIParams.includes("reasoning_effort") }
5418
+ : referenceCompat?.supportsReasoningEffort !== undefined
5419
+ ? { supportsReasoningEffort: referenceCompat.supportsReasoningEffort }
5420
+ : {}),
5421
+ ...(referenceCompat?.reasoningEffortMap ? { reasoningEffortMap: referenceCompat.reasoningEffortMap } : {}),
5422
+ ...(referenceCompat?.omitReasoningEffort !== undefined
5423
+ ? { omitReasoningEffort: referenceCompat.omitReasoningEffort }
5361
5424
  : {}),
5362
5425
  };
5363
5426
  return {
@@ -5401,13 +5464,23 @@ function mergeLiteLLMRichEndpointModels<TApi extends Api>(
5401
5464
  maxTokens: next.hasMaxTokens ? next.model.maxTokens : existing.model.maxTokens,
5402
5465
  input: next.supportsVision === true || next.supportsVision === false ? next.model.input : existing.model.input,
5403
5466
  reasoning: typeof next.supportsReasoning === "boolean" ? next.model.reasoning : existing.model.reasoning,
5404
- cost: next.hasCost ? next.model.cost : existing.model.cost,
5467
+ cost: { ...existing.model.cost, ...existing.reportedCost, ...next.reportedCost },
5405
5468
  compat: next.hasSupportedOpenAIParams ? next.model.compat : existing.model.compat,
5406
5469
  };
5407
5470
  if (next.hasToolMetadata) {
5408
5471
  model.supportsTools = next.model.supportsTools;
5409
5472
  }
5410
- return { ...next, apiRoute, model };
5473
+ return {
5474
+ ...next,
5475
+ apiRoute,
5476
+ model,
5477
+ reportedCost: { ...existing.reportedCost, ...next.reportedCost },
5478
+ hasContextWindow: existing.hasContextWindow || next.hasContextWindow,
5479
+ hasMaxTokens: existing.hasMaxTokens || next.hasMaxTokens,
5480
+ hasToolMetadata: existing.hasToolMetadata || next.hasToolMetadata,
5481
+ hasSupportedOpenAIParams: existing.hasSupportedOpenAIParams || next.hasSupportedOpenAIParams,
5482
+ hasCost: existing.hasCost || next.hasCost,
5483
+ };
5411
5484
  }
5412
5485
 
5413
5486
  async function fetchLiteLLMRichEndpoint<TApi extends Api>(
@@ -5469,6 +5542,7 @@ async function fetchLiteLLMRichEndpoint<TApi extends Api>(
5469
5542
  supportedOpenAIParams !== undefined,
5470
5543
  hasSupportedOpenAIParams: supportedOpenAIParams !== undefined,
5471
5544
  hasCost: getLiteLLMCost(entry) !== undefined,
5545
+ reportedCost: getLiteLLMReportedCost(entry),
5472
5546
  };
5473
5547
  const existing = deduped.get(model.id);
5474
5548
  deduped.set(model.id, existing ? mergeLiteLLMRichEndpointModels(existing, next) : next);
@@ -5530,7 +5604,12 @@ async function fetchLiteLLMRichModelsInternal<TApi extends Api>(
5530
5604
  for (const entry of deduped.values()) {
5531
5605
  if (
5532
5606
  (entry.supportsVision !== true && entry.supportsVision !== false) ||
5533
- (options.resolveApi !== undefined && entry.apiRoute === "unknown")
5607
+ (options.resolveApi !== undefined && entry.apiRoute === "unknown") ||
5608
+ (Object.keys(entry.reportedCost).length > 0 &&
5609
+ (entry.reportedCost.input === undefined ||
5610
+ entry.reportedCost.output === undefined ||
5611
+ entry.reportedCost.cacheRead === undefined ||
5612
+ entry.reportedCost.cacheWrite === undefined))
5534
5613
  ) {
5535
5614
  needsMoreMetadata = true;
5536
5615
  break;
@@ -5567,12 +5646,14 @@ export function litellmModelManagerOptions(config?: LiteLLMModelManagerConfig):
5567
5646
  const baseUrl = config?.baseUrl ?? getDefaultModelDiscoveryBaseUrl("litellm")!;
5568
5647
  return {
5569
5648
  providerId: "litellm",
5570
- // rich-v6 invalidates rows cached before OpenAI models moved to Responses.
5571
- // Earlier versions added bundled reference fallback, continued discovery
5572
- // past incomplete `/model_group/info`, stripped reseller usage suffixes,
5573
- // filtered placeholder-only `all-team-models` rows, and mapped rich pricing.
5574
- // Bump the version whenever the mappers below change, or warm authoritative
5575
- // caches keep serving pre-change rows for the full TTL.
5649
+ // rich-v8 invalidates rows whose `compatConfig` retained a colliding
5650
+ // bundled model's provider-specific transport (e.g. Fireworks
5651
+ // `wireModelIdMode`) before that leak was fixed. Earlier versions added
5652
+ // bundled reference fallback, moved OpenAI models to Responses, continued
5653
+ // past incomplete vision/API metadata and endpoints omitting cache
5654
+ // pricing, stripped reseller usage suffixes, filtered placeholder rows,
5655
+ // and mapped rich pricing. Bump the version whenever these mappers change,
5656
+ // or warm authoritative caches keep serving pre-change rows for the full TTL.
5576
5657
  cacheProviderId: resolveModelCacheProviderId("litellm", { baseUrl }),
5577
5658
  // litellm is a local-only proxy and is never bundled in models.json (that
5578
5659
  // would leak the machine's localhost catalog). Prefer the proxy's richer
@@ -6095,7 +6176,7 @@ export function anthropicModelManagerOptions(
6095
6176
  return {
6096
6177
  providerId: "anthropic",
6097
6178
  modelsDev: {
6098
- fetch: () => fetchWellKnownModels(config?.fetch),
6179
+ fetch: () => fetchRevalidatedWellKnownModelsWithTimeout(config?.fetch),
6099
6180
  map: payload => mapAnthropicModelsDev(payload, baseUrl),
6100
6181
  },
6101
6182
  ...(apiKey && {
@@ -6767,3 +6848,42 @@ export const MODELS_DEV_PROVIDER_DESCRIPTORS: readonly ModelsDevProviderDescript
6767
6848
  ...MODELS_DEV_PROVIDER_DESCRIPTORS_CODING_PLANS,
6768
6849
  ...MODELS_DEV_PROVIDER_DESCRIPTORS_SPECIALIZED,
6769
6850
  ];
6851
+
6852
+ const MODELS_DEV_DESCRIPTORS_BY_PROVIDER: Record<string, ModelsDevProviderDescriptor[]> = Object.create(null);
6853
+ for (const descriptor of MODELS_DEV_PROVIDER_DESCRIPTORS) {
6854
+ const providerDescriptors = MODELS_DEV_DESCRIPTORS_BY_PROVIDER[descriptor.providerId];
6855
+ if (providerDescriptors) {
6856
+ providerDescriptors.push(descriptor);
6857
+ } else {
6858
+ MODELS_DEV_DESCRIPTORS_BY_PROVIDER[descriptor.providerId] = [descriptor];
6859
+ }
6860
+ }
6861
+
6862
+ /** Providers whose bundled catalog can receive additive models.dev updates at runtime. */
6863
+ export const MODELS_DEV_CATALOG_PROVIDER_IDS: readonly string[] = Object.freeze(
6864
+ Object.keys(MODELS_DEV_DESCRIPTORS_BY_PROVIDER),
6865
+ );
6866
+
6867
+ /**
6868
+ * Build the shared models.dev fallback for one known provider.
6869
+ *
6870
+ * Provider managers sharing one fetch implementation reuse its conditional
6871
+ * catalog session. Each mapped provider slice is persisted independently so
6872
+ * startup can restore it without parsing the full catalog.
6873
+ *
6874
+ * `timeoutMs` bounds the catalog request. It is configurable for callers with a
6875
+ * stricter startup budget and for deterministic timeout tests.
6876
+ */
6877
+ export function modelsDevCatalogFallback(
6878
+ providerId: string,
6879
+ fetchImpl?: FetchImpl,
6880
+ timeoutMs = DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS,
6881
+ ): ModelsDevFallback<Api> | undefined {
6882
+ const descriptors = MODELS_DEV_DESCRIPTORS_BY_PROVIDER[providerId];
6883
+ if (!descriptors) return undefined;
6884
+ return {
6885
+ additiveOnly: true,
6886
+ fetch: () => fetchRevalidatedWellKnownModelsWithTimeout(fetchImpl, timeoutMs),
6887
+ map: payload => (isRecord(payload) ? filterModelsDevCatalogRows(mapModelsDevToModels(payload, descriptors)) : []),
6888
+ };
6889
+ }
@@ -307,7 +307,7 @@ function geminiLevelFlashFamily(version: "3.6" | "3.7", ...additionalMembers: st
307
307
  }
308
308
 
309
309
  const GEMINI_36_FLASH_FAMILY = geminiLevelFlashFamily("3.6", "gemini-3.6-flash-tiered");
310
- const GEMINI_37_FLASH_FAMILY = geminiLevelFlashFamily("3.7");
310
+ const GEMINI_37_FLASH_FAMILY = geminiLevelFlashFamily("3.7", "gemini-3.7-flash-tiered");
311
311
 
312
312
  function geminiProFamily(mode: "budget" | "google-level"): EffortVariantFamily {
313
313
  const budget = mode === "budget";
@@ -0,0 +1,44 @@
1
+ import { isRecord } from "../utils";
2
+
3
+ /** Provider-native AI Gateway prefix before the upstream route segment. */
4
+ export const CLOUDFLARE_AI_GATEWAY_BASE_URL = "https://gateway.ai.cloudflare.com/v1/<account>/<gateway>";
5
+ /** Anthropic Messages passthrough endpoint. */
6
+ export const CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL = `${CLOUDFLARE_AI_GATEWAY_BASE_URL}/anthropic`;
7
+ /** OpenAI Responses passthrough endpoint. */
8
+ export const CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL = `${CLOUDFLARE_AI_GATEWAY_BASE_URL}/openai`;
9
+ /** OpenAI-compatible Workers AI endpoint. */
10
+ export const CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL = `${CLOUDFLARE_AI_GATEWAY_BASE_URL}/compat`;
11
+
12
+ /** Opaque API-key credential payload persisted by Cloudflare AI Gateway login. */
13
+ export interface CloudflareAiGatewayCredential {
14
+ token: string;
15
+ accountId?: string;
16
+ gatewayId?: string;
17
+ }
18
+
19
+ /** Parse both structured login credentials and legacy plain gateway tokens. */
20
+ export function parseCloudflareAiGatewayCredential(value: string): CloudflareAiGatewayCredential | null {
21
+ const trimmed = value.trim();
22
+ if (!trimmed) return null;
23
+ if (!trimmed.startsWith("{")) return { token: trimmed };
24
+ try {
25
+ const parsed: unknown = JSON.parse(trimmed);
26
+ if (!isRecord(parsed)) return null;
27
+ if (typeof parsed.token !== "string" || !parsed.token.trim()) return null;
28
+ if (parsed.accountId !== undefined && typeof parsed.accountId !== "string") return null;
29
+ if (parsed.gatewayId !== undefined && typeof parsed.gatewayId !== "string") return null;
30
+ const credential: CloudflareAiGatewayCredential = { token: parsed.token.trim() };
31
+ const accountId = parsed.accountId?.trim();
32
+ const gatewayId = parsed.gatewayId?.trim();
33
+ if (accountId) credential.accountId = accountId;
34
+ if (gatewayId) credential.gatewayId = gatewayId;
35
+ return credential;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /** Serialize the gateway token with the routing identifiers collected during login. */
42
+ export function serializeCloudflareAiGatewayCredential(token: string, accountId: string, gatewayId: string): string {
43
+ return JSON.stringify({ token: token.trim(), accountId: accountId.trim(), gatewayId: gatewayId.trim() });
44
+ }