@linxiraos/pi-catalog 1.2.1 → 1.3.0

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 (127) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +4 -2
  3. package/THIRD-PARTY-NOTICES.txt +55 -5
  4. package/dist/types/build.d.ts +3 -2
  5. package/dist/types/compat/auth-ids.d.ts +2 -2
  6. package/dist/types/compat/axes.d.ts +1 -1
  7. package/dist/types/compat/behavior.d.ts +5 -0
  8. package/dist/types/compat/catalog-endpoint.d.ts +8 -0
  9. package/dist/types/compat/image-tokenization.d.ts +9 -1
  10. package/dist/types/compat/judgment-images.d.ts +25 -0
  11. package/dist/types/compat/provider-ids.d.ts +1 -1
  12. package/dist/types/compat/request-compression.d.ts +23 -0
  13. package/dist/types/compat/request-size.d.ts +12 -3
  14. package/dist/types/compat/thinking-replay.d.ts +9 -0
  15. package/dist/types/compat/types.d.ts +4 -0
  16. package/dist/types/discovery/antigravity.d.ts +7 -0
  17. package/dist/types/model-cache.d.ts +7 -0
  18. package/dist/types/model-manager.d.ts +6 -0
  19. package/dist/types/model-thinking.d.ts +11 -1
  20. package/dist/types/models.d.ts +16 -1
  21. package/dist/types/pricing.d.ts +37 -1
  22. package/dist/types/provider-models/openai-compat.d.ts +18 -46
  23. package/dist/types/types.d.ts +63 -12
  24. package/dist/types/wire/codex.d.ts +2 -0
  25. package/package.json +5 -5
  26. package/src/build.d.ts +3 -2
  27. package/src/build.ts +159 -80
  28. package/src/compat/auth-ids.ts +2 -0
  29. package/src/compat/axes.d.ts +1 -1
  30. package/src/compat/axes.ts +129 -4
  31. package/src/compat/behavior.d.ts +5 -0
  32. package/src/compat/behavior.ts +9 -1
  33. package/src/compat/cascade.ts +4 -1
  34. package/src/compat/catalog-endpoint.ts +43 -0
  35. package/src/compat/image-tokenization.ts +28 -10
  36. package/src/compat/judgment-images.ts +83 -0
  37. package/src/compat/provider-ids.d.ts +1 -1
  38. package/src/compat/provider-ids.ts +1 -0
  39. package/src/compat/request-compression.ts +43 -0
  40. package/src/compat/request-size.ts +20 -5
  41. package/src/compat/resolve.ts +90 -53
  42. package/src/compat/rules/README.md +32 -3
  43. package/src/compat/rules/auth/_order.kdl +1 -1
  44. package/src/compat/rules/auth/google-antigravity.kdl +1 -1
  45. package/src/compat/rules/auth/sambanova.kdl +9 -0
  46. package/src/compat/rules/auth/stencil.kdl +1 -1
  47. package/src/compat/rules/classes/anthropic.kdl +42 -2
  48. package/src/compat/rules/classes/deepseek.kdl +4 -0
  49. package/src/compat/rules/classes/gemini.kdl +46 -2
  50. package/src/compat/rules/classes/meta.kdl +7 -0
  51. package/src/compat/rules/classes/minimax.kdl +11 -1
  52. package/src/compat/rules/classes/openai.kdl +34 -3
  53. package/src/compat/rules/classes/xai.kdl +26 -0
  54. package/src/compat/rules/providers/amazon-bedrock.kdl +1 -0
  55. package/src/compat/rules/providers/anthropic.kdl +23 -0
  56. package/src/compat/rules/providers/azure.kdl +2 -0
  57. package/src/compat/rules/providers/cerebras.kdl +3 -0
  58. package/src/compat/rules/providers/cline-pass.kdl +16 -0
  59. package/src/compat/rules/providers/commandcode.kdl +10 -0
  60. package/src/compat/rules/providers/cursor.kdl +50 -1
  61. package/src/compat/rules/providers/deepinfra.kdl +3 -1
  62. package/src/compat/rules/providers/devin.kdl +117 -6
  63. package/src/compat/rules/providers/factory-droid.kdl +6 -0
  64. package/src/compat/rules/providers/firepass.kdl +15 -0
  65. package/src/compat/rules/providers/fireworks.kdl +26 -0
  66. package/src/compat/rules/providers/github-copilot.kdl +24 -0
  67. package/src/compat/rules/providers/google-antigravity.kdl +37 -0
  68. package/src/compat/rules/providers/google-gemini-cli.kdl +1 -0
  69. package/src/compat/rules/providers/google-vertex.kdl +5 -0
  70. package/src/compat/rules/providers/google.kdl +1 -0
  71. package/src/compat/rules/providers/image-tokenization.kdl +2 -1
  72. package/src/compat/rules/providers/local.kdl +8 -1
  73. package/src/compat/rules/providers/meta.kdl +16 -0
  74. package/src/compat/rules/providers/moonshot.kdl +2 -0
  75. package/src/compat/rules/providers/muse-code.kdl +49 -0
  76. package/src/compat/rules/providers/native-tools.kdl +3 -0
  77. package/src/compat/rules/providers/ollama-cloud.kdl +10 -0
  78. package/src/compat/rules/providers/openai-codex.kdl +29 -5
  79. package/src/compat/rules/providers/openai.kdl +1 -0
  80. package/src/compat/rules/providers/opencode-go.kdl +35 -0
  81. package/src/compat/rules/providers/opencode-zen.kdl +4 -0
  82. package/src/compat/rules/providers/openrouter.kdl +19 -0
  83. package/src/compat/rules/providers/sambanova.kdl +60 -0
  84. package/src/compat/rules/providers/tool-call-names.kdl +37 -0
  85. package/src/compat/rules/providers/typesafe.kdl +11 -0
  86. package/src/compat/rules/providers/umans.kdl +7 -0
  87. package/src/compat/rules/providers/venice.kdl +7 -0
  88. package/src/compat/rules/providers/xai-oauth.kdl +16 -6
  89. package/src/compat/rules/providers/xai.kdl +3 -0
  90. package/src/compat/rules/providers/zai.kdl +5 -0
  91. package/src/compat/rules/runtime/behavior.kdl +128 -8
  92. package/src/compat/rules/taxonomy/_collapse.kdl +11 -0
  93. package/src/compat/rules/taxonomy/gemma.kdl +1 -0
  94. package/src/compat/rules/taxonomy/meta.kdl +7 -0
  95. package/src/compat/rules/taxonomy/openai.kdl +2 -0
  96. package/src/compat/rules.json +1 -1
  97. package/src/compat/thinking-replay.ts +33 -0
  98. package/src/compat/types.d.ts +4 -0
  99. package/src/compat/types.ts +4 -0
  100. package/src/discovery/antigravity.d.ts +7 -0
  101. package/src/discovery/antigravity.ts +13 -0
  102. package/src/discovery/codex.ts +0 -4
  103. package/src/discovery/cursor.ts +28 -20
  104. package/src/discovery/devin.ts +7 -8
  105. package/src/model-cache.d.ts +7 -0
  106. package/src/model-cache.ts +8 -0
  107. package/src/model-manager.d.ts +6 -0
  108. package/src/model-manager.ts +41 -21
  109. package/src/model-thinking.d.ts +80 -0
  110. package/src/model-thinking.ts +13 -1
  111. package/src/model-tokenizer.ts +7 -1
  112. package/src/models.d.ts +16 -1
  113. package/src/models.json +1 -1
  114. package/src/models.ts +26 -12
  115. package/src/pricing.d.ts +37 -1
  116. package/src/pricing.ts +123 -12
  117. package/src/provider-models/bundled-references.ts +5 -2
  118. package/src/provider-models/cache-provider-id.ts +1 -1
  119. package/src/provider-models/descriptors.ts +2 -0
  120. package/src/provider-models/google.ts +7 -1
  121. package/src/provider-models/openai-compat.d.ts +18 -46
  122. package/src/provider-models/openai-compat.ts +188 -104
  123. package/src/provider-models/special.ts +1 -0
  124. package/src/types.d.ts +63 -12
  125. package/src/types.ts +71 -9
  126. package/src/wire/codex.d.ts +2 -0
  127. package/src/wire/codex.ts +17 -0
