@oh-my-pi/pi-catalog 18.2.6 → 18.2.7

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/THIRD-PARTY-NOTICES.txt +0 -37
  3. package/dist/types/build.d.ts +7 -1
  4. package/dist/types/compat/auth-ids.d.ts +1 -1
  5. package/dist/types/compat/axes.d.ts +1 -1
  6. package/dist/types/compat/cascade.d.ts +3 -2
  7. package/dist/types/compat/provider-ids.d.ts +1 -1
  8. package/dist/types/compat/resolve.d.ts +2 -0
  9. package/dist/types/compat/taxonomy.d.ts +2 -2
  10. package/dist/types/compat/types.d.ts +18 -7
  11. package/dist/types/discovery/index.d.ts +1 -0
  12. package/dist/types/discovery/openai-compatible.d.ts +2 -0
  13. package/dist/types/discovery/typesafe.d.ts +26 -0
  14. package/dist/types/model-manager.d.ts +1 -1
  15. package/dist/types/provider-models/openai-compat.d.ts +6 -4
  16. package/dist/types/provider-models/special.d.ts +10 -0
  17. package/dist/types/types.d.ts +20 -0
  18. package/package.json +4 -4
  19. package/src/build.ts +27 -5
  20. package/src/compat/auth-ids.ts +2 -0
  21. package/src/compat/axes.ts +9 -1
  22. package/src/compat/cascade.ts +45 -23
  23. package/src/compat/provider-ids.ts +3 -0
  24. package/src/compat/resolve.ts +24 -14
  25. package/src/compat/rules/README.md +5 -3
  26. package/src/compat/rules/auth/local.kdl +4 -0
  27. package/src/compat/rules/auth/typesafe.kdl +3 -6
  28. package/src/compat/rules/auth/web.kdl +4 -0
  29. package/src/compat/rules/classes/qwen.kdl +6 -5
  30. package/src/compat/rules/providers/anthropic.kdl +1 -0
  31. package/src/compat/rules/providers/deepinfra.kdl +28 -0
  32. package/src/compat/rules/providers/google-antigravity.kdl +17 -0
  33. package/src/compat/rules/providers/google.kdl +9 -0
  34. package/src/compat/rules/providers/llama.cpp.kdl +23 -0
  35. package/src/compat/rules/providers/local.kdl +110 -0
  36. package/src/compat/rules/providers/openai-codex.kdl +18 -0
  37. package/src/compat/rules/providers/openai.kdl +3 -0
  38. package/src/compat/rules/providers/openrouter.kdl +24 -0
  39. package/src/compat/rules/providers/typesafe.kdl +21 -0
  40. package/src/compat/rules/providers/web.kdl +153 -0
  41. package/src/compat/rules/providers/xai-oauth.kdl +26 -0
  42. package/src/compat/rules/providers/xai.kdl +22 -0
  43. package/src/compat/rules/runtime/behavior.kdl +2 -1
  44. package/src/compat/rules/taxonomy/qwen.kdl +2 -0
  45. package/src/compat/rules.json +1 -1
  46. package/src/compat/taxonomy.ts +92 -31
  47. package/src/compat/types.ts +22 -7
  48. package/src/discovery/index.ts +1 -0
  49. package/src/discovery/openai-compatible.ts +4 -1
  50. package/src/discovery/typesafe.ts +118 -0
  51. package/src/model-manager.ts +44 -15
  52. package/src/models.json +1 -1
  53. package/src/provider-models/descriptors.ts +6 -0
  54. package/src/provider-models/openai-compat.ts +246 -79
  55. package/src/provider-models/special.ts +53 -0
  56. package/src/types.ts +33 -0
@@ -14,13 +14,13 @@ import { formatRevision, parseRevisionPrefix } from "./revision";
14
14
  import rules from "./rules.json";
15
15
  import type { CompiledClass, CompiledIdentityOverride, CompiledMatcher, ModelIdentity } from "./types";
16
16
 
