@oh-my-pi/pi-catalog 18.14.2 → 18.16.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 (52) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/types/compat/auth-ids.d.ts +2 -2
  3. package/dist/types/compat/catalog-policy.d.ts +6 -4
  4. package/dist/types/compat/factory-droid.d.ts +7 -1
  5. package/dist/types/compat/provider-ids.d.ts +1 -1
  6. package/dist/types/compat/resolve.d.ts +1 -1
  7. package/dist/types/compat/revision.d.ts +2 -0
  8. package/dist/types/compat/server-side-fallback.d.ts +6 -4
  9. package/dist/types/compat/types.d.ts +3 -1
  10. package/dist/types/discovery/openai-compatible.d.ts +6 -0
  11. package/dist/types/models.d.ts +16 -0
  12. package/dist/types/pricing.d.ts +66 -6
  13. package/dist/types/provider-models/openai-compat.d.ts +18 -0
  14. package/dist/types/types.d.ts +11 -5
  15. package/dist/types/utils.d.ts +8 -0
  16. package/package.json +4 -4
  17. package/src/compat/auth-ids.ts +5 -0
  18. package/src/compat/axes.ts +5 -2
  19. package/src/compat/catalog-policy.ts +14 -10
  20. package/src/compat/collapse.ts +1 -1
  21. package/src/compat/factory-droid.ts +9 -1
  22. package/src/compat/provider-ids.ts +2 -0
  23. package/src/compat/resolve.ts +4 -2
  24. package/src/compat/revision.ts +10 -0
  25. package/src/compat/rules/README.md +2 -0
  26. package/src/compat/rules/auth/_order.kdl +1 -1
  27. package/src/compat/rules/auth/mistral.kdl +4 -0
  28. package/src/compat/rules/auth/modular.kdl +9 -0
  29. package/src/compat/rules/auth/scaleway.kdl +17 -0
  30. package/src/compat/rules/providers/factory-droid.kdl +219 -13
  31. package/src/compat/rules/providers/modular.kdl +35 -0
  32. package/src/compat/rules/providers/sambanova.kdl +2 -0
  33. package/src/compat/rules/providers/scaleway.kdl +179 -0
  34. package/src/compat/rules/runtime/behavior.kdl +13 -0
  35. package/src/compat/rules.json +1 -1
  36. package/src/compat/server-side-fallback.ts +7 -5
  37. package/src/compat/types.ts +3 -1
  38. package/src/discovery/codex.ts +2 -9
  39. package/src/discovery/factory-droid.ts +35 -11
  40. package/src/discovery/openai-compatible.ts +1 -1
  41. package/src/model-cache.ts +32 -58
  42. package/src/model-catalog-format.ts +5 -8
  43. package/src/model-manager.ts +27 -121
  44. package/src/model-tokenizer.ts +13 -12
  45. package/src/models.json +1 -1
  46. package/src/models.ts +32 -1
  47. package/src/pricing.ts +59 -94
  48. package/src/provider-models/descriptors.ts +2 -0
  49. package/src/provider-models/openai-compat.ts +60 -59
  50. package/src/types.ts +11 -4
  51. package/src/utils.ts +18 -0
  52. package/src/wire/factory-droid.ts +4 -3
package/src/models.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { classifyModel } from "./compat/taxonomy";
2
2
  import { getCatalogProvider, getCatalogProviders, openModelCatalog } from "./model-catalog-format";
3
3
  import modelsData from "./models.json" with { type: "json" };