@@ -0,0 +1,33 @@
1
+ /**
2
+ * How a reader bills a replayed signed thinking block as input, from the
3
+ * `thinking-replay-tokens` rules in the compat cascade. A model line's lineage
4
+ * rule (`rules/classes/*.kdl`) applies on every host; readers without one are
5
+ * estimated from the signature's bytes.
6
+ */
7
+ import { classifyModel } from "../identity";
8
+ import { resolveCascade } from "./cascade";
9
+ import type { ImageTokenizationTarget } from "./image-tokenization";
10
+
11
+ /**
12
+ * `output`: the reasoning the signature carries, at the size its turn billed
13
+ * as output; the signature's own bytes are free. `signature`: the signature's
14
+ * bytes stand in for that reasoning.
15
+ */
16
+ export type ThinkingReplayTokens = "output" | "signature";
17
+
18
+ /** How `reader` bills replayed thinking; `signature` unless its lineage declares otherwise. */
19
+ export function resolveThinkingReplayTokens(reader: ImageTokenizationTarget): ThinkingReplayTokens {
20
+ const provider = reader.provider ?? "";
21
+ const model = reader.id ?? "";
22
+ const identity = reader.identity ?? (model ? classifyModel(provider, model, { lenient: true }) : undefined);
23
+ const tokens = resolveCascade({
24
+ provider,
25
+ api: reader.api ?? "",
26
+ class: identity?.class ?? "unknown",
27
+ model,
28
+ reasoning: false,
29
+ ...(identity?.family !== undefined && { family: identity.family }),
30
+ ...(identity?.revision !== undefined && { revision: identity.revision }),
31
+ }).catalog.thinkingReplayTokens;
32
+ return tokens === "output" ? "output" : "signature";
33
+ }
@@ -328,6 +328,8 @@ export interface CompiledBehavior {
328
328
  retryResetTimezones: CompiledRetryResetTimezone[];
329
329
  retiredProviders: string[];
330
330
  referenceIsolatedProviders: string[];
331
+ /** Built-in model selector chains per agent role, keyed by role in declaration order. */
332
+ roleDefaults: Record<string, string[]>;
331
333
  }
332
334
  /**
333
335
  * A string setting from `auth/*.kdl` that may be overridden by environment
@@ -634,6 +636,8 @@ export interface CompiledProvider {
634
636
  dynamicModelsAuthoritative?: boolean;
635
637
  /** Generator backfills never copy reasoning/input/limits from same-id rows on other hosts. */
636
638
  skipCrossProviderReferenceFills?: boolean;
639
+ /** Runtime discovery keeps a bundled row's display name instead of the live one. */
640
+ keepBundledNames?: boolean;
637
641
  /** Present only for providers enrolled in `generate-models.ts` discovery. */
638
642
  discovery?: CompiledProviderDiscovery;
639
643
  /** Non-chat model kinds mapped to their runtime transport APIs. */
@@ -352,6 +352,8 @@ export interface CompiledBehavior {
352
352
  retryResetTimezones: CompiledRetryResetTimezone[];
353
353
  retiredProviders: string[];
354
354
  referenceIsolatedProviders: string[];
355
+ /** Built-in model selector chains per agent role, keyed by role in declaration order. */
356
+ roleDefaults: Record<string, string[]>;
355
357
  }