17
- /** Two classes (or two families) tie for one model id. */
17
+ /** Two classes, families, or reviewed override patterns tie for one model id. */
18
18
  export class AmbiguousIdentityError extends Error {
19
19
  constructor(
20
20
  readonly model: string,
21
21
  readonly first: string,
22
22
  readonly second: string,
23
- kind: "class" | "family",
23
+ kind: "class" | "family" | "override",
24
24
  ) {
25
25
  super(`ambiguous ${kind} for \`${model}\`: \`${first}\` and \`${second}\` tie`);
26
26
  this.name = "AmbiguousIdentityError";
@@ -201,27 +201,50 @@ function ranksInClass(classId: string, model: string, lenient: boolean): Omit<Cl
201
201
  return out;
202
202
  }
203
203
 
204
- // Lowercase override index built once per process: avoids re-lowercasing every
205
- // override model/provider string on every classifyModel call. First override
206
- // in tree order wins per (model, provider) slot, matching the linear scan it
207
- // replaced.
204
+ // Exact overrides retain a direct lowercase index. Pattern overrides are few
205
+ // and remain in one static list, ranked only after exact lookup misses.
208
206
  interface OverrideBucket {
209
207
  byProvider: Map<string, CompiledIdentityOverride>;
210
208
  agnostic: CompiledIdentityOverride | undefined;
211
209
  }
212
210
 
213
- let overrideIndex: Map<string, OverrideBucket> | undefined;
211
+ interface IndexedPatternOverride {
212
+ override: CompiledIdentityOverride;
213
+ glob: string;
214
+ provider?: string;
215
+ specificity: number;
216
+ }
214
217
 
215
- function getOverrideIndex(): Map<string, OverrideBucket> {
216
- if (overrideIndex !== undefined) return overrideIndex;
217
- const index = new Map<string, OverrideBucket>();
218
- for (const cls of rules.taxonomy.classes) {
218
+ interface OverrideIndexes {
219
+ exact: Map<string, OverrideBucket>;
220
+ patterns: IndexedPatternOverride[];
221
+ }
222
+
223
+ let overrideIndexes: OverrideIndexes | undefined;
224
+
225
+ function getOverrideIndexes(): OverrideIndexes {
226
+ if (overrideIndexes !== undefined) return overrideIndexes;
227
+ const exact = new Map<string, OverrideBucket>();
228
+ const patterns: IndexedPatternOverride[] = [];
229
+ const classes: readonly CompiledClass[] = rules.taxonomy.classes;
230
+ for (const cls of classes) {
219
231
  for (const override of cls.overrides) {
232
+ if (override.model === undefined) {
233
+ if (override.glob !== undefined) {
234
+ patterns.push({
235
+ override,
236
+ glob: override.glob,
237
+ provider: override.provider?.toLowerCase(),
238
+ specificity: nonWildcardBytes(override.glob),
239
+ });
240
+ }
241
+ continue;
242
+ }
220
243
  const key = override.model.toLowerCase();
221
- let bucket = index.get(key);
244
+ let bucket = exact.get(key);
222
245
  if (bucket === undefined) {
223
246
  bucket = { byProvider: new Map(), agnostic: undefined };
224
- index.set(key, bucket);
247
+ exact.set(key, bucket);
225
248
  }
226
249
  if (override.provider !== undefined) {
227
250
  const providerKey = override.provider.toLowerCase();
@@ -231,30 +254,68 @@ function getOverrideIndex(): Map<string, OverrideBucket> {
231
254
  }
232
255
  }
233
256
  }
234
- overrideIndex = index;
235
- return index;
257
+ overrideIndexes = { exact, patterns };
258
+ return overrideIndexes;
259
+ }
260
+
261
+ function overrideIsActive(override: CompiledIdentityOverride, observedAtMs: number | undefined): boolean {
262
+ return override.expiresAtMs === undefined || observedAtMs === undefined || observedAtMs < override.expiresAtMs;
263
+ }
264
+
265
+ interface PatternOverrideRanking {
266
+ winner?: { specificity: number; override: CompiledIdentityOverride };
267
+ tied?: readonly [CompiledIdentityOverride, CompiledIdentityOverride];
268
+ }
269
+
270
+ function rankPatternOverrides(
271
+ patterns: readonly IndexedPatternOverride[],
272
+ provider: string | undefined,
273
+ bareModel: string,
274
+ observedAtMs: number | undefined,
275
+ ): PatternOverrideRanking {
276
+ let winner: { specificity: number; override: CompiledIdentityOverride } | undefined;
277
+ let tied: readonly [CompiledIdentityOverride, CompiledIdentityOverride] | undefined;
278
+ for (const pattern of patterns) {
279
+ const { override, specificity } = pattern;
280
+ if (pattern.provider !== provider || !overrideIsActive(override, observedAtMs)) continue;
281
+ if (!globMatch(pattern.glob, bareModel)) continue;
282
+ if (winner?.specificity === specificity) {
283
+ tied = [winner.override, override];
284
+ } else if (winner === undefined || winner.specificity < specificity) {
285
+ winner = { specificity, override };
286
+ tied = undefined;
287
+ }
288
+ }
289
+ return { winner, tied };
236
290
  }
237
291
 
238
292
  function findIdentityOverride(
239
293
  provider: string,
240
294
  bareModel: string,
241
295
  observedAtMs: number | undefined,
296
+ lenient: boolean,
242
297
  ): CompiledIdentityOverride | undefined {
243
- const bucket = getOverrideIndex().get(bareModel.toLowerCase());
244
- if (bucket === undefined) return undefined;
245
- const scoped = bucket.byProvider.get(provider.toLowerCase());
246
- if (scoped !== undefined) {
247
- if (scoped.expiresAtMs === undefined || observedAtMs === undefined || observedAtMs < scoped.expiresAtMs) {
248
- return scoped;
249
- }
298
+ const lowerProvider = provider.toLowerCase();
299
+ const lowerBareModel = bareModel.toLowerCase();
300
+ const indexes = getOverrideIndexes();
301
+ const bucket = indexes.exact.get(lowerBareModel);
302
+ if (bucket !== undefined) {
303
+ const scoped = bucket.byProvider.get(lowerProvider);
304
+ if (scoped !== undefined && overrideIsActive(scoped, observedAtMs)) return scoped;
305
+ const agnostic = bucket.agnostic;
306
+ if (agnostic !== undefined && overrideIsActive(agnostic, observedAtMs)) return agnostic;
250
307
  }
251
- const agnostic = bucket.agnostic;
252
- if (agnostic !== undefined) {
253
- if (agnostic.expiresAtMs === undefined || observedAtMs === undefined || observedAtMs < agnostic.expiresAtMs) {
254
- return agnostic;
255
- }
308
+
309
+ const scoped = rankPatternOverrides(indexes.patterns, lowerProvider, lowerBareModel, observedAtMs);
310
+ const ranked =
311
+ scoped.winner === undefined && scoped.tied === undefined
312
+ ? rankPatternOverrides(indexes.patterns, undefined, lowerBareModel, observedAtMs)
313
+ : scoped;
314
+ if (ranked.tied !== undefined) {
315
+ if (lenient) return undefined;
316
+ throw new AmbiguousIdentityError(lowerBareModel, ranked.tied[0].id, ranked.tied[1].id, "override");
256
317
  }
257
- return undefined;
318
+ return ranked.winner?.override;
258
319
  }
259
320
 
260
321
  /** Result of collapsing a wire id through the suffix vocabulary. */
@@ -369,8 +430,8 @@ export function stripThinkingVariantSuffix(model: string): string | undefined {
369
430
  * other model classified under the same key. Calls with an explicit
370
431
  * observedAtMs skip the memo so override expiry stays exact.
371
432
  *
372
- * @throws AmbiguousIdentityError on equal-rank cross-class or cross-family
373
- * matches unless `opts.lenient`.
433
+ * @throws AmbiguousIdentityError on equal-rank cross-class, cross-family, or
434
+ * reviewed-pattern matches unless `opts.lenient`.
374
435
  */
375
436
  const classifyMemo = new Map<string, ModelIdentity>();
376
437
  const CLASSIFY_MEMO_MAX = 4096;
@@ -396,7 +457,7 @@ function classifyModelUncached(provider: string, modelId: string, opts?: Classif
396
457
  const lenient = opts?.lenient === true;
397
458
  const trimmed = modelId.trim();
398
459
  const bare = bareOf(trimmed);
399
- const override = findIdentityOverride(provider, bare, opts?.observedAtMs);
460
+ const override = findIdentityOverride(provider, bare, opts?.observedAtMs, lenient);
400
461
  if (override) {
401
462
  const logical = override.logical ?? trimmed;
402
463
  const cls = override.class ?? classifyRanks(logical, lenient).class;
@@ -5,7 +5,7 @@
5
5
  * `behavior.ts`, `resolve.ts`) exposes to consumers.
6
6
  */
7
7
  import type { Effort } from "../effort";
8
- import type { KnownApi, ThinkingControlMode, TokenCost } from "../types";
8
+ import type { Api, ThinkingControlMode, TokenCost } from "../types";
9
9
  import type { RevisionOp } from "./revision";
10
10
 
11
11
  /** Class-membership matcher kinds, most to least specific. */
@@ -32,11 +32,9 @@ export interface CompiledRevisionPrefix {
32
32
  anywhere?: boolean;
33
33
  }
34
34
 
35
- /** One compiled reviewed identity correction. */
36
- export interface CompiledIdentityOverride {
35
+ interface CompiledIdentityOverrideFields {
37
36
  id: string;
38
37
  provider?: string;
39
- model: string;
40
38
  logical?: string;
41
39
  class?: string;
42
40
  family?: string;
@@ -49,6 +47,21 @@ export interface CompiledIdentityOverride {
49
47
  expiresAtMs?: number;
50
48
  }
51
49
 
50
+ /** One compiled reviewed identity correction with exactly one bare-model selector. */
51
+ export type CompiledIdentityOverride = CompiledIdentityOverrideFields &
52
+ (
53
+ | {
54
+ /** Exact bare-model selector. */
55
+ model: string;
56
+ glob?: never;
57
+ }
58
+ | {
59
+ model?: never;
60
+ /** Anchored, case-insensitive bare-model glob. */
61
+ glob: string;
62
+ }
63
+ );
64
+
52
65
  /** One compiled model class: matchers, families, revision rules, overrides. */
53
66
  export interface CompiledClass {
54
67
  id: string;
@@ -588,7 +601,7 @@ export interface CompiledProviderDiscovery {
588
601
  export interface CompiledSeedModel {
589
602
  id: string;
590
603
  name: string;
591
- api: KnownApi;
604
+ api: Api;
592
605
  provider: string;
593
606
  baseUrl: string;
594
607
  reasoning: boolean;
@@ -615,8 +628,8 @@ export interface CompiledSeed {
615
628
  }
616
629
 
617
630
  /**
618
- * One chat-model provider's catalog entry: the non-code half of what the
619
- * runtime and generator know about a provider. A `providers/<id>.kdl` file
631
+ * One model provider's catalog entry: the non-code half of what the runtime
632
+ * and generator know about a provider. A `providers/<id>.kdl` file
620
633
  * declares one by carrying `default-model`; files without it are wire-compat
621
634
  * only (custom provider ids such as `llama.cpp`).
622
635
  */
@@ -634,6 +647,8 @@ export interface CompiledProvider {
634
647
  skipCrossProviderReferenceFills?: boolean;
635
648
  /** Present only for providers enrolled in `generate-models.ts` discovery. */
636
649
  discovery?: CompiledProviderDiscovery;
650
+ /** Non-chat model kinds mapped to their runtime transport APIs. */
651
+ kindApis?: Partial<Record<"image" | "tts" | "stt", Api>>;
637
652
  /** Authored bundled rows, when the provider cannot be discovered at generation time. */
638
653
  seed?: CompiledSeed;
639
654
  }
@@ -5,3 +5,4 @@ export * from "./gemini-cli";
5
5
  export * from "./gitlab-duo-workflow";
6
6
  export * from "./openai-compatible";
7
7
  export * from "./protobuf";
8
+ export * from "./typesafe";
@@ -106,6 +106,8 @@ export interface FetchOpenAICompatibleModelsOptions<TApi extends Api> {
106
106
  apiKey?: string;
107
107
  /** Additional request headers. */
108
108
  headers?: Record<string, string>;
109
+ /** Query parameters appended to the `/models` probe (e.g. OpenRouter's `output_modalities` roster filter). */
110
+ query?: Record<string, string>;
109
111
  /** Optional AbortSignal for request cancellation; caller owns its lifecycle. */
110
112
  signal?: AbortSignal;
111
113
  /**
@@ -155,10 +157,11 @@ export async function fetchOpenAICompatibleModels<TApi extends Api>(
155
157
  }
156
158
 
157
159
  const fetchImpl = discoveryFetch(options.fetch);
160
+ const query = options.query ? `?${new URLSearchParams(options.query)}` : "";
158
161
  const fetchPayload = async (signal?: AbortSignal): Promise<unknown | null> => {
159
162
  let response: Response;
160
163
  try {
161
- response = await fetchImpl(`${baseUrl}${MODELS_PATH}`, {
164
+ response = await fetchImpl(`${baseUrl}${MODELS_PATH}${query}`, {
162
165
  method: "GET",
163
166
  headers: requestHeaders,
164
167
  signal,
@@ -0,0 +1,118 @@
1
+ import type { FetchImpl, ModelSpec } from "../types";
2
+ import { discoveryFetch, isRecord } from "../utils";
3
+
4
+ /** Public TypeSafe API root shared by discovery and judgment requests. */
5
+ export const TYPESAFE_DEFAULT_BASE_URL = "https://api.typesafe.ai";
6
+
7
+ /** Wire card returned by TypeSafe's authenticated `GET /v1/models`. */
8
+ export interface TypeSafeModelCard {
9
+ name: string;
10
+ description: string;
11
+ release_date: string;
12
+ }
13
+
14
+ /** Credentials and transport overrides for TypeSafe account model discovery. */
15
+ export interface FetchTypeSafeModelsOptions {
16
+ apiKey: string;
17
+ baseUrl?: string;
18
+ fetch?: FetchImpl;
19
+ signal?: AbortSignal;
20
+ }
21
+
22
+ function normalizeBaseUrl(value: string): string | null {
23
+ try {
24
+ const url = new URL(value);
25
+ return url.toString().replace(/\/+$/, "");
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function parseModelCard(value: unknown): TypeSafeModelCard | null {
32
+ if (
33
+ !isRecord(value) ||
34
+ typeof value.name !== "string" ||
35
+ value.name.trim().length === 0 ||
36
+ typeof value.description !== "string" ||
37
+ typeof value.release_date !== "string"
38
+ ) {
39
+ return null;
40
+ }
41
+ return {
42
+ name: value.name.trim(),
43
+ description: value.description.trim(),
44
+ release_date: value.release_date,
45
+ };
46
+ }
47
+
48
+ /** Validate and de-duplicate the official `{ models: [...] }` response. */
49
+ export function parseTypeSafeModelCards(payload: unknown): TypeSafeModelCard[] | null {
50
+ if (!isRecord(payload) || !Array.isArray(payload.models)) return null;
51
+ const cards: TypeSafeModelCard[] = [];
52
+ const seen = new Set<string>();
53
+ for (const raw of payload.models) {
54
+ const card = parseModelCard(raw);
55
+ if (card === null) return null;
56
+ if (seen.has(card.name)) continue;
57
+ seen.add(card.name);
58
+ cards.push(card);
59
+ }
60
+ return cards;
61
+ }
62
+
63
+ /**
64
+ * Fetch TypeSafe's account-visible judge models.
65
+ *
66
+ * Returns `null` for transport, HTTP, or envelope failures. A valid empty
67
+ * roster returns `[]`, allowing the model manager to distinguish it from an
68
+ * unavailable discovery endpoint.
69
+ */
70
+ export async function fetchTypeSafeModels(
71
+ options: FetchTypeSafeModelsOptions,
72
+ ): Promise<ModelSpec<"typesafe">[] | null> {
73
+ const baseUrl = normalizeBaseUrl(options.baseUrl ?? TYPESAFE_DEFAULT_BASE_URL);
74
+ if (baseUrl === null) return null;
75
+
76
+ let response: Response;
77
+ try {
78
+ response = await discoveryFetch(options.fetch)(`${baseUrl}/v1/models`, {
79
+ method: "GET",
80
+ headers: {
81
+ Accept: "application/json",
82
+ Authorization: `Bearer ${options.apiKey}`,
83
+ },
84
+ signal: options.signal,
85
+ });
86
+ } catch {
87
+ return null;
88
+ }
89
+ if (!response.ok) return null;
90
+
91
+ let payload: unknown;
92
+ try {
93
+ payload = await response.json();
94
+ } catch {
95
+ return null;
96
+ }
97
+ const cards = parseTypeSafeModelCards(payload);
98
+ if (cards === null) return null;
99
+
100
+ const models: ModelSpec<"typesafe">[] = [];
101
+ for (const card of cards) {
102
+ models.push({
103
+ id: card.name,
104
+ name: card.description || card.name,
105
+ api: "typesafe",
106
+ provider: "typesafe",
107
+ baseUrl,
108
+ kind: "judge",
109
+ reasoning: false,
110
+ input: ["text"],
111
+ supportsTools: false,
112
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
113
+ contextWindow: null,
114
+ maxTokens: null,
115
+ });
116
+ }
117
+ return models;
118
+ }
@@ -5,7 +5,15 @@ import { applyCatalogMetrics, CatalogMetricsIndex } from "./identity/metrics";
5
5
  import { readModelCache, writeModelCache } from "./model-cache";
6
6
  import { type GeneratedProvider, getBundledModels } from "./models";
7
7
  import { isTimeBasedCost } from "./pricing";
8
- import type { Api, Model, ModelCost, ModelSpec, Provider, TokenCost } from "./types";
8
+ import {
9
+ type Api,
10
+ type Model,
11
+ type ModelCost,
12
+ modelKind,
13
+ type ModelSpec,
14
+ type Provider,
15
+ type TokenCost,
16
+ } from "./types";
9
17
  import { isRecord } from "./utils";
10
18
 
11
19
  const DEFAULT_CACHE_TTL_MS = 2 * 60 * 60 * 1000;
@@ -292,7 +300,7 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
292
300
  ? await Promise.all([fetchModelsDev(options), dynamicFetcher ? fetchDynamicModels(dynamicFetcher) : null])
293
301
  : [null, null];
294
302
  const modelsDevFetchSucceeded = fetchedModelsDevModels !== null;
295
- const normalizedModelsDevModels = fetchedModelsDevModels ?? [];
303
+ const normalizedModelsDevModels = fetchedModelsDevModels?.models ?? [];
296
304
  const modelsDevModels = additiveStaticModelIds
297
305
  ? normalizedModelsDevModels.filter(model => !additiveStaticModelIds.has(model.id))
298
306
  : normalizedModelsDevModels;
@@ -319,7 +327,7 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
319
327
  const cacheModels = additiveStaticModelIds
320
328
  ? preparedCacheModels.filter(model => !additiveStaticModelIds.has(model.id))
321
329
  : preparedCacheModels;
322
- const dynamicModels = fetchedDynamicModels ?? [];
330
+ const dynamicModels = fetchedDynamicModels?.models ?? [];
323
331
  // A successful empty endpoint result stays authoritative for THIS cycle (so an
324
332
  // intentional catalog emptying still prunes removed models downstream), but
325
333
  // is NOT pinned into the cache as authoritative — that would suppress the
@@ -331,12 +339,20 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
331
339
  (dynamicModelsAuthoritative || !hasModelsDevFetcher || modelsDevFetchSucceeded)
332
340
  : modelsDevFetchSucceeded;
333
341
  const mergedWithCache = mergeDynamicModels(staticModels, cacheModels);
334
- const mergedWithModelsDev = mergeDynamicModels(mergedWithCache, modelsDevModels);
342
+ const mergedWithModelsDev = mergeDynamicModels(
343
+ mergedWithCache,
344
+ modelsDevModels,
345
+ fetchedModelsDevModels?.explicitKindModels,
346
+ );
335
347
  const catalogMetricsSource = modelsDevFetchSucceeded ? normalizedModelsDevModels : preparedCacheModels;
336
348
  const mergedWithCatalogMetrics = additiveStaticModelIds
337
349
  ? mergeCatalogMetrics(mergedWithModelsDev, catalogMetricsSource)
338
350
  : mergedWithModelsDev;
339
- const mergedModels = mergeDynamicModels(mergedWithCatalogMetrics, dynamicModels);
351
+ const mergedModels = mergeDynamicModels(
352
+ mergedWithCatalogMetrics,
353
+ dynamicModels,
354
+ fetchedDynamicModels?.explicitKindModels,
355
+ );
340
356
  const models = collapseBuiltVariants(
341
357
  authoritativeDynamicFetchSucceeded ? retainModelIds(mergedModels, dynamicModels) : mergedModels,
342
358
  );
@@ -423,9 +439,15 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
423
439
  };
424
440
  }
425
441
 
442
+ /** Materialized discovery rows plus kind provenance captured before policy can supply a KDL kind. */
443
+ interface DiscoveredModelSet<TApi extends Api> {
444
+ models: Model<TApi>[];
445
+ explicitKindModels: ReadonlySet<Model<TApi>>;
446
+ }
447
+
426
448
  async function fetchModelsDev<TApi extends Api, TModelsDevPayload>(
427
449
  options: ModelManagerOptions<TApi, TModelsDevPayload>,
428
- ): Promise<Model<TApi>[] | null> {
450
+ ): Promise<DiscoveredModelSet<TApi> | null> {
429
451
  if (!options.modelsDev) {
430
452
  return null;
431
453
  }
@@ -440,7 +462,7 @@ async function fetchModelsDev<TApi extends Api, TModelsDevPayload>(
440
462
 
441
463
  async function fetchDynamicModels<TApi extends Api>(
442
464
  fetcher: () => Promise<readonly ModelSpec<TApi>[] | null>,
443
- ): Promise<Model<TApi>[] | null> {
465
+ ): Promise<DiscoveredModelSet<TApi> | null> {
444
466
  try {
445
467
  const models = await fetcher();
446
468
  if (models === null) {
@@ -512,6 +534,7 @@ function mergeCatalogMetrics<TApi extends Api>(
512
534
  function mergeDynamicModels<TApi extends Api>(
513
535
  baseModels: readonly Model<TApi>[],
514
536
  dynamicModels: readonly Model<TApi>[],
537
+ explicitKindModels?: ReadonlySet<Model<TApi>>,
515
538
  ): Model<TApi>[] {
516
539
  // Empty-side fast paths: `mergeDynamicModels(base, [])` is the common shape
517
540
  // after we've already merged the first pair, and `(...)` with no base
@@ -528,6 +551,9 @@ function mergeDynamicModels<TApi extends Api>(
528
551
  merged.set(dynamicModel.id, dynamicModel);
529
552
  continue;
530
553
  }
554
+ // A policy-derived kind on a chat row is not permission to replace an
555
+ // authored runner. Only a kind present before materialization can do so.
556
+ if (modelKind(existingModel) !== "chat" && !explicitKindModels?.has(dynamicModel)) continue;
531
557
  merged.set(dynamicModel.id, mergeDynamicModel(existingModel, dynamicModel));
532
558
  }
533
559
  return Array.from(merged.values());
@@ -537,12 +563,12 @@ function retainModelIds<TApi extends Api>(
537
563
  models: readonly Model<TApi>[],
538
564
  retainedModels: readonly Model<TApi>[],
539
565
  ): Model<TApi>[] {
540
- if (retainedModels.length === 0 || models.length === 0) return [];
566
+ if (models.length === 0) return [];
541
567
  const retainedIds = new Set(retainedModels.map(model => model.id));
542
- return models.filter(model => retainedIds.has(model.id));
568
+ return models.filter(model => modelKind(model) !== "chat" || retainedIds.has(model.id));
543
569
  }
544
570
 
545
- const MODEL_CACHE_FINGERPRINT_VERSION = `merge-v4:${VERSION}`;
571
+ const MODEL_CACHE_FINGERPRINT_VERSION = `merge-v5:${VERSION}`;
546
572
 
547
573
  /**
548
574
  * Return the versioned content identity for a static provider slice. Callers
@@ -663,17 +689,20 @@ function preferDiscoveryLimit(discoveryLimit: number | null, fallbackLimit: numb
663
689
  return discoveryLimit;
664
690
  }
665
691
 
666
- function buildDiscoveredModelSpecs<TApi extends Api>(value: unknown): Model<TApi>[] {
692
+ function buildDiscoveredModelSpecs<TApi extends Api>(value: unknown): DiscoveredModelSet<TApi> {
693
+ const models: Model<TApi>[] = [];
694
+ const explicitKindModels = new Set<Model<TApi>>();
667
695
  if (!Array.isArray(value)) {
668
- return [];
696
+ return { models, explicitKindModels };
669
697
  }
670
- const models: Model<TApi>[] = [];
671
698
  for (const item of value) {
672
699
  if (isModelLike(item)) {
673
- models.push(buildModel(item as ModelSpec<TApi>));
700
+ const model = buildModel(item as ModelSpec<TApi>);
701
+ models.push(model);
702
+ if (item.kind !== undefined) explicitKindModels.add(model);
674
703
  }
675
704
  }
676
- return models;
705
+ return { models, explicitKindModels };
677
706
  }
678
707
 
679
708
  function isModelLike(value: unknown): value is ModelSpec<Api> {