4
- import { dueEffectiveRate } from "./pricing";
4
+ import { dueDatedCard, dueEffectiveRate } from "./pricing";
5
5
  import type {
6
6
  Api,
7
7
  EffectiveTokenCost,
@@ -77,6 +77,37 @@ export function getModelPricingStatus(model: Pick<Model<Api>, "cost" | "pricingS
77
77
  return model.pricingStatus ?? "unknown";
78
78
  }
79
79
 
80
+ /** Factory Droid credit rate in force at a time, and the next dated change to it. */
81
+ export interface FactoryDroidCreditRate {
82
+ rate: number;
83
+ /** The earliest later `credit-rates` card that changes `rate`: the rate it sets and when (Unix ms). */
84
+ next?: { rate: number; at: number };
85
+ }
86
+
87
+ /**
88
+ * Factory Droid Standard Credits rate at `timestamp` (Unix ms, default now):
89
+ * the `input` of the `credit-rates` card due then (the latest at or before
90
+ * it, as for cost cards), else the base rate. Undefined for models without a
91
+ * credit rate.
92
+ */
93
+ export function getFactoryDroidCreditRate(
94
+ model: Pick<Model, "factoryDroidCredits" | "factoryDroidCreditCards">,
95
+ timestamp = Date.now(),
96
+ ): FactoryDroidCreditRate | undefined {
97
+ const base = model.factoryDroidCredits;
98
+ if (base === undefined) return undefined;
99
+ const cards = model.factoryDroidCreditCards ?? [];
100
+ const rate = dueDatedCard(cards, timestamp)?.input ?? base;
101
+ let next: FactoryDroidCreditRate["next"];
102
+ for (const card of cards) {
103
+ const cardRate = card.input ?? base;
104
+ if (card.effectiveFrom > timestamp && cardRate !== rate && !(next && next.at <= card.effectiveFrom)) {
105
+ next = { rate: cardRate, at: card.effectiveFrom };
106
+ }
107
+ }
108
+ return next ? { rate, next } : { rate };
109
+ }
110
+
80
111
  /**
81
112
  * The base token card in force at `timestamp` (Unix ms, default now): the
82
113
  * latest due `timeBased` effective rate, else the row's own legs. Price
package/src/pricing.ts CHANGED
@@ -1,84 +1,35 @@
1
- import type {
2
- EffectiveTokenCost,
3
- LongContextTokenCost,
4
- ModelCost,
5
- PeakPricingWindow,
6
- TimeBasedCost,
7
- TokenCost,
8
- } from "./types";
1
+ import { type } from "@oh-my-pi/omptype";
2
+ import type { EffectiveTokenCost, ModelCost, TimeBasedCost, TokenCost } from "./types";
9
3
  import { isRecord } from "./utils";
10
4
 
11
- function nonnegative(value: unknown): value is number {
12
- return typeof value === "number" && Number.isFinite(value) && value >= 0;
13
- }
14
-
15
- function isRates(value: unknown): value is TokenCost {
16
- return (
17
- isRecord(value) &&
18
- nonnegative(value.input) &&
19
- nonnegative(value.output) &&
20
- nonnegative(value.cacheRead) &&
21
- nonnegative(value.cacheWrite)
22
- );
23
- }
24
-
25
- function isLongContext(value: unknown): value is LongContextTokenCost {
26
- return (
27
- isRecord(value) &&
28
- isRates(value) &&
29
- typeof value.inputThreshold === "number" &&
30
- Number.isFinite(value.inputThreshold) &&
31
- value.inputThreshold > 0 &&
32
- (value.inputThresholdInclusive === undefined || typeof value.inputThresholdInclusive === "boolean")
33
- );
34
- }
35
-
36
- function isPeakWindow(value: unknown): value is PeakPricingWindow {
37
- return (
38
- isRecord(value) &&
39
- Array.isArray(value.weekdays) &&
40
- value.weekdays.length > 0 &&
41
- value.weekdays.every(day => Number.isInteger(day) && day >= 0 && day <= 6) &&
42
- new Set(value.weekdays).size === value.weekdays.length &&
43
- typeof value.startMinute === "number" &&
44
- Number.isInteger(value.startMinute) &&
45
- typeof value.endMinute === "number" &&
46
- Number.isInteger(value.endMinute) &&
47
- value.startMinute >= 0 &&
48
- value.endMinute <= 1440 &&
49
- value.startMinute < value.endMinute
50
- );
51
- }
52
-
53
- function isEffectiveRate(value: unknown): value is EffectiveTokenCost {
54
- return (
55
- isRecord(value) &&
56
- isRates(value) &&
57
- typeof value.effectiveFrom === "number" &&
58
- Number.isSafeInteger(value.effectiveFrom) &&
59
- Math.abs(value.effectiveFrom) <= 8_640_000_000_000_000 &&
60
- (value.longContext === undefined || isLongContext(value.longContext))
61
- );
62
- }
5
+ // Schedules are also checked on in-memory discovery output, where absent optionals may be explicit `undefined`.
6
+ const Rate = type("number >= 0");
7
+ const Rates = type({ input: Rate, output: Rate, cacheRead: Rate, cacheWrite: Rate });
8
+ const LongContext = Rates.and({ inputThreshold: "number > 0", "inputThresholdInclusive?": "boolean | undefined" });
9
+ const PeakWindow = type({
10
+ weekdays: type("0 <= number.integer <= 6").array().atLeastLength(1),
11
+ startMinute: "number.integer >= 0",
12
+ endMinute: "number.integer <= 1440",
13
+ }).narrow(window => new Set(window.weekdays).size === window.weekdays.length && window.startMinute < window.endMinute);
14
+ const EffectiveRate = Rates.and({
15
+ // The `Date` range.
16
+ effectiveFrom: "-8640000000000000 <= number.integer <= 8640000000000000",
17
+ "longContext?": LongContext.or("undefined"),
18
+ });
19
+ /** Serialized time-based pricing schedule, as cached model rows carry it. */
20
+ export const TimeBasedCostShape = type({
21
+ offPeakMultiplier: Rate,
22
+ peakWindows: PeakWindow.array(),
23
+ "effectiveRates?": EffectiveRate.array().or("undefined"),
24
+ }).narrow(
25
+ cost =>
26
+ cost.effectiveRates === undefined ||
27
+ new Set(cost.effectiveRates.map(rate => rate.effectiveFrom)).size === cost.effectiveRates.length,
28
+ );
63
29
 
64
30
  /** Validate serialized schedules before admitting cached model rows. */
65
31
  export function isTimeBasedCost(value: unknown): value is TimeBasedCost {
66
- if (
67
- !isRecord(value) ||
68
- !nonnegative(value.offPeakMultiplier) ||
69
- !Array.isArray(value.peakWindows) ||
70
- !value.peakWindows.every(isPeakWindow)
71
- ) {
72
- return false;
73
- }
74
- if (value.effectiveRates === undefined) return true;
75
- if (!Array.isArray(value.effectiveRates) || !value.effectiveRates.every(isEffectiveRate)) return false;
76
- const dates = new Set<number>();
77
- for (const rate of value.effectiveRates) {
78
- if (dates.has(rate.effectiveFrom)) return false;
79
- dates.add(rate.effectiveFrom);
80
- }
81
- return true;
32
+ return TimeBasedCostShape.allows(value);
82
33
  }
83
34
 
84
35
  function payload(value: unknown, keys: readonly string[], field: string): Record<string, unknown> {
@@ -148,15 +99,15 @@ export function materializeTimeBasedCost(value: unknown): TimeBasedCost {
148
99
  }
149
100
 
150
101
  /**
151
- * The latest `effectiveRates` card of a materialized schedule due at
152
- * `timestamp`, or undefined before the first one (or without a schedule).
102
+ * The dated card due at `timestamp`: the one with the latest `effectiveFrom`
103
+ * at or before it, in any order, or undefined before the first card.
153
104
  */
154
- export function dueEffectiveRate(
155
- schedule: TimeBasedCost | undefined,
105
+ export function dueDatedCard<T extends { effectiveFrom: number }>(
106
+ cards: readonly T[],
156
107
  timestamp: number,
157
- ): EffectiveTokenCost | undefined {
158
- let due: EffectiveTokenCost | undefined;
159
- for (const candidate of schedule?.effectiveRates ?? []) {
108
+ ): T | undefined {
109
+ let due: T | undefined;
110
+ for (const candidate of cards) {
160
111
  if (candidate.effectiveFrom <= timestamp && (due === undefined || candidate.effectiveFrom > due.effectiveFrom)) {
161
112
  due = candidate;
162
113
  }
@@ -164,21 +115,38 @@ export function dueEffectiveRate(
164
115
  return due;
165
116
  }
166
117
 
118
+ /**
119
+ * The latest `effectiveRates` card of a materialized schedule due at
120
+ * `timestamp`, or undefined before the first one (or without a schedule).
121
+ */
122
+ export function dueEffectiveRate(
123
+ schedule: TimeBasedCost | undefined,
124
+ timestamp: number,
125
+ ): EffectiveTokenCost | undefined {
126
+ return dueDatedCard(schedule?.effectiveRates ?? [], timestamp);
127
+ }
128
+
167
129
  const TOKEN_COST_LEGS = ["input", "output", "cacheRead", "cacheWrite"] as const;
168
130
  const DATED_CARD_KEYS: readonly string[] = ["effectiveFrom", ...TOKEN_COST_LEGS];
169
131
 
170
- /** A `cost-patch`/`cost-fallback` dated card: the legs it overwrites from `effectiveFrom` (Unix ms). */
132
+ /**
133
+ * A dated `effective-rates` card of a `cost-patch`, `cost-fallback` or
134
+ * `credit-rates` rule: the legs it overwrites from `effectiveFrom` (Unix ms).
135
+ */
171
136
  export interface DatedCostCard extends Partial<TokenCost> {
172
137
  effectiveFrom: number;
173
138
  }
174
139
 
175
140
  /**
176
- * Parse the compiled `effective-rates` block of a `cost-patch` or
177
- * `cost-fallback` payload into dated cards, ascending by date. Throws on a
178
- * malformed card, so a typo in a rule fails the build instead of silently
179
- * leaving the old price in force.
141
+ * Parse the compiled `effective-rates` block of a `cost-patch`,
142
+ * `cost-fallback` or Factory Droid `credit-rates` payload into dated cards,
143
+ * ascending by date. Throws on a malformed card, so a typo in a rule fails the
144
+ * build instead of silently leaving the old price in force.
180
145
  */
181
- export function parseDatedCostCards(costAxis: unknown, axis: "cost-patch" | "cost-fallback"): DatedCostCard[] {
146
+ export function parseDatedCostCards(
147
+ costAxis: unknown,
148
+ axis: "cost-patch" | "cost-fallback" | "credit-rates",
149
+ ): DatedCostCard[] {
182
150
  const effectiveRates = isRecord(costAxis) ? costAxis.effectiveRates : undefined;
183
151
  if (effectiveRates === undefined) return [];
184
152
  if (!isRecord(effectiveRates)) throw new Error(`Invalid ${axis} effective-rates: expected named objects`);
@@ -190,7 +158,7 @@ export function parseDatedCostCards(costAxis: unknown, axis: "cost-patch" | "cos
190
158
  for (const leg of TOKEN_COST_LEGS) {
191
159
  const value = entry[leg];
192
160
  if (value === undefined) continue;
193
- if (!nonnegative(value))
161
+ if (!Rate.allows(value))
194
162
  throw new Error(`Invalid ${axis} effective-rate ${leg}: expected a nonnegative number`);
195
163
  card[leg] = value;
196
164
  }
@@ -235,10 +203,7 @@ export function datedCost(base: TokenCost, cards: readonly DatedCostCard[], now
235
203
  * `cost` unchanged before the first card.
236
204
  */
237
205
  export function applyDueDatedCostCard(cost: TokenCost, cards: readonly DatedCostCard[], now = Date.now()): void {
238
- let due: DatedCostCard | undefined;
239
- for (const card of cards) {
240
- if (card.effectiveFrom <= now) due = card;
241
- }
206
+ const due = dueDatedCard(cards, now);
242
207
  if (due === undefined) return;
243
208
  for (const leg of TOKEN_COST_LEGS) {
244
209
  const value = due[leg];
@@ -61,6 +61,7 @@ import {
61
61
  qwenPortalModelManagerOptions,
62
62
  sakanaModelManagerOptions,
63
63
  sambanovaModelManagerOptions,
64
+ scalewayModelManagerOptions,
64
65
  siliconflowCnModelManagerOptions,
65
66
  siliconflowModelManagerOptions,
66
67
  singularityApiDevModelManagerOptions,
@@ -150,6 +151,7 @@ const MODEL_MANAGER_FACTORIES: Readonly<Partial<Record<KnownProvider, ModelManag
150
151
  "qwen-portal": config => qwenPortalModelManagerOptions(config),
151
152
  sakana: config => sakanaModelManagerOptions(config),
152
153
  sambanova: config => sambanovaModelManagerOptions(config),
154
+ scaleway: config => scalewayModelManagerOptions(config),
153
155
  siliconflow: config => siliconflowModelManagerOptions(config),
154
156
  "siliconflow-cn": config => siliconflowCnModelManagerOptions(config),
155
157
  "singularityapi-dev": config => singularityApiDevModelManagerOptions(config),
@@ -13,7 +13,7 @@ import {
13
13
  import { resolveAnthropicApiVersion } from "../compat/anthropic";
14
14
  import { xaiResponsesReasoningEffortMap } from "../compat/openai";
15
15
  import { hasModelScopedEffortLadder, resolveModelPolicy } from "../compat/resolve";
16
- import { compareRevision, parseRevision } from "../compat/revision";
16
+ import { compareRevision, parseRevision, revisionAtLeast } from "../compat/revision";
17
17
  import { providerEntries, seedModels } from "../compat/providers";
18
18
  import { billingVariantPlain, classifyModel, discoveryVocabulary } from "../compat/taxonomy";
19
19
  import {
@@ -21,6 +21,7 @@ import {
21
21
  fetchOpenAICompatibleModels,
22
22
  type OpenAICompatibleModelMapperContext,
23
23
  type OpenAICompatibleModelRecord,
24
+ withOpenAICompatibleDiscoveryTimeout,
24
25
  } from "../discovery/openai-compatible";
25
26
  import { Effort, THINKING_EFFORTS } from "../effort";
26
27
  import { FIREWORKS_FAST_SUFFIX, toFireworksPublicModelId } from "../fireworks-model-id";
@@ -43,15 +44,7 @@ import {
43
44
  type TokenCost,
44
45
  runnerApiKind,
45
46
  } from "../types";
46
- import {
47
- discoveryFetch,
48
- isAnthropicOAuthToken,
49
- isRecord,
50
- toBoolean,
51
- toNumber,
52
- toPositiveNumber,
53
- toPositiveNumberOrNull,
54
- } from "../utils";
47
+ import { discoveryFetch, isAnthropicOAuthToken, isRecord, toBoolean, toNumber, toPositiveNumber } from "../utils";
55
48
  import { ALIBABA_TOKEN_PLAN_BASE_URL, parseAlibabaTokenPlanCredential } from "../wire/alibaba-token-plan";
56
49
  import { normalizeCharmHyperBaseUrl } from "../wire/charm-hyper";
57
50
  import { CLINEPASS_API_BASE_URL, clinePassClientHeaders } from "../wire/cline-pass";
@@ -78,15 +71,6 @@ import { getClinePassModelMetadata } from "./cline-pass";
78
71
  import type { ModelManagerConfig } from "./descriptor-types";
79
72
  import { filterModelsDevCatalogRows } from "./models-dev-policies";
80
73
 
81
- function revisionAtLeast(revision: string | undefined, floor: string): boolean {
82
- if (revision === undefined) return false;
83
- const parsedRevision = parseRevision(revision);
84
- const parsedFloor = parseRevision(floor);
85
- return (
86
- parsedRevision !== undefined && parsedFloor !== undefined && compareRevision(parsedRevision, parsedFloor) >= 0
87
- );
88
- }
89
-
90
74
  function isGlmReasoningIdentity(provider: string, modelId: string, floor: string): boolean {
91
75
  const identity = classifyModel(provider, modelId, { lenient: true });
92
76
  if (identity.class !== "glm" || !revisionAtLeast(identity.revision, floor)) return false;
@@ -99,24 +83,6 @@ const MODELS_DEV_URL = "https://catalog.stencil.so/models.json.zstd";
99
83
  /** Little-endian magic number opening every zstd frame (RFC 8878). */
100
84
  const ZSTD_MAGIC = 0xfd2fb528;
101
85
 
102
- /**
103
- * Uses a cancellable timer rather than the native abort-timeout helper so
104
- * successful fast discovery requests do not leave armed timeout signals for
105
- * concurrent GC to trip over later.
106
- */
107
- async function withCatalogDiscoveryTimeout<T>(timeoutMs: number, run: (signal: AbortSignal) => Promise<T>): Promise<T> {
108
- const controller = new AbortController();
109
- const timer = setTimeout(
110
- () => controller.abort(new DOMException("The operation timed out.", "TimeoutError")),
111
- timeoutMs,
112
- );
113
- try {
114
- return await run(controller.signal);
115
- } finally {
116
- clearTimeout(timer);
117
- }
118
- }
119
-
120
86
  const ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
121
87
  const ANTHROPIC_OAUTH_BETA =
122
88
  "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advanced-tool-use-2025-11-20,effort-2025-11-24,extended-cache-ttl-2025-04-11";
@@ -226,8 +192,9 @@ export function fetchWellKnownModels(fetchImpl?: FetchImpl, signal?: AbortSignal
226
192
  function fetchRevalidatedWellKnownModels(fetchImpl?: FetchImpl, signal?: AbortSignal): Promise<unknown> {
227
193
  const session = getCatalogSession(fetchImpl);
228
194
  if (!session.inflight) {
229
- session.inflight = withCatalogDiscoveryTimeout(DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS, transportSignal =>
230
- fetchCatalogPayload(fetchImpl ?? discoveryFetch(), session, transportSignal),
195
+ session.inflight = withOpenAICompatibleDiscoveryTimeout(
196
+ DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS,
197
+ transportSignal => fetchCatalogPayload(fetchImpl ?? discoveryFetch(), session, transportSignal),
231
198
  ).finally(() => {
232
199
  session.inflight = null;
233
200
  });
@@ -239,7 +206,7 @@ function fetchRevalidatedWellKnownModelsWithTimeout(
239
206
  fetchImpl?: FetchImpl,
240
207
  timeoutMs = DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS,
241
208
  ): Promise<unknown> {
242
- return withCatalogDiscoveryTimeout(timeoutMs, signal => fetchRevalidatedWellKnownModels(fetchImpl, signal));
209
+ return withOpenAICompatibleDiscoveryTimeout(timeoutMs, signal => fetchRevalidatedWellKnownModels(fetchImpl, signal));
243
210
  }
244
211
 
245
212
  async function fetchCatalogPayload(
@@ -545,6 +512,8 @@ function mapWithBundledReference<TApi extends Api>(
545
512
  return {
546
513
  ...defaults,
547
514
  name,
515
+ contextWindow: toPositiveNumber(entry.context_length, defaults.contextWindow),
516
+ maxTokens: toPositiveNumber(entry.max_completion_tokens, defaults.maxTokens),
548
517
  };
549
518
  }
550
519
  // Generic `/models` rows describe the chat roster. Do not make a bundled
@@ -1725,8 +1694,9 @@ async function fetchDeepinfraModels(options: {
1725
1694
  const fetchImpl = discoveryFetch(options.fetch);
1726
1695
  let payload: unknown;
1727
1696
  try {
1728
- const response = await withCatalogDiscoveryTimeout(DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS, signal =>
1729
- fetchImpl(`${options.baseUrl}/models${DEEPINFRA_MODELS_QUERY}`, { method: "GET", headers, signal }),
1697
+ const response = await withOpenAICompatibleDiscoveryTimeout(
1698
+ DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS,
1699
+ signal => fetchImpl(`${options.baseUrl}/models${DEEPINFRA_MODELS_QUERY}`, { method: "GET", headers, signal }),
1730
1700
  );
1731
1701
  if (!response.ok) {
1732
1702
  return null;
@@ -2284,7 +2254,7 @@ async function loadSiliconFlowModelsDevReferences(
2284
2254
  try {
2285
2255
  // Bounded: this enrichment is optional, so a stalled models.dev must not
2286
2256
  // hold back the authoritative endpoint request that runs after it.
2287
- const payload = await withCatalogDiscoveryTimeout(SILICONFLOW_MODELS_DEV_REFERENCE_TIMEOUT_MS, signal =>
2257
+ const payload = await withOpenAICompatibleDiscoveryTimeout(SILICONFLOW_MODELS_DEV_REFERENCE_TIMEOUT_MS, signal =>
2288
2258
  fetchWellKnownModels(fetchImpl, signal),
2289
2259
  );
2290
2260
  return createModelsDevReferenceMap<"openai-completions">(
@@ -2828,7 +2798,7 @@ const CLINE_PASS_LIVE_CATALOG_URL = "https://openrouter.ai/api/v1/models";
2828
2798
  /** Enrichment tier, never authoritative: any failure degrades to the bundled path. */
2829
2799
  async function fetchClinePassLiveCatalog(fetchImpl: FetchImpl): Promise<ClinePassLiveCatalog | undefined> {
2830
2800
  try {
2831
- const response = await withCatalogDiscoveryTimeout(5_000, signal =>
2801
+ const response = await withOpenAICompatibleDiscoveryTimeout(5_000, signal =>
2832
2802
  fetchImpl(CLINE_PASS_LIVE_CATALOG_URL, { signal }),
2833
2803
  );
2834
2804
  if (!response.ok) return undefined;
@@ -3007,7 +2977,7 @@ async function fetchClinePassModels(
3007
2977
  // The roster is authoritative for ids; the live reference catalog is a pure
3008
2978
  // enrichment tier fetched alongside and tolerated away (offline → bundle).
3009
2979
  const [response, liveCatalog] = await Promise.all([
3010
- withCatalogDiscoveryTimeout(5_000, signal =>
2980
+ withOpenAICompatibleDiscoveryTimeout(5_000, signal =>
3011
2981
  fetchImpl(CLINEPASS_MODELS_URL, { signal, headers: clinePassClientHeaders() }),
3012
2982
  ),
3013
2983
  fetchClinePassLiveCatalog(fetchImpl),
@@ -4422,7 +4392,7 @@ export async function fetchLmStudioNativeModelMetadata(
4422
4392
  if (options?.signal !== undefined) {
4423
4393
  return fetchMetadata(options.signal);
4424
4394
  }
4425
- return withCatalogDiscoveryTimeout(LM_STUDIO_NATIVE_METADATA_TIMEOUT_MS, fetchMetadata);
4395
+ return withOpenAICompatibleDiscoveryTimeout(LM_STUDIO_NATIVE_METADATA_TIMEOUT_MS, fetchMetadata);
4426
4396
  }
4427
4397
 
4428
4398
  export interface LmStudioModelManagerConfig {
@@ -4770,8 +4740,6 @@ export function basetenModelManagerOptions(
4770
4740
  cacheWrite: 0,
4771
4741
  };
4772
4742
 
4773
- const contextWindow = toPositiveNumber(raw.context_length, reference?.contextWindow ?? defaults.contextWindow);
4774
- const maxTokens = toPositiveNumber(raw.max_completion_tokens, reference?.maxTokens ?? defaults.maxTokens);
4775
4743
  const baseModel = mapWithBundledReference(entry, defaults, reference);
4776
4744
 
4777
4745
  return {
@@ -4779,8 +4747,6 @@ export function basetenModelManagerOptions(
4779
4747
  reasoning,
4780
4748
  input: vision ? ["text", "image"] : ["text"],
4781
4749
  cost,
4782
- contextWindow,
4783
- maxTokens,
4784
4750
  ...(supportsTools === false ? { supportsTools } : {}),
4785
4751
  };
4786
4752
  },
@@ -6393,7 +6359,9 @@ async function fetchLiteLLMRichModelsInternal<TApi extends Api>(
6393
6359
  if (options.signal !== undefined) {
6394
6360
  return fetchModels(options.signal);
6395
6361
  }
6396
- return options.timeoutMs !== undefined ? withCatalogDiscoveryTimeout(options.timeoutMs, fetchModels) : fetchModels();
6362
+ return options.timeoutMs !== undefined
6363
+ ? withOpenAICompatibleDiscoveryTimeout(options.timeoutMs, fetchModels)
6364
+ : fetchModels();
6397
6365
  }
6398
6366
 
6399
6367
  export async function fetchLiteLLMRichModels<TApi extends Api>(
@@ -6544,13 +6512,7 @@ export function nanoGptModelManagerOptions(
6544
6512
  const maxOutputTokens = entry.max_output_tokens ?? entry.max_completion_tokens;
6545
6513
  const mapped = mapWithBundledReference(
6546
6514
  { ...entry, max_completion_tokens: maxOutputTokens },
6547
- reference
6548
- ? defaults
6549
- : {
6550
- ...defaults,
6551
- contextWindow: toPositiveNumberOrNull(entry.context_length),
6552
- maxTokens: toPositiveNumberOrNull(maxOutputTokens),
6553
- },
6515
+ defaults,
6554
6516
  reference,
6555
6517
  );
6556
6518
  return {
@@ -6783,7 +6745,7 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana
6783
6745
  fetchDynamicModels: async () => {
6784
6746
  const fetchImpl = discoveryFetch(config?.fetch);
6785
6747
  const requestBaseUrl = isPersonalGitHubCopilotBaseUrl(baseUrl)
6786
- ? ((await withCatalogDiscoveryTimeout(DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS, signal =>
6748
+ ? ((await withOpenAICompatibleDiscoveryTimeout(DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS, signal =>
6787
6749
  discoverGitHubCopilotApiEndpoint(apiKey, fetchImpl, signal),
6788
6750
  )) ?? baseUrl)
6789
6751
  : baseUrl;
@@ -7794,6 +7756,8 @@ const MODELS_DEV_PROVIDER_DESCRIPTORS_SPECIALIZED: readonly ModelsDevProviderDes
7794
7756
  return { api: "openai-completions" as const, baseUrl: ZENMUX_OPENAI_BASE_URL };
7795
7757
  },
7796
7758
  }),
7759
+ // --- Modular ---
7760
+ openAiCompletionsDescriptor("modular", "modular", "https://api.modular.com/v1"),
7797
7761
  ];
7798
7762
  /** All provider descriptors for models.dev data mapping in generate-models.ts. */
7799
7763
  export const MODELS_DEV_PROVIDER_DESCRIPTORS: readonly ModelsDevProviderDescriptor[] = [
@@ -8238,3 +8202,40 @@ export function singularityApiTechModelManagerOptions(
8238
8202
  ): ModelManagerOptions<Api> {
8239
8203
  return singularityApiModelManagerOptions("singularityapi-tech", SINGULARITYAPI_TECH_API_BASE_URL, config);
8240
8204
  }
8205
+
8206
+ // ---------------------------------------------------------------------------
8207
+ // Scaleway
8208
+ // ---------------------------------------------------------------------------
8209
+
8210
+ /**
8211
+ * Scaleway discovery configuration: the API key plus optional base-URL and
8212
+ * fetch overrides. Consumed by {@link scalewayModelManagerOptions}, and
8213
+ * exported for extensions and tests that construct the manager directly.
8214
+ */
8215
+ export interface ScalewayModelManagerConfig {
8216
+ apiKey?: string;
8217
+ baseUrl?: string;
8218
+ fetch?: FetchImpl;
8219
+ }
8220
+
8221
+ /**
8222
+ * Scaleway model manager: plain OpenAI-compatible chat completions at
8223
+ * `api.scaleway.ai/v1` (fr-par serverless Generative APIs). `/v1/models`
8224
+ * returns bare ids, so the authored seed in `providers/scaleway.kdl` is the
8225
+ * reference each live row merges names, modalities, prices, and limits from;
8226
+ * a successful fetch is the complete roster and prunes retired rows.
8227
+ */
8228
+ export function scalewayModelManagerOptions(
8229
+ config?: ScalewayModelManagerConfig,
8230
+ ): ModelManagerOptions<"openai-completions"> {
8231
+ return createOpenAICompatibleModelManagerOptions({
8232
+ api: "openai-completions",
8233
+ providerId: "scaleway",
8234
+ defaultBaseUrl: "https://api.scaleway.ai/v1",
8235
+ config,
8236
+ dynamicModelsAuthoritative: true,
8237
+ requireApiKey: true,
8238
+ filterModel: (_entry, model) => !isExcludedModel("scaleway", model.id),
8239
+ mapModel: mapWithBundledReference,
8240
+ });
8241
+ }
package/src/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ModelIdentity } from "./compat/types";
2
2
  import type { Effort } from "./effort";
3
+ import type { DatedCostCard } from "./pricing";
3
4
 
4
5
  // Re-exported from @oh-my-pi/pi-utils so the whole workspace shares one
5
6
  // `fetch`-compatible signature (tls-fetch's wrappers produce/accept it).
@@ -1477,8 +1478,8 @@ export interface ModelCost extends TokenCost {
1477
1478
  /**
1478
1479
  * Exact local content tokenizer family for a model.
1479
1480
  *
1480
- * Absent means no first-party local tokenizer is known and consumers retain
1481
- * their estimate/default-tokenizer policy. The values name tokenizer
1481
+ * Absent means no first-party local tokenizer is known and consumers count
1482
+ * in o200k_base. The values name tokenizer
1482
1483
  * generations rather than providers: DeepSeek V3 through V4 share
1483
1484
  * `"deepseek-v3"`; Kimi K2 through K3 share `"kimi-k2"`.
1484
1485
  */
@@ -1491,6 +1492,7 @@ export type ModelTokenizer =
1491
1492
  | "deepseek-v3"
1492
1493
  | "kimi-k2"
1493
1494
  | "glm5"
1495
+ | "cl100k"
1494
1496
  | "o200k";
1495
1497
 
1496
1498
  /** One account's discovered entitlements on a model; see {@link Model.accountAccess}. */
@@ -1686,8 +1688,12 @@ export interface Model<TApi extends Api = Api> {
1686
1688
  factoryDroidApiProviders?: string[];
1687
1689
  /** Factory Droid: `configured_order` when live routing chose the rotation; absent means registry order. */
1688
1690
  factoryDroidRoutingSource?: "configured_order";
1691
+ /** Factory Droid: server-side refusal fallbacks the account can run; absent when none are named. */
1692
+ factoryDroidRefusalFallbacks?: string[];
1689
1693
  /** Factory Droid Standard Credits base rate (relative per-token weight, not dollars). */
1690
1694
  factoryDroidCredits?: number;
1695
+ /** Factory Droid dated `credit-rates` cards, ascending; `getFactoryDroidCreditRate` picks the one due now. */
1696
+ factoryDroidCreditCards?: readonly DatedCostCard[];
1691
1697
  /** Canonical organization resolved during Factory discovery. */
1692
1698
  factoryDroidOrgId?: string;
1693
1699
  cost: ModelCost;
@@ -1760,8 +1766,9 @@ export interface Model<TApi extends Api = Api> {
1760
1766
  useResponsesLite?: boolean;
1761
1767
  /**
1762
1768
  * Service-tier ids the provider advertises for this model (Codex discovery
1763
- * `service_tiers[].id`, e.g. `priority`, `ultrafast`). Absent when the
1764
- * provider publishes no per-model tier list.
1769
+ * `service_tiers[].id`, or an OpenAI-compatible `/v1/models` row's
1770
+ * `service_tiers`, e.g. `priority`, `ultrafast`). Absent when the provider
1771
+ * publishes no per-model tier list.
1765
1772
  */
1766
1773
  serviceTiers?: readonly string[];
1767
1774
  /** Preferred model to switch to when context promotion is triggered (model id or provider/id). */
package/src/utils.ts CHANGED
@@ -40,6 +40,24 @@ export function toBoolean(value: unknown): boolean | undefined {
40
40
  return typeof value === "boolean" ? value : undefined;
41
41
  }
42
42
 
43
+ /**
44
+ * Service-tier ids from a discovery row's `service_tiers` array, deduplicated
45
+ * in advertisement order. Entries may be codex-rs `{ id, name, description }`
46
+ * objects or bare id strings (OpenAI-compatible relays). Returns `undefined`
47
+ * when the value is not an array; an empty array is kept, since it is a
48
+ * statement that the model offers no optional tier.
49
+ */
50
+ export function parseServiceTierIds(value: unknown): string[] | undefined {
51
+ if (!Array.isArray(value)) return undefined;
52
+ const ids: string[] = [];
53
+ for (const tier of value) {
54
+ const raw = tier !== null && typeof tier === "object" && "id" in tier ? tier.id : tier;
55
+ const id = typeof raw === "string" ? raw.trim() : "";
56
+ if (id && !ids.includes(id)) ids.push(id);
57
+ }
58
+ return ids;
59
+ }
60
+
43
61
  export function isAnthropicOAuthToken(key: string): boolean {
44
62
  return key.includes("sk-ant-oat");
45
63
  }
@@ -124,7 +124,8 @@ export function factoryDroidModelCacheProviderId(options: { apiKey?: string } &
124
124
  }
125
125
  const region = options.region === "eu" ? "eu" : "global";
126
126
  const scope = `${credentialScope}\u0000${options.orgId ?? ""}\u0000${region}\u0000${options.inferenceRegion ?? region}`;
127
- // v4: rows cached before credits became a scalar base rate carry the old
128
- // `{ input, output, cacheRead }` object and must be refetched.
129
- return `factory-droid:models-v4:${Bun.hash(scope).toString(36)}`;
127
+ // v5: rows cached before discovery recorded the account's refusal fallbacks
128
+ // would let the request path send every fallback the rules name, so they
129
+ // must be refetched.
130
+ return `factory-droid:models-v5:${Bun.hash(scope).toString(36)}`;
130
131
  }