356
358
 
357
359
  /**
@@ -668,6 +670,8 @@ export interface CompiledProvider {
668
670
  dynamicModelsAuthoritative?: boolean;
669
671
  /** Generator backfills never copy reasoning/input/limits from same-id rows on other hosts. */
670
672
  skipCrossProviderReferenceFills?: boolean;
673
+ /** Runtime discovery keeps a bundled row's display name instead of the live one. */
674
+ keepBundledNames?: boolean;
671
675
  /** Present only for providers enrolled in `generate-models.ts` discovery. */
672
676
  discovery?: CompiledProviderDiscovery;
673
677
  /** Non-chat model kinds mapped to their runtime transport APIs. */
@@ -18,6 +18,13 @@ export interface AntigravityDiscoveryApiModel {
18
18
  apiProvider?: string;
19
19
  modelProvider?: string;
20
20
  isInternal?: boolean;
21
+ /**
22
+ * Set for roster rows the backend lists but refuses to serve on this
23
+ * account (e.g. `gemini-4-argon-disabled`, `tagTitle: "Unavailable"`,
24
+ * `model: MODEL_PLACEHOLDER_M421`); a request against such an id answers
25
+ * `Cloud Code Assist API error (404): Requested entity was not found.`
26
+ */
27
+ disabled?: boolean;
21
28
  supportsVideo?: boolean;
22
29
  }
23
30
  /**
@@ -29,6 +29,13 @@ export interface AntigravityDiscoveryApiModel {
29
29
  apiProvider?: string;
30
30
  modelProvider?: string;
31
31
  isInternal?: boolean;
32
+ /**
33
+ * Set for roster rows the backend lists but refuses to serve on this
34
+ * account (e.g. `gemini-4-argon-disabled`, `tagTitle: "Unavailable"`,
35
+ * `model: MODEL_PLACEHOLDER_M421`); a request against such an id answers
36
+ * `Cloud Code Assist API error (404): Requested entity was not found.`
37
+ */
38
+ disabled?: boolean;
32
39
  supportsVideo?: boolean;
33
40
  }
34
41
 
@@ -72,6 +79,7 @@ const AntigravityDiscoveryApiModelSchema = type({
72
79
  "apiProvider?": type("unknown").pipe(value => (typeof value === "string" ? value : undefined)),
73
80
  "modelProvider?": type("unknown").pipe(value => (typeof value === "string" ? value : undefined)),
74
81
  "isInternal?": type("unknown").pipe(value => (typeof value === "boolean" ? value : undefined)),
82
+ "disabled?": type("unknown").pipe(value => (typeof value === "boolean" ? value : undefined)),
75
83
  "supportsVideo?": type("unknown").pipe(value => (typeof value === "boolean" ? value : undefined)),
76
84
  });
77
85
 
