@hyav/pi-provider 0.1.7 → 0.2.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.
@@ -0,0 +1,739 @@
1
+ import type {
2
+ ModelCostBySkuSources,
3
+ ModelFieldSource,
4
+ ModelFieldSources,
5
+ ProviderCost,
6
+ ProviderModel,
7
+ ProviderModelDraft,
8
+ } from "./types.ts";
9
+
10
+ export type ThinkingLevelMap = NonNullable<ProviderModel["thinkingLevelMap"]>;
11
+
12
+ export const ORIGINAL_PI_PROVIDER_ALLOWLIST = new Set<string>([
13
+ "ant-ling",
14
+ "anthropic",
15
+ "deepseek",
16
+ "google",
17
+ "kimi-coding",
18
+ "minimax",
19
+ "minimax-cn",
20
+ "mistral",
21
+ "moonshotai",
22
+ "moonshotai-cn",
23
+ "openai",
24
+ "xai",
25
+ "xiaomi",
26
+ "zai",
27
+ "zai-coding-cn",
28
+ ]);
29
+
30
+ export interface PiCatalogModelMeta {
31
+ id: string;
32
+ name?: string;
33
+ contextWindow?: number;
34
+ maxTokens?: number;
35
+ input?: ("text" | "image")[];
36
+ reasoning?: boolean;
37
+ thinkingLevelMap?: Record<string, string | null>;
38
+ compat?: Record<string, unknown>;
39
+ cost?: ProviderCost;
40
+ provider?: string;
41
+ canonicalId?: string;
42
+ aliases?: string[];
43
+ }
44
+
45
+ export interface PiCatalogSnapshot {
46
+ models: Map<string, PiCatalogModelMeta>;
47
+ byProvider: Map<string, Map<string, PiCatalogModelMeta>>;
48
+ generatedAt?: number;
49
+ version?: string;
50
+ }
51
+
52
+ /** The generated catalog functions exposed by Pi's active pi-ai module. */
53
+ export interface PiCatalogSource {
54
+ getBuiltinProviders: () => string[];
55
+ getBuiltinModels: (provider: string) => unknown[];
56
+ getBuiltinModelDataGeneratedAt?: () => number | undefined;
57
+ }
58
+
59
+ export interface ModelMatchResult {
60
+ matched: PiCatalogModelMeta;
61
+ matchType: "exact" | "alias" | "normalized";
62
+ provider?: string;
63
+ }
64
+
65
+ export interface MergedModelResult {
66
+ draft: ProviderModelDraft;
67
+ fieldSources: ModelFieldSources;
68
+ matchedModel?: PiCatalogModelMeta;
69
+ matchType?: "exact" | "alias" | "normalized" | "none";
70
+ }
71
+
72
+ function cloneCost(cost: ProviderCost): ProviderCost {
73
+ return {
74
+ ...cost,
75
+ ...(cost.tiers ? { tiers: cost.tiers.map((tier) => ({ ...tier })) } : {}),
76
+ };
77
+ }
78
+
79
+ function cloneThinkingMap(map: Record<string, string | null>): Record<string, string | null> {
80
+ return { ...map };
81
+ }
82
+
83
+ function isSameManufacturer(providerA: string | undefined, providerB: string | undefined): boolean {
84
+ if (!providerA || !providerB) return false;
85
+ if (providerA === providerB) return true;
86
+ const families = [
87
+ ["minimax", "minimax-cn"],
88
+ ["moonshotai", "moonshotai-cn", "kimi-coding"],
89
+ ["zai", "zai-coding-cn"],
90
+ ];
91
+ return families.some((group) => group.includes(providerA) && group.includes(providerB));
92
+ }
93
+
94
+ export function createEmptyCatalogSnapshot(): PiCatalogSnapshot {
95
+ return {
96
+ models: new Map(),
97
+ byProvider: new Map(),
98
+ };
99
+ }
100
+
101
+ export function parsePiCatalogFromProviders(
102
+ builtinProviders: string[],
103
+ getModels: (provider: string) => unknown[],
104
+ generatedAt?: number,
105
+ allowlist: ReadonlySet<string> = ORIGINAL_PI_PROVIDER_ALLOWLIST,
106
+ ): PiCatalogSnapshot {
107
+ const models = new Map<string, PiCatalogModelMeta>();
108
+ const byProvider = new Map<string, Map<string, PiCatalogModelMeta>>();
109
+ const collisions = new Set<string>();
110
+
111
+ for (const providerId of builtinProviders) {
112
+ if (!allowlist.has(providerId)) continue;
113
+ const providerModels = new Map<string, PiCatalogModelMeta>();
114
+ byProvider.set(providerId, providerModels);
115
+
116
+ const rawModels = getModels(providerId);
117
+ if (!Array.isArray(rawModels)) continue;
118
+
119
+ for (const raw of rawModels) {
120
+ if (!raw || typeof raw !== "object") continue;
121
+ const item = raw as Record<string, unknown>;
122
+ if (typeof item.id !== "string" || item.id.trim() === "") continue;
123
+
124
+ const id = item.id.trim();
125
+ const lowerId = id.toLowerCase();
126
+ const meta: PiCatalogModelMeta = {
127
+ id,
128
+ provider: providerId,
129
+ };
130
+
131
+ if (typeof item.name === "string" && item.name.trim() !== "") {
132
+ meta.name = item.name.trim();
133
+ }
134
+ if (typeof item.contextWindow === "number" && Number.isFinite(item.contextWindow) && item.contextWindow > 0) {
135
+ meta.contextWindow = item.contextWindow;
136
+ }
137
+ if (typeof item.maxTokens === "number" && Number.isFinite(item.maxTokens) && item.maxTokens > 0) {
138
+ meta.maxTokens = item.maxTokens;
139
+ }
140
+ if (typeof item.reasoning === "boolean") {
141
+ meta.reasoning = item.reasoning;
142
+ }
143
+ if (Array.isArray(item.input)) {
144
+ const filtered = item.input.filter((mode): mode is "text" | "image" => mode === "text" || mode === "image");
145
+ if (filtered.length > 0) meta.input = [...new Set(filtered)];
146
+ }
147
+ if (
148
+ item.thinkingLevelMap &&
149
+ typeof item.thinkingLevelMap === "object" &&
150
+ !Array.isArray(item.thinkingLevelMap)
151
+ ) {
152
+ meta.thinkingLevelMap = cloneThinkingMap(item.thinkingLevelMap as Record<string, string | null>);
153
+ }
154
+ if (item.compat && typeof item.compat === "object" && !Array.isArray(item.compat)) {
155
+ const {
156
+ baseUrl: _b,
157
+ api: _a,
158
+ headers: _h,
159
+ apiKey: _k,
160
+ ...safeCompat
161
+ } = item.compat as Record<string, unknown>;
162
+ meta.compat = { ...safeCompat };
163
+ }
164
+ if (item.cost && typeof item.cost === "object" && !Array.isArray(item.cost)) {
165
+ meta.cost = cloneCost(item.cost as ProviderCost);
166
+ }
167
+
168
+ providerModels.set(lowerId, meta);
169
+
170
+ if (collisions.has(lowerId)) {
171
+ continue;
172
+ }
173
+ const existing = models.get(lowerId);
174
+ if (existing) {
175
+ if (isSameManufacturer(existing.provider, providerId)) {
176
+ const existingIsPaid =
177
+ existing.cost !== undefined && (Number(existing.cost.input) > 0 || Number(existing.cost.output) > 0);
178
+ const metaIsZero =
179
+ meta.cost !== undefined && Number(meta.cost.input) === 0 && Number(meta.cost.output) === 0;
180
+ if (existingIsPaid && metaIsZero) {
181
+ if (existing.contextWindow === undefined && meta.contextWindow !== undefined) {
182
+ existing.contextWindow = meta.contextWindow;
183
+ }
184
+ if (existing.maxTokens === undefined && meta.maxTokens !== undefined) {
185
+ existing.maxTokens = meta.maxTokens;
186
+ }
187
+ if (existing.reasoning === undefined && meta.reasoning !== undefined) {
188
+ existing.reasoning = meta.reasoning;
189
+ }
190
+ if (!existing.input && meta.input) {
191
+ existing.input = [...meta.input];
192
+ }
193
+ if (!existing.thinkingLevelMap && meta.thinkingLevelMap) {
194
+ existing.thinkingLevelMap = cloneThinkingMap(meta.thinkingLevelMap);
195
+ }
196
+ } else {
197
+ models.set(lowerId, meta);
198
+ }
199
+ } else {
200
+ collisions.add(lowerId);
201
+ models.delete(lowerId);
202
+ }
203
+ } else {
204
+ models.set(lowerId, meta);
205
+ }
206
+ }
207
+ }
208
+
209
+ return {
210
+ models,
211
+ byProvider,
212
+ generatedAt,
213
+ };
214
+ }
215
+
216
+ let cachedGlobalSnapshot: PiCatalogSnapshot | undefined;
217
+ const snapshotCache = new Map<string, PiCatalogSnapshot>();
218
+ let cachedAllModule: PiCatalogSource | undefined;
219
+
220
+ function getAllowlistCacheKey(allowlist: ReadonlySet<string>): string {
221
+ if (allowlist === ORIGINAL_PI_PROVIDER_ALLOWLIST) return "__default__";
222
+ if (
223
+ allowlist.size === ORIGINAL_PI_PROVIDER_ALLOWLIST.size &&
224
+ [...allowlist].every((item) => ORIGINAL_PI_PROVIDER_ALLOWLIST.has(item))
225
+ ) {
226
+ return "__default__";
227
+ }
228
+ return [...allowlist].sort().join(",");
229
+ }
230
+
231
+ export function isLegacyNormalizedModel(model: unknown): boolean {
232
+ if (!model || typeof model !== "object") return false;
233
+ const m = model as Record<string, unknown>;
234
+ const cost = m.cost as Record<string, unknown> | undefined;
235
+ return (
236
+ m.contextWindow === 128_000 &&
237
+ m.maxTokens === 16_384 &&
238
+ m.reasoning === false &&
239
+ typeof cost === "object" &&
240
+ cost !== null &&
241
+ cost.input === 0 &&
242
+ cost.output === 0
243
+ );
244
+ }
245
+
246
+ export function isLegacyNormalizedSnapshot(models: unknown): boolean {
247
+ if (!Array.isArray(models) || models.length === 0) return false;
248
+ return models.some(isLegacyNormalizedModel);
249
+ }
250
+
251
+ interface ModelRegistryCatalogLike {
252
+ getAll?: () => unknown;
253
+ getProvider?: (provider: string) => unknown;
254
+ }
255
+
256
+ function parsePiCatalogFromModelRegistry(
257
+ modelRegistry: unknown,
258
+ allowlist: ReadonlySet<string>,
259
+ ): PiCatalogSnapshot | undefined {
260
+ if (modelRegistry === null || typeof modelRegistry !== "object") return undefined;
261
+ const registry = modelRegistry as ModelRegistryCatalogLike;
262
+ const allowedProviders = new Map<string, string>();
263
+ for (const provider of allowlist) allowedProviders.set(provider.toLowerCase(), provider);
264
+ const grouped = new Map<string, unknown[]>();
265
+ const addModel = (model: unknown): void => {
266
+ if (model === null || typeof model !== "object") return;
267
+ const provider = (model as Record<string, unknown>).provider;
268
+ if (typeof provider !== "string") return;
269
+ const canonicalProvider = allowedProviders.get(provider.toLowerCase());
270
+ if (!canonicalProvider) return;
271
+ const models = grouped.get(canonicalProvider);
272
+ if (models) models.push(model);
273
+ else grouped.set(canonicalProvider, [model]);
274
+ };
275
+
276
+ if (typeof registry.getAll === "function") {
277
+ try {
278
+ const models = registry.getAll();
279
+ if (Array.isArray(models)) {
280
+ for (const model of models) addModel(model);
281
+ return parsePiCatalogFromProviders([...allowlist], (provider) => grouped.get(provider) ?? []);
282
+ }
283
+ } catch {
284
+ // Fall through to the provider-by-provider compatibility path.
285
+ }
286
+ }
287
+
288
+ if (typeof registry.getProvider !== "function") return undefined;
289
+ let foundProvider = false;
290
+ for (const provider of allowlist) {
291
+ try {
292
+ const candidate = registry.getProvider(provider);
293
+ if (candidate === null || typeof candidate !== "object") continue;
294
+ foundProvider = true;
295
+ const getModels = (candidate as { getModels?: unknown }).getModels;
296
+ if (typeof getModels !== "function") continue;
297
+ const models = getModels.call(candidate);
298
+ if (!Array.isArray(models)) continue;
299
+ for (const model of models) addModel(model);
300
+ } catch {
301
+ // A single provider must not prevent the remaining catalog from loading.
302
+ }
303
+ }
304
+ if (!foundProvider) return undefined;
305
+ return parsePiCatalogFromProviders([...allowlist], (provider) => grouped.get(provider) ?? []);
306
+ }
307
+
308
+ function parsePiCatalogFromSource(source: PiCatalogSource, allowlist: ReadonlySet<string>): PiCatalogSnapshot {
309
+ const providers = source.getBuiltinProviders();
310
+ const generatedAt = source.getBuiltinModelDataGeneratedAt?.();
311
+ return parsePiCatalogFromProviders(
312
+ providers,
313
+ (provider) => source.getBuiltinModels(provider),
314
+ generatedAt,
315
+ allowlist,
316
+ );
317
+ }
318
+
319
+ export interface LoadPiCatalogOptions {
320
+ allowlist?: ReadonlySet<string>;
321
+ /** Current Pi registry, preferred over any module-local static catalog. */
322
+ modelRegistry?: unknown;
323
+ /** Catalog functions captured from Pi's outer extension module graph. */
324
+ builtinCatalog?: PiCatalogSource;
325
+ fetch?: typeof globalThis.fetch;
326
+ }
327
+
328
+ export async function loadPiCatalog(
329
+ optionsOrAllowlist?: ReadonlySet<string> | LoadPiCatalogOptions,
330
+ ): Promise<PiCatalogSnapshot> {
331
+ const options: LoadPiCatalogOptions =
332
+ optionsOrAllowlist && "has" in optionsOrAllowlist
333
+ ? { allowlist: optionsOrAllowlist as ReadonlySet<string> }
334
+ : ((optionsOrAllowlist as LoadPiCatalogOptions | undefined) ?? {});
335
+ const allowlist = options.allowlist ?? ORIGINAL_PI_PROVIDER_ALLOWLIST;
336
+
337
+ // The registry belongs to the running Pi instance. It is intentionally not
338
+ // cached: Pi can refresh its native catalog while the process is alive.
339
+ if (options.modelRegistry !== undefined) {
340
+ const runtimeSnapshot = parsePiCatalogFromModelRegistry(options.modelRegistry, allowlist);
341
+ if (runtimeSnapshot) return runtimeSnapshot;
342
+ }
343
+
344
+ // A Pi-loaded entrypoint can capture the host's virtual pi-ai module before
345
+ // handing control to the nested Jiti graph. This is what makes startup and
346
+ // --list-models use the same catalog as the active Pi binary.
347
+ if (options.builtinCatalog) {
348
+ try {
349
+ return parsePiCatalogFromSource(options.builtinCatalog, allowlist);
350
+ } catch {
351
+ // Fall back to the module resolved from this package below.
352
+ }
353
+ }
354
+
355
+ const cacheKey = getAllowlistCacheKey(allowlist);
356
+ if (cacheKey === "__default__" && cachedGlobalSnapshot) return cachedGlobalSnapshot;
357
+ const cached = snapshotCache.get(cacheKey);
358
+ if (cached) return cached;
359
+
360
+ try {
361
+ if (!cachedAllModule) {
362
+ cachedAllModule = (await import("@earendil-works/pi-ai/providers/all")) as PiCatalogSource;
363
+ }
364
+ const snapshot = parsePiCatalogFromSource(cachedAllModule, allowlist);
365
+ snapshotCache.set(cacheKey, snapshot);
366
+ if (cacheKey === "__default__") {
367
+ cachedGlobalSnapshot = snapshot;
368
+ }
369
+ return snapshot;
370
+ } catch {
371
+ const empty = createEmptyCatalogSnapshot();
372
+ snapshotCache.set(cacheKey, empty);
373
+ if (cacheKey === "__default__") {
374
+ cachedGlobalSnapshot = empty;
375
+ }
376
+ return empty;
377
+ }
378
+ }
379
+
380
+ export function setPiCatalogForTest(snapshot: PiCatalogSnapshot | undefined): void {
381
+ snapshotCache.clear();
382
+ cachedGlobalSnapshot = snapshot;
383
+ }
384
+
385
+ export function toPiCatalogSnapshot(candidate: unknown): PiCatalogSnapshot | undefined {
386
+ if (!candidate) return cachedGlobalSnapshot ?? undefined;
387
+ if (
388
+ typeof candidate === "object" &&
389
+ candidate !== null &&
390
+ "models" in candidate &&
391
+ (candidate as PiCatalogSnapshot).models instanceof Map
392
+ ) {
393
+ return candidate as PiCatalogSnapshot;
394
+ }
395
+ if (typeof candidate === "object" && candidate !== null) {
396
+ const rawCandidate = candidate as Record<string, any>;
397
+ const entries =
398
+ "models" in rawCandidate &&
399
+ typeof rawCandidate.models === "object" &&
400
+ rawCandidate.models !== null &&
401
+ !(rawCandidate.models instanceof Map)
402
+ ? Object.entries(rawCandidate.models)
403
+ : Object.entries(rawCandidate);
404
+ const models = new Map<string, PiCatalogModelMeta>();
405
+ for (const [id, value] of entries) {
406
+ if (!value || typeof value !== "object") continue;
407
+ const provider = id.includes("/") ? id.slice(0, id.indexOf("/")) : "custom";
408
+ const rawModelId = id.includes("/") ? id.slice(id.indexOf("/") + 1) : id;
409
+ const meta: PiCatalogModelMeta = {
410
+ id: rawModelId,
411
+ provider,
412
+ ...(value.name ? { name: value.name } : {}),
413
+ ...(value.contextWindow !== undefined ? { contextWindow: value.contextWindow } : {}),
414
+ ...(value.maxTokens !== undefined ? { maxTokens: value.maxTokens } : {}),
415
+ ...(value.input ? { input: [...value.input] } : {}),
416
+ ...(value.reasoning !== undefined ? { reasoning: value.reasoning } : {}),
417
+ ...(value.thinkingLevelMap ? { thinkingLevelMap: { ...value.thinkingLevelMap } } : {}),
418
+ ...(value.compat ? { compat: { ...value.compat } } : {}),
419
+ ...(value.cost ? { cost: cloneCost(value.cost) } : {}),
420
+ };
421
+ models.set(id.toLowerCase().trim(), meta);
422
+ models.set(rawModelId.toLowerCase().trim(), meta);
423
+ }
424
+ return {
425
+ models,
426
+ byProvider: new Map(),
427
+ generatedAt: Date.now(),
428
+ };
429
+ }
430
+ return cachedGlobalSnapshot ?? undefined;
431
+ }
432
+
433
+ export function stripProviderPrefix(modelId: string, currentProviderId?: string): string {
434
+ let candidate = modelId.trim();
435
+ if (currentProviderId && candidate.toLowerCase().startsWith(`${currentProviderId.toLowerCase()}/`)) {
436
+ candidate = candidate.slice(currentProviderId.length + 1);
437
+ }
438
+ const slashIndex = candidate.indexOf("/");
439
+ if (slashIndex > 0) {
440
+ const prefix = candidate.slice(0, slashIndex).toLowerCase();
441
+ if (
442
+ prefix === "openapi" ||
443
+ prefix === "models" ||
444
+ prefix === "google" ||
445
+ prefix === "deepseek" ||
446
+ prefix === "anthropic" ||
447
+ prefix === "openai" ||
448
+ prefix === "xai" ||
449
+ prefix === "minimax" ||
450
+ prefix === "mistral" ||
451
+ prefix === "moonshotai" ||
452
+ prefix === "zai"
453
+ ) {
454
+ candidate = candidate.slice(slashIndex + 1);
455
+ }
456
+ }
457
+ return candidate;
458
+ }
459
+
460
+ const DATE_SUFFIX_REGEX = /-(?:20\d{2}[-_]?\d{2}[-_]?\d{2}|\d{4})$/i;
461
+ const LATEST_SUFFIX_REGEX = /-latest$/i;
462
+ const EFFORT_SUFFIX_REGEX = /-(?:minimal|low|medium|high|xhigh|max|thinking|reasoning)(?:-effort)?$/i;
463
+ const THINKING_BUDGET_REGEX = /-(?:thinking|budget)-\d+k$/i;
464
+ const PREVIEW_SUFFIX_REGEX = /-preview(?:-\d{2}-\d{4}|-\d{4})?$/i;
465
+
466
+ export function stripKnownModelSuffixes(modelId: string): string {
467
+ let result = modelId;
468
+ let changed = true;
469
+ while (changed) {
470
+ const prev = result;
471
+ result = result
472
+ .replace(DATE_SUFFIX_REGEX, "")
473
+ .replace(LATEST_SUFFIX_REGEX, "")
474
+ .replace(EFFORT_SUFFIX_REGEX, "")
475
+ .replace(THINKING_BUDGET_REGEX, "")
476
+ .replace(PREVIEW_SUFFIX_REGEX, "");
477
+ changed = result !== prev;
478
+ }
479
+ return result;
480
+ }
481
+
482
+ export function findPiCatalogModel(
483
+ modelId: string,
484
+ catalog: PiCatalogSnapshot,
485
+ currentProviderId?: string,
486
+ ): ModelMatchResult | undefined {
487
+ if (!modelId || modelId.trim() === "") return undefined;
488
+ const trimmed = modelId.trim();
489
+ const lower = trimmed.toLowerCase();
490
+
491
+ // Step 1: Exact match of full ID
492
+ if (currentProviderId) {
493
+ const providerModels = catalog.byProvider.get(currentProviderId.toLowerCase());
494
+ const exactInProvider = providerModels?.get(lower);
495
+ if (exactInProvider) {
496
+ return { matched: exactInProvider, matchType: "exact", provider: currentProviderId };
497
+ }
498
+ }
499
+ const exactGlobal = catalog.models.get(lower);
500
+ if (exactGlobal) {
501
+ return { matched: exactGlobal, matchType: "exact", provider: exactGlobal.provider };
502
+ }
503
+
504
+ // Step 2: Strip transport provider prefix
505
+ const strippedPrefix = stripProviderPrefix(trimmed, currentProviderId).toLowerCase();
506
+ if (strippedPrefix !== lower) {
507
+ if (currentProviderId) {
508
+ const providerModels = catalog.byProvider.get(currentProviderId.toLowerCase());
509
+ const matchInProvider = providerModels?.get(strippedPrefix);
510
+ if (matchInProvider) {
511
+ return { matched: matchInProvider, matchType: "normalized", provider: currentProviderId };
512
+ }
513
+ }
514
+ const matchGlobal = catalog.models.get(strippedPrefix);
515
+ if (matchGlobal) {
516
+ return { matched: matchGlobal, matchType: "normalized", provider: matchGlobal.provider };
517
+ }
518
+ }
519
+
520
+ // Step 3 & 4: Handle known latest, date, effort suffixes
521
+ const baseModelName = stripKnownModelSuffixes(strippedPrefix).toLowerCase();
522
+ if (baseModelName !== "" && baseModelName !== strippedPrefix) {
523
+ if (currentProviderId) {
524
+ const providerModels = catalog.byProvider.get(currentProviderId.toLowerCase());
525
+ const matchBaseInProvider = providerModels?.get(baseModelName);
526
+ if (matchBaseInProvider) {
527
+ return { matched: matchBaseInProvider, matchType: "normalized", provider: currentProviderId };
528
+ }
529
+ }
530
+ const matchBaseGlobal = catalog.models.get(baseModelName);
531
+ if (matchBaseGlobal) {
532
+ return { matched: matchBaseGlobal, matchType: "normalized", provider: matchBaseGlobal.provider };
533
+ }
534
+ }
535
+
536
+ // Step 5: Search for models matching normalized base name across pool
537
+ const candidates: PiCatalogModelMeta[] = [];
538
+ const searchPool = currentProviderId
539
+ ? (catalog.byProvider.get(currentProviderId.toLowerCase())?.values() ?? [])
540
+ : catalog.models.values();
541
+
542
+ for (const candidate of searchPool) {
543
+ const candLower = candidate.id.toLowerCase();
544
+ if (candLower === strippedPrefix || candLower === baseModelName) {
545
+ candidates.push(candidate);
546
+ continue;
547
+ }
548
+ const candBase = stripKnownModelSuffixes(candLower);
549
+ if (candBase === baseModelName) {
550
+ candidates.push(candidate);
551
+ }
552
+ }
553
+
554
+ const uniqueCandidates = new Map<string, PiCatalogModelMeta>();
555
+ for (const cand of candidates) {
556
+ uniqueCandidates.set(cand.id.toLowerCase(), cand);
557
+ }
558
+
559
+ if (uniqueCandidates.size === 1) {
560
+ const matched = uniqueCandidates.values().next().value as PiCatalogModelMeta;
561
+ return { matched, matchType: "normalized", provider: matched.provider };
562
+ }
563
+
564
+ return undefined;
565
+ }
566
+
567
+ export function mergeModelWithPiCatalog(
568
+ draft: ProviderModelDraft,
569
+ catalog: PiCatalogSnapshot | undefined,
570
+ options: {
571
+ useFallback?: boolean;
572
+ currentProviderId?: string;
573
+ } = {},
574
+ ): MergedModelResult {
575
+ const useFallback = options.useFallback ?? true;
576
+ if (!useFallback || !catalog) {
577
+ return {
578
+ draft: { ...draft },
579
+ fieldSources: {
580
+ contextWindow: draft.contextWindow !== undefined ? "provider" : "default",
581
+ maxTokens: draft.maxTokens !== undefined ? "provider" : "default",
582
+ input: draft.input !== undefined ? "provider" : "default",
583
+ reasoning: draft.reasoning !== undefined ? "provider" : "default",
584
+ thinkingLevelMap: draft.thinkingLevelMap !== undefined ? "provider" : "default",
585
+ cost: draft.cost !== undefined ? (draft.pricingSource ?? "provider") : "default",
586
+ },
587
+ matchType: "none",
588
+ };
589
+ }
590
+
591
+ const matchResult = findPiCatalogModel(draft.id, catalog, options.currentProviderId);
592
+ const piMeta = matchResult?.matched;
593
+ const matchType = matchResult?.matchType ?? "none";
594
+
595
+ // Number fields
596
+ const contextWindow = draft.contextWindow !== undefined ? draft.contextWindow : piMeta?.contextWindow;
597
+ const contextWindowSource =
598
+ draft.contextWindow !== undefined ? "provider" : piMeta?.contextWindow !== undefined ? "pi" : "default";
599
+
600
+ const maxTokens = draft.maxTokens !== undefined ? draft.maxTokens : piMeta?.maxTokens;
601
+ const maxTokensSource =
602
+ draft.maxTokens !== undefined ? "provider" : piMeta?.maxTokens !== undefined ? "pi" : "default";
603
+
604
+ // Boolean reasoning
605
+ const reasoning = draft.reasoning !== undefined ? draft.reasoning : piMeta?.reasoning;
606
+ const reasoningSource =
607
+ draft.reasoning !== undefined ? "provider" : piMeta?.reasoning !== undefined ? "pi" : "default";
608
+
609
+ // Input modes
610
+ const input = draft.input !== undefined ? draft.input : piMeta?.input ? [...piMeta.input] : undefined;
611
+ const inputSource = draft.input !== undefined ? "provider" : piMeta?.input !== undefined ? "pi" : "default";
612
+
613
+ // Thinking level map
614
+ let thinkingLevelMap: ThinkingLevelMap | undefined;
615
+ let thinkingLevelMapSource: "provider" | "pi" | "mixed" | "default" = "default";
616
+
617
+ if (draft.reasoning === false) {
618
+ thinkingLevelMap = undefined;
619
+ thinkingLevelMapSource = "default";
620
+ } else if (draft.thinkingLevelMap !== undefined && piMeta?.thinkingLevelMap !== undefined) {
621
+ const allLevels = new Set([...Object.keys(piMeta.thinkingLevelMap), ...Object.keys(draft.thinkingLevelMap)]);
622
+ const mergedMap: Record<string, string | null> = {};
623
+ let hasProviderLevel = false;
624
+ let hasPiLevel = false;
625
+
626
+ const draftThinkingMap = draft.thinkingLevelMap as Record<string, string | null>;
627
+ const piThinkingMap = piMeta.thinkingLevelMap as Record<string, string | null>;
628
+ for (const level of allLevels) {
629
+ if (level in draftThinkingMap) {
630
+ mergedMap[level] = draftThinkingMap[level] as string | null;
631
+ hasProviderLevel = true;
632
+ } else if (level in piThinkingMap) {
633
+ mergedMap[level] = piThinkingMap[level] as string | null;
634
+ hasPiLevel = true;
635
+ }
636
+ }
637
+ thinkingLevelMap = mergedMap;
638
+ thinkingLevelMapSource = hasProviderLevel && hasPiLevel ? "mixed" : hasProviderLevel ? "provider" : "pi";
639
+ } else if (draft.thinkingLevelMap !== undefined) {
640
+ thinkingLevelMap = { ...draft.thinkingLevelMap };
641
+ thinkingLevelMapSource = "provider";
642
+ } else if (piMeta?.thinkingLevelMap !== undefined) {
643
+ thinkingLevelMap = cloneThinkingMap(piMeta.thinkingLevelMap);
644
+ thinkingLevelMapSource = "pi";
645
+ }
646
+
647
+ // Compat
648
+ const compat =
649
+ draft.compat !== undefined || piMeta?.compat !== undefined
650
+ ? { ...(piMeta?.compat ?? {}), ...(draft.compat ?? {}) }
651
+ : undefined;
652
+
653
+ // Cost merging by SKU
654
+ let mergedCost: ProviderCost | undefined;
655
+ let costSource: ModelFieldSource = "default";
656
+ let costBySku: ModelCostBySkuSources | undefined;
657
+
658
+ if (draft.cost !== undefined && piMeta?.cost !== undefined) {
659
+ const draftCost = draft.cost;
660
+ const piCost = piMeta.cost;
661
+ costBySku = {
662
+ input: draftCost.input !== undefined ? "provider" : piCost.input !== undefined ? "pi" : "default",
663
+ output: draftCost.output !== undefined ? "provider" : piCost.output !== undefined ? "pi" : "default",
664
+ cacheRead: draftCost.cacheRead !== undefined ? "provider" : piCost.cacheRead !== undefined ? "pi" : "default",
665
+ cacheWrite:
666
+ draftCost.cacheWrite !== undefined ? "provider" : piCost.cacheWrite !== undefined ? "pi" : "default",
667
+ };
668
+ const values = [costBySku.input, costBySku.output, costBySku.cacheRead, costBySku.cacheWrite];
669
+ const hasProviderSku = values.some((v) => v === "provider");
670
+ const hasPiSku = values.some((v) => v === "pi");
671
+ costSource = hasProviderSku && hasPiSku ? "mixed" : hasProviderSku ? (draft.pricingSource ?? "provider") : "pi";
672
+ mergedCost = {
673
+ input: draftCost.input ?? piCost.input ?? 0,
674
+ output: draftCost.output ?? piCost.output ?? 0,
675
+ cacheRead: draftCost.cacheRead ?? piCost.cacheRead ?? 0,
676
+ cacheWrite: draftCost.cacheWrite ?? piCost.cacheWrite ?? 0,
677
+ ...(draftCost.tiers
678
+ ? { tiers: draftCost.tiers.map((t) => ({ ...t })) }
679
+ : piCost.tiers
680
+ ? { tiers: piCost.tiers.map((t) => ({ ...t })) }
681
+ : {}),
682
+ };
683
+ } else if (draft.cost !== undefined) {
684
+ mergedCost = cloneCost(draft.cost);
685
+ costSource = draft.pricingSource ?? "provider";
686
+ costBySku = {
687
+ input: "provider",
688
+ output: "provider",
689
+ cacheRead: "provider",
690
+ cacheWrite: "provider",
691
+ };
692
+ } else if (piMeta?.cost !== undefined) {
693
+ mergedCost = cloneCost(piMeta.cost);
694
+ costSource = "pi";
695
+ costBySku = {
696
+ input: "pi",
697
+ output: "pi",
698
+ cacheRead: "pi",
699
+ cacheWrite: "pi",
700
+ };
701
+ }
702
+
703
+ const name = draft.name !== undefined ? draft.name : piMeta?.name;
704
+
705
+ const mergedDraft: ProviderModelDraft = {
706
+ ...draft,
707
+ ...(name !== undefined ? { name } : {}),
708
+ ...(contextWindow !== undefined ? { contextWindow } : {}),
709
+ ...(maxTokens !== undefined ? { maxTokens } : {}),
710
+ ...(reasoning !== undefined ? { reasoning } : {}),
711
+ ...(input !== undefined ? { input } : {}),
712
+ ...(thinkingLevelMap !== undefined ? { thinkingLevelMap } : {}),
713
+ ...(compat !== undefined ? { compat } : {}),
714
+ ...(mergedCost !== undefined
715
+ ? {
716
+ cost: mergedCost,
717
+ pricingSource:
718
+ costSource === "mixed" ? "mixed" : costSource === "pi" ? "pi" : (draft.pricingSource ?? "provider"),
719
+ }
720
+ : {}),
721
+ };
722
+
723
+ const fieldSources: ModelFieldSources = {
724
+ contextWindow: contextWindowSource,
725
+ maxTokens: maxTokensSource,
726
+ reasoning: reasoningSource,
727
+ input: inputSource,
728
+ thinkingLevelMap: thinkingLevelMapSource,
729
+ cost: costSource,
730
+ ...(costBySku ? { costBySku } : {}),
731
+ };
732
+
733
+ return {
734
+ draft: mergedDraft,
735
+ fieldSources,
736
+ matchedModel: piMeta,
737
+ matchType,
738
+ };
739
+ }