@@ -188,6 +196,11 @@ export async function fetchAntigravityDiscoveryModels(
188
196
  if (model.isInternal === true) {
189
197
  continue;
190
198
  }
199
+ // Rows the backend marks unavailable are listed but 404 on inference;
200
+ // discovery is authoritative, so drop them instead of offering a dead id.
201
+ if (model.disabled === true) {
202
+ continue;
203
+ }
191
204
 
192
205
  const supportsImages = model.supportsImages === true;
193
206
  models.push({
@@ -69,7 +69,6 @@ const codexModelEntrySchema = type({
69
69
  "priority?": "unknown",
70
70
  "prefer_websockets?": "unknown",
71
71
  "use_responses_lite?": "unknown",
72
- "tool_mode?": "unknown",
73
72
  "available_access_programs?": "unknown",
74
73
  "service_tiers?": "unknown",
75
74
  });
@@ -302,7 +301,6 @@ interface ParsedCodexModelEntry {
302
301
  input: ("text" | "image")[];
303
302
  preferWebsockets: boolean;
304
303
  useResponsesLite: boolean;
305
- toolMode: boolean;
306
304
  priority: number;
307
305
  /** Advertised tier ids; `undefined` when the entry has no `service_tiers` array. */
308
306
  serviceTiers: string[] | undefined;
@@ -356,7 +354,6 @@ function parseCodexModelEntry(entry: unknown): ParsedCodexModelEntry | null {
356
354
  input: normalizeInputModalities(payload.input_modalities),
357
355
  preferWebsockets: toBoolean(payload.prefer_websockets) === true,
358
356
  useResponsesLite: toBoolean(payload.use_responses_lite) === true,
359
- toolMode: payload.tool_mode === "code_mode_only",
360
357
  priority: toFiniteNumber(payload.priority) ?? Number.MAX_SAFE_INTEGER,
361
358
  serviceTiers,
362
359
  };
@@ -422,7 +419,6 @@ function buildNormalizedCodexModel(
422
419
  maxTokens,
423
420
  ...(parsed.preferWebsockets ? { preferWebsockets: true } : {}),
424
421
  ...(parsed.useResponsesLite ? { useResponsesLite: true } : {}),
425
- ...(parsed.toolMode ? { toolMode: "code_mode_only" as const } : {}),
426
422
  ...(parsed.serviceTiers !== undefined ? { serviceTiers: parsed.serviceTiers } : {}),
427
423
  ...(parsed.priority !== Number.MAX_SAFE_INTEGER ? { priority: parsed.priority } : {}),
428
424
  },
@@ -1,13 +1,15 @@
1
1
  import * as http2 from "node:http2";
2
2
  import { type } from "@linxiraos/pi-omptype";
3
+ import { slugify } from "@linxiraos/pi-utils";
3
4
  import { collapseVariants, type EffortVariantFamily, reviewedVariantFamilyId } from "../compat/collapse";
4
5
  import { compareRevision, parseRevision } from "../compat/revision";
5
6
  import { resolveCatalogAxes, resolveModelPolicy } from "../compat/resolve";
6
7
  import { classifyModel } from "../compat/taxonomy";
7
8
  import { Effort, THINKING_EFFORTS } from "../effort";
8
9
  import { getBundledModels } from "../models";
10
+ import { datedCost, parseDatedCostCards } from "../pricing";
9
11
  import { toModelSpec } from "../provider-models/bundled-references";
10
- import type { CursorModelRoute, Model, ModelSpec, TokenCost } from "../types";
12
+ import type { CursorModelRoute, Model, ModelCost, ModelSpec, TokenCost } from "../types";
11
13
  import {
12
14
  CURSOR_AVAILABLE_MODELS_PATH,
13
15
  CURSOR_DEFAULT_BASE_URL,
@@ -435,13 +437,21 @@ function cursorVariantCostMultiplier(
435
437
  return multiplier;
436
438
  }
437
439
 
438
- function scaleTokenCost(cost: TokenCost, multiplier: number): TokenCost {
440
+ function scaleTokenCost(cost: ModelCost, multiplier: number): ModelCost {
439
441
  if (multiplier === 1) return cost;
442
+ const scale = <T extends TokenCost>(card: T): T => ({
443
+ ...card,
444
+ input: card.input * multiplier,
445
+ output: card.output * multiplier,
446
+ cacheRead: card.cacheRead * multiplier,
447
+ cacheWrite: card.cacheWrite * multiplier,
448
+ });
449
+ const effectiveRates = cost.timeBased?.effectiveRates;
440
450
  return {
441
- input: cost.input * multiplier,
442
- output: cost.output * multiplier,
443
- cacheRead: cost.cacheRead * multiplier,
444
- cacheWrite: cost.cacheWrite * multiplier,
451
+ ...scale({ input: cost.input, output: cost.output, cacheRead: cost.cacheRead, cacheWrite: cost.cacheWrite }),
452
+ ...(cost.timeBased && {
453
+ timeBased: { ...cost.timeBased, ...(effectiveRates && { effectiveRates: effectiveRates.map(scale) }) },
454
+ }),
445
455
  };
446
456
  }
447
457
 
@@ -462,7 +472,7 @@ function resolveRichCursorCardCost(
462
472
  details: AvailableModelsResponse_ModelDetails,
463
473
  entry: NormalizedRichVariant,
464
474
  laneId: string,
465
- ): TokenCost | undefined {
475
+ ): ModelCost | undefined {
466
476
  const costAxes = details.parameterDefinitions
467
477
  .filter(definition =>
468
478
  [
@@ -503,7 +513,7 @@ function resolveRichCursorCardCost(
503
513
  return base && scaleTokenCost(base, cursorVariantCostMultiplier(details, entry));
504
514
  }
505
515
 
506
- function resolveCursorCardAxes(slug: string): { patch?: TokenCost; fallback?: TokenCost } {
516
+ function resolveCursorCardAxes(slug: string): { patch?: ModelCost; fallback?: ModelCost } {
507
517
  const axes = resolveCatalogAxes({
508
518
  id: slug,
509
519
  name: slug,
@@ -516,7 +526,9 @@ function resolveCursorCardAxes(slug: string): { patch?: TokenCost; fallback?: To
516
526
  contextWindow: DEFAULT_CONTEXT_WINDOW,
517
527
  maxTokens: DEFAULT_MAX_TOKENS,
518
528
  });
519
- const card = (payload: unknown): TokenCost | undefined => {
529
+ // Dated cards ride along as a schedule, so a lane priced from a sibling
530
+ // slug's card (whose own id no rule matches) still changes price on time.
531
+ const card = (payload: unknown, axis: "cost-patch" | "cost-fallback"): ModelCost | undefined => {
520
532
  if (typeof payload !== "object" || payload === null) return undefined;
521
533
  const field = (key: string): number | undefined => {
522
534
  const value = Reflect.get(payload, key);
@@ -525,9 +537,12 @@ function resolveCursorCardAxes(slug: string): { patch?: TokenCost; fallback?: To
525
537
  const input = field("input");
526
538
  const output = field("output");
527
539
  if (input === undefined || output === undefined) return undefined;
528
- return { input, output, cacheRead: field("cacheRead") ?? 0, cacheWrite: field("cacheWrite") ?? 0 };
540
+ return datedCost(
541
+ { input, output, cacheRead: field("cacheRead") ?? 0, cacheWrite: field("cacheWrite") ?? 0 },
542
+ parseDatedCostCards(payload, axis),
543
+ );
529
544
  };
530
- return { patch: card(axes.costPatch), fallback: card(axes.costFallback) };
545
+ return { patch: card(axes.costPatch, "cost-patch"), fallback: card(axes.costFallback, "cost-fallback") };
531
546
  }
532
547
 
533
548
  function normalizeRichCursorModels(
@@ -679,8 +694,8 @@ function cursorLaneId(
679
694
  ): string {
680
695
  const suffixes: string[] = [];
681
696
  for (const parameter of parameters) {
682
- const id = sanitizeCursorLanePart(parameter.id);
683
- const value = sanitizeCursorLanePart(parameter.value);
697
+ const id = slugify(parameter.id);
698
+ const value = slugify(parameter.value);
684
699
  // Boolean lanes are named by their `true` side regardless of variant
685
700
  // order: Cursor lists `composer-2.5`'s Fast variant first, and treating
686
701
  // that as the default would hand the bare id to the Fast route.
@@ -729,13 +744,6 @@ function reviewedCursorLaneId(entries: readonly NormalizedRichVariant[], claimed
729
744
  return familyId;
730
745
  }
731
746
 
732
- function sanitizeCursorLanePart(value: string): string {
733
- return value
734
- .toLowerCase()
735
- .replace(/[^a-z0-9]+/g, "-")
736
- .replace(/^-+|-+$/g, "");
737
- }
738
-
739
747
  function cursorLaneName(details: AvailableModelsResponse_ModelDetails, laneId: string, baseId: string): string {
740
748
  const baseName =
741
749
  details.clientDisplayName?.trim() || details.serverModelName?.trim() || details.name.trim() || baseId;
@@ -1,4 +1,4 @@
1
- import { logger } from "@linxiraos/pi-utils";
1
+ import { logger, slugify } from "@linxiraos/pi-utils";
2
2
  import { collapseVariants, type EffortVariantFamily } from "../compat/collapse";
3
3
  import { Effort, THINKING_EFFORTS } from "../effort";
4
4
  import type { DevinCompat, FetchImpl, ModelCost, ModelSpec } from "../types";
@@ -106,8 +106,8 @@ function devinCostDenominatorTokens(denominator: string): number {
106
106
  /**
107
107
  * Per-million-token rates from the config's cost dimensions. `COST_FUZZY` marks
108
108
  * an estimated rate, not a different unit, so both kinds are read. `cacheWrite`
109
- * has no Cascade dimension — Devin bills cache writes at the input rate — and
110
- * stays 0.
109
+ * has no Cascade dimension and stays 0; the provider's `cache-write-rate` rules
110
+ * supply Devin's published rate.
111
111
  *
112
112
  * Composite configs (`fusion`) flatten their own rate card plus every
113
113
  * dispatched component's card into one `modelDimensions` list. A `Sidekick`
@@ -229,10 +229,7 @@ function collectDevinFamilyLane(lanes: Map<string, DevinFamilyLane>, config: Cli
229
229
  if (thinking === false) effort = "off";
230
230
 
231
231
  // Family label as an ZETA id: "GPT-5.6 Sol" -> "gpt-5-6-sol".
232
- const baseId = label
233
- .toLowerCase()
234
- .replace(/[^a-z0-9]+/g, "-")
235
- .replace(/^-+|-+$/g, "");
232
+ const baseId = slugify(label);
236
233
  if (!baseId) return;
237
234
  const laneId = `${baseId}${oneMillionContext ? "-1m" : ""}${fast ? "-fast" : ""}`;
238
235
  let lane = lanes.get(laneId);
@@ -478,7 +475,9 @@ function devinFusionLeadUid(uid: string, liveUids: ReadonlyMap<string, unknown>)
478
475
  /**
479
476
  * Point a Fusion pairing at its lead. zeta runs only the lead (the sidekick is
480
477
  * paired by the native client), so the limits and pricing a caller budgets
481
- * against are the lead's, not the composite card's.
478
+ * against are the lead's, not the composite card's. The composite uid names no
479
+ * model, so `buildModel` resolves the row under its `requestModelId` and the
480
+ * lead's catalog rules (cache-write rate, long-context tier) apply there.
482
481
  */
483
482
  function routeDevinFusionLead(spec: ModelSpec<"devin-agent">, lead: ModelSpec<"devin-agent">): void {
484
483
  spec.requestModelId = lead.id;
@@ -20,6 +20,13 @@ export interface CacheEntry<TApi extends Api = Api> {
20
20
  models: Model<TApi>[];
21
21
  fresh: boolean;
22
22
  authoritative: boolean;
23
+ /**
24
+ * `authoritative` of the payload row itself, ignoring the
25
+ * `model_cache_refresh` side row. A failed refresh flips only the side row
26
+ * (freshness plus retry backoff), so this keeps answering "were `models`
27
+ * written by an authoritative discovery result?" after such a write.
28
+ */
29
+ payloadAuthoritative: boolean;
23
30
  updatedAt: number;
24
31
  /** Model ids whose live headers were intentionally omitted from disk. */
25
32
  headerOmittedModelIds: readonly string[];
@@ -145,6 +145,13 @@ export interface CacheEntry<TApi extends Api = Api> {
145
145
  models: Model<TApi>[];
146
146
  fresh: boolean;
147
147
  authoritative: boolean;
148
+ /**
149
+ * `authoritative` of the payload row itself, ignoring the
150
+ * `model_cache_refresh` side row. A failed refresh flips only the side row
151
+ * (freshness plus retry backoff), so this keeps answering "were `models`
152
+ * written by an authoritative discovery result?" after such a write.
153
+ */
154
+ payloadAuthoritative: boolean;
148
155
  updatedAt: number;
149
156
  /** Model ids whose live headers were intentionally omitted from disk. */
150
157
  headerOmittedModelIds: readonly string[];
@@ -596,6 +603,7 @@ function parseCacheRow<TApi extends Api>(
596
603
  models,
597
604
  fresh,
598
605
  authoritative,
606
+ payloadAuthoritative: row.authoritative === 1,
599
607
  updatedAt,
600
608
  headerOmittedModelIds,
601
609
  unrestorableHeaderModelIds,
@@ -51,6 +51,12 @@ export interface ModelManagerOptions<TApi extends Api = Api, TModelsDevPayload =
51
51
  * a zero discovery price is treated as unknown and the bundled rate is kept.
52
52
  */
53
53
  dynamicCostAuthoritative?: boolean;
54
+ /**
55
+ * When true, a static row's display name wins over the cached or live row
56
+ * for the same id, so a model keeps one name before and after discovery.
57
+ * Set from the provider's `keep-bundled-names` catalog entry.
58
+ */
59
+ keepStaticNames?: boolean;
54
60
  /**
55
61
  * When true, a fresh cache never satisfies an online-eligible refresh: the
56
62
  * dynamic fetch always runs (an explicit `"offline"` strategy is still
@@ -73,6 +73,12 @@ export interface ModelManagerOptions<TApi extends Api = Api, TModelsDevPayload =
73
73
  * a zero discovery price is treated as unknown and the bundled rate is kept.
74
74
  */
75
75
  dynamicCostAuthoritative?: boolean;
76
+ /**
77
+ * When true, a static row's display name wins over the cached or live row
78
+ * for the same id, so a model keeps one name before and after discovery.
79
+ * Set from the provider's `keep-bundled-names` catalog entry.
80
+ */
81
+ keepStaticNames?: boolean;
76
82
  /**
77
83
  * When true, a fresh cache never satisfies an online-eligible refresh: the
78
84
  * dynamic fetch always runs (an explicit `"offline"` strategy is still
@@ -254,11 +260,14 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
254
260
  const staticModels = options.staticModels
255
261
  ? materializeStaticModels<TApi>(options.staticModels)
256
262
  : (getBundledModels(options.providerId as GeneratedProvider) as Model<TApi>[]);
257
- const additiveStaticModelIds =
263
+ const modelsDevAdditiveStaticModelIds =
258
264
  options.modelsDev?.additiveOnly && staticModels.length > 0
259
265
  ? new Set(staticModels.map(model => model.id))
260
266
  : undefined;
261
267
  const cache = readModelCache<TApi>(cacheProviderId, ttlMs, now, dbPath);
268
+ const dynamicModelsAuthoritative = options.dynamicModelsAuthoritative ?? false;
269
+ const isAuthoritativeDynamicCache = dynamicModelsAuthoritative && (cache?.payloadAuthoritative ?? false);
270
+ const cacheAdditiveStaticModelIds = isAuthoritativeDynamicCache ? undefined : modelsDevAdditiveStaticModelIds;
262
271
  const restoredCache = restoreCachedModelHeaders(
263
272
  cache?.models ?? [],
264
273
  staticModels,
@@ -270,11 +279,11 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
270
279
  );
271
280
  const usableCachedModels = restoredCache.models.filter(model => !restoredCache.unresolvedModelIds.has(model.id));
272
281
  const cacheHasUnresolvedHeaders = restoredCache.unresolvedModelIds.size > 0;
273
- const dynamicModelsAuthoritative = options.dynamicModelsAuthoritative ?? false;
274
282
  const mergeAuthority: DynamicMergeAuthority = {
275
283
  input: options.dynamicInputAuthoritative ?? false,
276
284
  reasoning: options.dynamicReasoningAuthoritative ?? false,
277
285
  cost: options.dynamicCostAuthoritative ?? false,
286
+ staticNames: options.keepStaticNames ? new Map(staticModels.map(model => [model.id, model.name])) : undefined,
278
287
  };
279
288
  const cacheDropIds = options.dropCachedModelIdsOnStaticMismatch;
280
289
  const staticCatalogFingerprint = fingerprintStaticModels(staticModels, dynamicModelsAuthoritative);
@@ -315,10 +324,10 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
315
324
  cacheFingerprintMatches &&
316
325
  !cacheHasUnresolvedHeaders
317
326
  ) {
318
- const cacheContribution = additiveStaticModelIds
319
- ? restoredCache.models.filter(model => !additiveStaticModelIds.has(model.id))
327
+ const cacheContribution = cacheAdditiveStaticModelIds
328
+ ? restoredCache.models.filter(model => !cacheAdditiveStaticModelIds.has(model.id))
320
329
  : restoredCache.models;
321
- const cachedModels = additiveStaticModelIds
330
+ const cachedModels = cacheAdditiveStaticModelIds
322
331
  ? mergeCatalogMetrics(
323
332
  mergeDynamicModels(staticModels, cacheContribution, mergeAuthority),
324
333
  restoredCache.models,
@@ -338,8 +347,8 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
338
347
  : [null, null];
339
348
  const modelsDevFetchSucceeded = fetchedModelsDevModels !== null;
340
349
  const normalizedModelsDevModels = fetchedModelsDevModels?.models ?? [];
341
- const modelsDevModels = additiveStaticModelIds
342
- ? normalizedModelsDevModels.filter(model => !additiveStaticModelIds.has(model.id))
350
+ const modelsDevModels = modelsDevAdditiveStaticModelIds
351
+ ? normalizedModelsDevModels.filter(model => !modelsDevAdditiveStaticModelIds.has(model.id))
343
352
  : normalizedModelsDevModels;
344
353
  const shouldUseFreshCacheAsAuthoritative =
345
354
  strategy === "online-if-uncached" && hasUsableFreshCache && hasAuthoritativeCache;
@@ -359,10 +368,8 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
359
368
  cacheFingerprintMatches,
360
369
  options.dropCachedModelIdsOnStaticMismatch,
361
370
  );
362
- // Additive shared-catalog rows may only introduce IDs. Apply that boundary
363
- // to cache fallback too, including snapshots written by an older binary.
364
- const cacheModels = additiveStaticModelIds
365
- ? preparedCacheModels.filter(model => !additiveStaticModelIds.has(model.id))
371
+ const cacheModels = cacheAdditiveStaticModelIds
372
+ ? preparedCacheModels.filter(model => !cacheAdditiveStaticModelIds.has(model.id))
366
373
  : preparedCacheModels;
367
374
  const dynamicModels = fetchedDynamicModels?.models ?? [];
368
375
  // A successful empty endpoint result stays authoritative for THIS cycle (so an
@@ -383,7 +390,9 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
383
390
  fetchedModelsDevModels?.explicitKindModels,
384
391
  );
385
392
  const catalogMetricsSource = modelsDevFetchSucceeded ? normalizedModelsDevModels : preparedCacheModels;
386
- const mergedWithCatalogMetrics = additiveStaticModelIds
393
+ const mergedWithCatalogMetrics = (
394
+ modelsDevFetchSucceeded ? modelsDevAdditiveStaticModelIds : cacheAdditiveStaticModelIds
395
+ )
387
396
  ? mergeCatalogMetrics(mergedWithModelsDev, catalogMetricsSource)
388
397
  : mergedWithModelsDev;
389
398
  const mergedModels = mergeDynamicModels(
@@ -393,7 +402,11 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
393
402
  fetchedDynamicModels?.explicitKindModels,
394
403
  );
395
404
  const models = collapseBuiltVariants(
396
- authoritativeDynamicFetchSucceeded ? retainModelIds(mergedModels, dynamicModels) : mergedModels,
405
+ authoritativeDynamicFetchSucceeded
406
+ ? retainModelIds(mergedModels, dynamicModels)
407
+ : !dynamicFetchSucceeded && isAuthoritativeDynamicCache && usableCachedModels.length > 0
408
+ ? retainModelIds(mergedModels, usableCachedModels)
409
+ : mergedModels,
397
410
  );
398
411
  const resolutionAuthoritative = !hasRemoteFetcher || remoteResolutionComplete || shouldUseFreshCacheAsAuthoritative;
399
412
  const remoteUpdatedAt = anyRemoteFetchSucceeded ? now() : undefined;
@@ -438,15 +451,18 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
438
451
  cacheFingerprintMatches,
439
452
  options.dropCachedModelIdsOnStaticMismatch,
440
453
  );
441
- const latestCacheModels = additiveStaticModelIds
442
- ? preparedLatestCacheModels.filter(model => !additiveStaticModelIds.has(model.id))
454
+ const latestCacheModels = cacheAdditiveStaticModelIds
455
+ ? preparedLatestCacheModels.filter(model => !cacheAdditiveStaticModelIds.has(model.id))
443
456
  : preparedLatestCacheModels;
457
+ const mergedFallbackModels = mergeDynamicModels(
458
+ mergeDynamicModels(staticModels, latestCacheModels, mergeAuthority),
459
+ modelsDevModels,
460
+ mergeAuthority,
461
+ );
444
462
  const fallbackSnapshotModels = collapseBuiltVariants(
445
- mergeDynamicModels(
446
- mergeDynamicModels(staticModels, latestCacheModels, mergeAuthority),
447
- modelsDevModels,
448
- mergeAuthority,
449
- ),
463
+ isAuthoritativeDynamicCache && latestUsableCacheModels.length > 0
464
+ ? retainModelIds(mergedFallbackModels, latestUsableCacheModels)
465
+ : mergedFallbackModels,
450
466
  );
451
467
  if (fallbackSnapshotModels.length > 0 || latestCache !== null || cache !== null) {
452
468
  writeModelCache(
@@ -582,6 +598,8 @@ interface DynamicMergeAuthority {
582
598
  readonly input: boolean;
583
599
  readonly reasoning: boolean;
584
600
  readonly cost: boolean;
601
+ /** Static display names by id that override dynamic rows (`keepStaticNames`). */
602
+ readonly staticNames: ReadonlyMap<string, string> | undefined;
585
603
  }
586
604
 
587
605
  function mergeDynamicModels<TApi extends Api>(
@@ -694,7 +712,9 @@ function mergeDynamicModel<TApi extends Api>(
694
712
  return buildModel({
695
713
  ...existingModel,
696
714
  ...dynamicModel,
697
- name: preferDiscoveryName(dynamicModel.name, existingModel.name, dynamicModel.id),
715
+ name:
716
+ authority.staticNames?.get(dynamicModel.id) ??
717
+ preferDiscoveryName(dynamicModel.name, existingModel.name, dynamicModel.id),
698
718
  reasoning,
699
719
  ...(trustsOwnThinking ? { thinking: dynamicModel.thinking } : {}),
700
720
  input: supportsImage ? ["text", "image"] : ["text"],
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Thinking metadata: runtime field-read helpers.
3
+ *
4
+ * Derivation lives in the compat engine (`compat/resolve`) and runs exactly
5
+ * once per model — from `buildModel` for dynamic specs and from the catalog
6
+ * generator for bundled entries. Everything here reads baked fields only: no
7
+ * id parsing, no host matching, no compat detection per request.
8
+ */
9
+ import { Effort } from "./effort";
10
+ import type { Api, Model, ModelSpec, ThinkingConfig } from "./types";
11
+ /**
12
+ * Runtime helpers read baked metadata only, so they accept both pre-build
13
+ * specs and built models.
14
+ */
15
+ type ApiModel<TApi extends Api = Api> = ModelSpec<TApi> | Model<TApi>;
16
+ /**
17
+ * Returns the supported thinking efforts declared on the model metadata.
18
+ * Empty for non-reasoning models and for reasoning models without a
19
+ * controllable effort surface (`thinking: undefined`).
20
+ */
21
+ export declare function getSupportedEfforts<TApi extends Api>(model: ApiModel<TApi>): readonly Effort[];
22
+ /**
23
+ * Neutral view of a model whose thinking ladder is unverified: `reasoning: false`
24
+ * and no ladder. Re-entering policy resolution starts from it (the ladder is
25
+ * derived output, not authored input), and requests without an effort send it so
26
+ * no reasoning or disable fields reach an upstream that may reject them.
27
+ */
28
+ export declare function withoutUnverifiedThinking<T extends {
29
+ reasoning: boolean;
30
+ thinking?: ThinkingConfig;
31
+ }>(model: T): T;
32
+ /**
33
+ * Clamps a requested thinking level against explicit model metadata.
34
+ *
35
+ * Non-reasoning models always resolve to `undefined`.
36
+ */
37
+ export declare function clampThinkingLevelForModel<TApi extends Api>(model: ApiModel<TApi> | undefined, requested: Effort | undefined): Effort | undefined;
38
+ export declare function requireSupportedEffort<TApi extends Api>(model: ApiModel<TApi>, effort: Effort): Effort;
39
+ /** Maps a normalized thinking effort to Google's `thinkingLevel` enum values.
40
+ * When a collapsed family routes `minimal` onto the same wire id as `low`
41
+ * (Antigravity Gemini 3.6/3.7 Flash), emit `LOW` — Cloud Code Assist rejects
42
+ * `MINIMAL` on those `-low` SKUs.
43
+ */
44
+ export declare function mapEffortToGoogleThinkingLevel<TApi extends Api>(effort: Effort, model?: ApiModel<TApi>): "MINIMAL" | "LOW" | "MEDIUM" | "HIGH";
45
+ /**
46
+ * Maps a normalized thinking effort to Anthropic adaptive effort values via
47
+ * the model's baked `thinking.effortMap` (identity for unmapped efforts).
48
+ *
49
+ * The Anthropic adaptive wire vocabulary has no `minimal` tier (valid values:
50
+ * `low`, `medium`, `high`, `xhigh`, `max`); a model ladder that exposes
51
+ * `minimal` — custom `anthropic-messages` providers do, unlike the built-in
52
+ * Claude ladders — would otherwise serialize `output_config.effort: "minimal"`
53
+ * and 400 (`level "minimal" not supported`). Clamp it to the lowest real tier,
54
+ * mirroring {@link mapEffortToGoogleThinkingLevel}'s `minimal` handling.
55
+ */
56
+ export declare function mapEffortToAnthropicAdaptiveEffort<TApi extends Api>(model: ApiModel<TApi>, effort: Effort): "low" | "medium" | "high" | "xhigh" | "max" | "adaptive";
57
+ /**
58
+ * Resolves the upstream wire model id for a request at the given effort
59
+ * (`undefined` = thinking off). Collapsed effort-tier variants route through
60
+ * `thinking.effortRouting`; everything else falls back to
61
+ * `requestModelId ?? id`.
62
+ */
63
+ export declare function resolveWireModelId<TApi extends Api>(model: ApiModel<TApi>, effort: Effort | undefined): string;
64
+ /**
65
+ * Lowest supported effort in canonical order — the clamp target for
66
+ * thinking-off requests on `thinking.requiresEffort` models.
67
+ */
68
+ export declare function minimumSupportedEffort<TApi extends Api>(model: ApiModel<TApi>): Effort | undefined;
69
+ /**
70
+ * Clamp target for effort-less requests on `thinking.requiresEffort` models:
71
+ * the effort whose wire route equals the model's default wire id
72
+ * (`requestModelId`), so a collapsed row clamps to the tier it already
73
+ * advertises as its default rather than the numerically lowest supported tier
74
+ * (e.g. Cursor Grok 4.5/4.6 default to `medium`, the only tier the Start plan
75
+ * serves). Falls back to {@link minimumSupportedEffort} when no route matches
76
+ * the default id — families whose default already is the minimum, or that
77
+ * expose no routing, are unaffected.
78
+ */
79
+ export declare function defaultSupportedEffort<TApi extends Api>(model: ApiModel<TApi>): Effort | undefined;
80
+ export {};
@@ -7,7 +7,7 @@
7
7
  * id parsing, no host matching, no compat detection per request.
8
8
  */
9
9
  import { Effort, THINKING_EFFORTS } from "./effort";
10
- import type { Api, Model, ModelSpec } from "./types";
10
+ import type { Api, Model, ModelSpec, ThinkingConfig } from "./types";
11
11
 
12
12
  /**
13
13
  * Runtime helpers read baked metadata only, so they accept both pre-build
@@ -27,6 +27,18 @@ export function getSupportedEfforts<TApi extends Api>(model: ApiModel<TApi>): re
27
27
  return model.thinking?.efforts ?? [];
28
28
  }
29
29
 
30
+ /**
31
+ * Neutral view of a model whose thinking ladder is unverified: `reasoning: false`
32
+ * and no ladder. Re-entering policy resolution starts from it (the ladder is
33
+ * derived output, not authored input), and requests without an effort send it so
34
+ * no reasoning or disable fields reach an upstream that may reject them.
35
+ */
36
+ export function withoutUnverifiedThinking<T extends { reasoning: boolean; thinking?: ThinkingConfig }>(model: T): T {
37
+ if (!model.thinking?.unverified) return model;
38
+ const { thinking: _derived, ...rest } = model;
39
+ return { ...rest, reasoning: false } as T;
40
+ }
41
+
30
42
  /**
31
43
  * Clamps a requested thinking level against explicit model metadata.
32
44
  *
@@ -49,6 +49,11 @@ function glmTokenizer(identity: ModelIdentity): ModelTokenizer | undefined {
49
49
  return identity.class === "glm" && revisionAtLeast(identity.revision, "5") ? "glm5" : undefined;
50
50
  }
51
51
 
52
+ // GPT-5 and later bill text in o200k_base; older generations (cl100k GPT-4, unparsed gpt-4o) keep the estimate.
53
+ function openAITokenizer(identity: ModelIdentity): ModelTokenizer | undefined {
54
+ return identity.class === "openai" && revisionAtLeast(identity.revision, "5") ? "o200k" : undefined;
55
+ }
56
+
52
57
  /**
53
58
  * Resolve the exact locally embedded tokenizer for a canonical model id.
54
59
  *
@@ -68,7 +73,8 @@ export function resolveModelTokenizer(modelId: string, provider = ""): ModelToke
68
73
  qwenTokenizer(identity) ??
69
74
  deepSeekTokenizer(identity) ??
70
75
  kimiTokenizer(identity) ??
71
- glmTokenizer(identity);
76
+ glmTokenizer(identity) ??
77
+ openAITokenizer(identity);
72
78
  if (modelTokenizerCache.size === MAX_TOKENIZER_CACHE_ENTRIES) modelTokenizerCache.clear();
73
79
  modelTokenizerCache.set(cacheKey, tokenizer ?? null);
74
80
  return tokenizer;