pi-provider-litellm 2.2.1 → 3.0.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,947 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import { intersectThinkingLevelMaps, THINKING_LEVEL_DEFINITIONS } from "./thinking-levels.js";
3
+ import type { DiscoveredModel, ModelInfoEntry } from "./types.js";
4
+
5
+ export const DEFAULT_CONTEXT_WINDOW = 128_000;
6
+ export const DEFAULT_MAX_TOKENS = 16_384;
7
+
8
+ export type SemanticFamily = "claude" | "deepseek" | "gemini" | "kimi" | "openai";
9
+ export type SemanticModel = "deepseek-v4" | "kimi-k2.5-k2.6" | "kimi-k2.7-code" | "kimi-k3";
10
+
11
+ type FamilyEvidence = SemanticFamily | "conflicting";
12
+
13
+ export type MessagesBackendCompat = Pick<
14
+ NonNullable<Model<"anthropic-messages">["compat"]>,
15
+ "forceAdaptiveThinking" | "supportsTemperature" | "supportsStrictTools"
16
+ >;
17
+
18
+ type OpenAICompat = NonNullable<Model<"openai-completions">["compat"]>;
19
+
20
+ export interface ReasoningPolicy {
21
+ reasoning: boolean;
22
+ thinkingLevelMap?: DiscoveredModel["thinkingLevelMap"];
23
+ compat?: Pick<
24
+ OpenAICompat,
25
+ "requiresReasoningContentOnAssistantMessages" | "supportsReasoningEffort" | "thinkingFormat"
26
+ >;
27
+ }
28
+
29
+ export interface CatalogResolution {
30
+ provider?: string;
31
+ catalogModelId?: string;
32
+ semanticFamily?: FamilyEvidence;
33
+ semanticModel?: SemanticModel;
34
+ messagesCompat?: MessagesBackendCompat;
35
+ messagesThinkingLevelMap?: DiscoveredModel["thinkingLevelMap"];
36
+ reasoning?: boolean;
37
+ effortLevels?: string[];
38
+ thinkingLevelMap?: DiscoveredModel["thinkingLevelMap"];
39
+ vision?: boolean;
40
+ contextWindow?: number;
41
+ maxTokens?: number;
42
+ cost?: Partial<DiscoveredModel["cost"]>;
43
+ }
44
+
45
+ export type CatalogResolver = (entry: ModelInfoEntry) => CatalogResolution | undefined;
46
+
47
+ export interface ReducedModelGroup {
48
+ id: string;
49
+ api: "anthropic-messages" | "openai-completions" | "openai-responses";
50
+ reasoning: boolean;
51
+ acceptsResponsesReasoningControl: boolean;
52
+ thinkingLevelMap?: DiscoveredModel["thinkingLevelMap"];
53
+ vision: boolean;
54
+ contextWindow: number;
55
+ maxTokens: number;
56
+ cost: DiscoveredModel["cost"];
57
+ hasCompleteCost: boolean;
58
+ hasCompleteMetadata: boolean;
59
+ catalogProvider?: string;
60
+ semanticModel?: SemanticModel;
61
+ semanticFamily?: FamilyEvidence;
62
+ messagesCompat?: MessagesBackendCompat;
63
+ // Set when deployments disagreed on catalog provider identity, so catalog
64
+ // limits, pricing, and reasoning metadata were withheld for the whole group.
65
+ catalogAuthorityAmbiguous?: boolean;
66
+ // One entry per routable deployment. Undefined means that deployment supplied
67
+ // no usable family evidence; callers use this to gate outbound rewrites.
68
+ deploymentFamilies: (FamilyEvidence | undefined)[];
69
+ normalizeThinkTags: boolean;
70
+ suppressReasoningVisibility: boolean;
71
+ acceptedOpenAIParams: string[];
72
+ reasoningPolicy: ReasoningPolicy;
73
+ }
74
+
75
+ const RESPONSES_MODE_PATTERN = /^responses?$/i;
76
+ const CHAT_STYLE_MODE_PATTERN = /^chat$/i;
77
+ const COST_FIELDS = ["input", "output", "cacheRead", "cacheWrite"] as const;
78
+ type CostField = (typeof COST_FIELDS)[number];
79
+ type ModelCost = DiscoveredModel["cost"];
80
+ type ModelCostTier = NonNullable<ModelCost["tiers"]>[number];
81
+
82
+ // `/model/info` is parsed JSON from operator-authored proxy config, so a field
83
+ // declared as a string can arrive as a number. Reading it as one must withhold
84
+ // that row's evidence, not throw and lose every model in the response.
85
+ export function wireString(value: unknown): string | undefined {
86
+ return typeof value === "string" ? value : undefined;
87
+ }
88
+
89
+ // Unreadable capability flags are withheld rather than coerced. In particular,
90
+ // string values such as `"false"` must not become truthy capability evidence.
91
+ function wireBoolean(value: unknown): boolean | undefined {
92
+ return typeof value === "boolean" ? value : undefined;
93
+ }
94
+
95
+ export function isResponsesMode(mode: unknown): boolean {
96
+ const value = wireString(mode);
97
+ return value !== undefined && RESPONSES_MODE_PATTERN.test(value.trim());
98
+ }
99
+
100
+ export function normalizedMode(mode: unknown): "chat" | "responses" | "unknown" | "unsupported" {
101
+ if (mode == null) return "unknown";
102
+ const value = wireString(mode)?.trim();
103
+ // An unreadable mode remains in the conservative reduction instead of being
104
+ // filtered as unsupported and silently relaxing the remaining group.
105
+ if (value === undefined) return "unknown";
106
+ if (RESPONSES_MODE_PATTERN.test(value)) return "responses";
107
+ if (CHAT_STYLE_MODE_PATTERN.test(value)) return "chat";
108
+ return "unsupported";
109
+ }
110
+
111
+ // Canonicalization is depth-bounded because deployment metadata is untrusted.
112
+ const MAX_CANONICAL_DEPTH = 12;
113
+
114
+ function sortValue(value: unknown, depth = 0): unknown {
115
+ // Do not traverse the remaining untrusted subtree at the cap. Its contents
116
+ // are deliberately outside the bounded canonical identity.
117
+ if (depth >= MAX_CANONICAL_DEPTH) return "[depth-limit]";
118
+ if (Array.isArray(value)) return value.map((child) => sortValue(child, depth + 1));
119
+ if (typeof value !== "object" || value === null) return value;
120
+ return Object.fromEntries(
121
+ Object.entries(value)
122
+ .sort(([left], [right]) => left.localeCompare(right))
123
+ .map(([key, child]) => [key, sortValue(child, depth + 1)]),
124
+ );
125
+ }
126
+
127
+ // Compare catalog policies without depending on object key order.
128
+ export function stableJson(value: unknown): string | undefined {
129
+ return value === undefined ? undefined : JSON.stringify(sortValue(value));
130
+ }
131
+
132
+ function stableEntry(entry: ModelInfoEntry): string {
133
+ return JSON.stringify(sortValue(entry));
134
+ }
135
+
136
+ // Exact identified duplicates collapse, conflicting variants sharing an id stay
137
+ // plural, and anonymous rows stay distinct because no identity proves equality.
138
+ function uniqueDeployments(entries: readonly ModelInfoEntry[]): ModelInfoEntry[] {
139
+ const identified = new Map<string, Map<string, ModelInfoEntry>>();
140
+ const anonymous: Array<{ signature: string; entry: ModelInfoEntry }> = [];
141
+ for (const entry of entries) {
142
+ const signature = stableEntry(entry);
143
+ const id = wireString(entry.model_info?.id)?.trim();
144
+ if (id) {
145
+ const variants = identified.get(id) ?? new Map<string, ModelInfoEntry>();
146
+ variants.set(signature, entry);
147
+ identified.set(id, variants);
148
+ } else {
149
+ anonymous.push({ signature, entry });
150
+ }
151
+ }
152
+ // Conflicting rows for one deployment remain in the reduction so their
153
+ // disagreement fails closed, while exact repeats stay idempotent.
154
+ return [
155
+ ...[...identified.entries()]
156
+ .sort(([left], [right]) => left.localeCompare(right))
157
+ .flatMap(([, variants]) =>
158
+ [...variants.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([, entry]) => entry),
159
+ ),
160
+ ...anonymous.sort((left, right) => left.signature.localeCompare(right.signature)).map(({ entry }) => entry),
161
+ ];
162
+ }
163
+
164
+ // A router limit is usable only when it is a finite positive token count.
165
+ // Without this, one deployment reporting 0 would clamp the whole group to 0.
166
+ function explicitLimit(value: number | undefined): number | undefined {
167
+ return value === undefined || !Number.isFinite(value) || value <= 0 ? undefined : value;
168
+ }
169
+
170
+ const EXTENDED_LEVELS = THINKING_LEVEL_DEFINITIONS.map(([level]) => level);
171
+
172
+ const LITELLM_LEVEL_FLAGS = {
173
+ off: "supports_none_reasoning_effort",
174
+ minimal: "supports_minimal_reasoning_effort",
175
+ low: "supports_low_reasoning_effort",
176
+ xhigh: "supports_xhigh_reasoning_effort",
177
+ max: "supports_max_reasoning_effort",
178
+ } as const;
179
+
180
+ function normalizeEffort(level: string): (typeof EXTENDED_LEVELS)[number] | undefined {
181
+ const normalized = level === "none" ? "off" : level;
182
+ return EXTENDED_LEVELS.find((candidate) => candidate === normalized);
183
+ }
184
+
185
+ // A public effort list is complete: listed standard levels are enabled and omitted
186
+ // ones are denied. Without a public list, standard levels stay absent so Pi keeps
187
+ // its defaults. LiteLLM flags may add or remove levels, while xhigh/max always
188
+ // require an explicit LiteLLM opt-in.
189
+ function reasoningLevelMap(
190
+ entries: readonly ModelInfoEntry[],
191
+ catalogEfforts: readonly (readonly string[] | undefined)[],
192
+ catalogMaps: readonly DiscoveredModel["thinkingLevelMap"][],
193
+ ): DiscoveredModel["thinkingLevelMap"] {
194
+ const publicSets = catalogEfforts
195
+ .filter((levels): levels is readonly string[] => levels !== undefined)
196
+ .map((levels) => new Set(levels.map(normalizeEffort).filter((level) => level !== undefined)))
197
+ .filter((levels) => levels.size > 0);
198
+ const map: NonNullable<DiscoveredModel["thinkingLevelMap"]> = {};
199
+ if (publicSets.length > 0) {
200
+ for (const level of ["off", "minimal", "low", "medium", "high"] as const) {
201
+ map[level] = publicSets.every((set) => set.has(level)) ? (level === "off" ? "none" : level) : null;
202
+ }
203
+ }
204
+ // A catalog level map is direct evidence even when its selectable projection
205
+ // is empty. Preserve its denials independently from public effort lists, whose
206
+ // unrecognized values intentionally have no opinion.
207
+ for (const catalogMap of catalogMaps) Object.assign(map, deniedLevels(catalogMap));
208
+
209
+ for (const [level, flag] of Object.entries(LITELLM_LEVEL_FLAGS) as Array<
210
+ [keyof typeof LITELLM_LEVEL_FLAGS, (typeof LITELLM_LEVEL_FLAGS)[keyof typeof LITELLM_LEVEL_FLAGS]]
211
+ >) {
212
+ const reported = entries.map((entry) => wireBoolean(entry.model_info?.[flag]));
213
+ if (reported.some((value) => value === false)) map[level] = null;
214
+ else if (reported.length > 0 && reported.every((value) => value === true)) {
215
+ map[level] = level === "off" ? "none" : level;
216
+ }
217
+ }
218
+ for (const level of ["xhigh", "max"] as const) {
219
+ const flag = LITELLM_LEVEL_FLAGS[level];
220
+ if (!entries.every((entry) => wireBoolean(entry.model_info?.[flag]) === true)) map[level] = null;
221
+ }
222
+ return map;
223
+ }
224
+
225
+ function deniedLevels(map: DiscoveredModel["thinkingLevelMap"]): Partial<Record<string, null>> {
226
+ return Object.fromEntries(
227
+ Object.entries(map ?? {})
228
+ .filter(([, level]) => level === null)
229
+ .map(([level]) => [level, null]),
230
+ );
231
+ }
232
+
233
+ function normalizeParams(params: unknown): Set<string> {
234
+ if (!Array.isArray(params)) return new Set();
235
+ const named = params.map((param) => wireString(param)?.trim()).filter((param) => Boolean(param));
236
+ return new Set(named as string[]);
237
+ }
238
+
239
+ function acceptedParams(entry: ModelInfoEntry): Set<string> {
240
+ const params = normalizeParams(entry.model_info?.supported_openai_params);
241
+ for (const param of normalizeParams(entry.litellm_params?.allowed_openai_params)) params.add(param);
242
+ return params;
243
+ }
244
+
245
+ function intersectParams(entries: readonly ModelInfoEntry[]): string[] {
246
+ const [first, ...rest] = entries.map(acceptedParams);
247
+ if (!first) return [];
248
+ return [...first].filter((param) => rest.every((params) => params.has(param))).sort();
249
+ }
250
+
251
+ const ONLY_HIGH = {
252
+ off: null,
253
+ minimal: null,
254
+ low: null,
255
+ medium: null,
256
+ high: "high",
257
+ xhigh: null,
258
+ max: null,
259
+ } as const;
260
+
261
+ // pi-ai reads an ABSENT thinkingLevelMap as "every standard level supported"
262
+ // (getSupportedThinkingLevels), so a policy that cannot transmit any level has
263
+ // to deny each one explicitly instead of omitting the map.
264
+ export const NO_TRANSMISSIBLE_LEVELS = {
265
+ off: null,
266
+ minimal: null,
267
+ low: null,
268
+ medium: null,
269
+ high: null,
270
+ xhigh: null,
271
+ max: null,
272
+ } as const;
273
+
274
+ // Efforts the Responses API accepts. pi-ai passes an unmapped level through
275
+ // verbatim and reads `thinkingLevelMap.off` as the disable value, so a Chat-shaped
276
+ // map would emit `off` or `max` as an effort. `none` is the disable spelling —
277
+ // pi-ai's own `openai/gpt-5.5` entry maps `off` to it.
278
+ const RESPONSES_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh"]);
279
+
280
+ // A level map is only meaningful next to the compat that serializes it, and the
281
+ // two used to travel separately: five call sites each decided whether to copy
282
+ // one, the other, or both. `SerializerPolicy` closes them into one value so a
283
+ // consumer cannot take a level map without the conclusion that carries it.
284
+ export interface SerializerPolicy {
285
+ reasoning: boolean;
286
+ thinkingLevelMap?: DiscoveredModel["thinkingLevelMap"];
287
+ compat: DiscoveredModel["compat"];
288
+ }
289
+
290
+ // Chat carries a level through `reasoning_effort` or an explicit `thinkingFormat`.
291
+ // pi-ai would default effort on for a litellm provider, but a closed policy must
292
+ // state its own carrier rather than inherit one, so this reports only explicit
293
+ // evidence and the caller sets the carrier it concluded.
294
+ function chatCarrier(compat: OpenAICompat | undefined): boolean {
295
+ return compat?.thinkingFormat !== undefined || compat?.supportsReasoningEffort === true;
296
+ }
297
+
298
+ function deniesChatEffort(compat: OpenAICompat | undefined): boolean {
299
+ return compat?.supportsReasoningEffort === false && compat?.thinkingFormat === undefined;
300
+ }
301
+
302
+ // Restrictions that only remove unsupported request fields are safe to apply as
303
+ // soon as one deployment requires them. Shape-changing fields need every
304
+ // deployment to agree because an unlabeled sibling may reject the alternate wire
305
+ // form (for example, `max_tokens` instead of `max_completion_tokens`).
306
+ const COMPAT_APPLY_IF_ANY = [
307
+ "supportsStore",
308
+ "supportsDeveloperRole",
309
+ "supportsReasoningEffort",
310
+ "supportsStrictMode",
311
+ ] as const;
312
+ const COMPAT_REQUIRE_UNANIMITY = [
313
+ "maxTokensField",
314
+ "cacheControlFormat",
315
+ "thinkingFormat",
316
+ "requiresReasoningContentOnAssistantMessages",
317
+ ] as const;
318
+
319
+ export function meetVendorCompat(
320
+ perDeployment: readonly (DiscoveredModel["compat"] | undefined)[],
321
+ ): DiscoveredModel["compat"] {
322
+ const candidates = perDeployment.map((compat) => compat as OpenAICompat | undefined);
323
+ if (candidates.length === 0) return undefined;
324
+ const met: Record<string, unknown> = {};
325
+ for (const field of COMPAT_APPLY_IF_ANY) {
326
+ const stated = candidates.map((compat) => compat?.[field]).filter((value) => value !== undefined);
327
+ if (stated.length > 0 && new Set(stated).size === 1) met[field] = stated[0];
328
+ }
329
+ for (const field of COMPAT_REQUIRE_UNANIMITY) {
330
+ const stated = candidates.map((compat) => compat?.[field]);
331
+ if (stated.every((value) => value !== undefined) && new Set(stated).size === 1) met[field] = stated[0];
332
+ }
333
+ return (Object.keys(met).length > 0 ? met : undefined) as DiscoveredModel["compat"];
334
+ }
335
+
336
+ // Translates a Chat-shaped map into Responses efforts, denying any level with no
337
+ // valid Responses value instead of letting it through verbatim. pi-ai treats an
338
+ // absent xhigh/max entry as unsupported (unlike the standard levels), so the
339
+ // translation must preserve that distinction rather than widening the map.
340
+ export function toResponsesLevels(
341
+ levels: DiscoveredModel["thinkingLevelMap"] | undefined,
342
+ ): NonNullable<DiscoveredModel["thinkingLevelMap"]> {
343
+ const translated: Record<string, string | null> = {};
344
+ for (const level of EXTENDED_LEVELS) {
345
+ const chat = levels?.[level];
346
+ if (level === "off") {
347
+ // Disable is `none` on this API, never `off`.
348
+ translated.off = chat === null ? null : "none";
349
+ continue;
350
+ }
351
+ if (chat === undefined && (level === "xhigh" || level === "max")) {
352
+ translated[level] = null;
353
+ continue;
354
+ }
355
+ // pi-ai passes an absent standard level through as its own name.
356
+ const candidate = chat === undefined ? level : chat;
357
+ translated[level] = candidate !== null && RESPONSES_EFFORTS.has(candidate) ? candidate : null;
358
+ }
359
+ return translated as NonNullable<DiscoveredModel["thinkingLevelMap"]>;
360
+ }
361
+
362
+ // The one place a level map is paired with a serializer conclusion. Every
363
+ // discovery, cache, health and singleton path consumes the whole returned object.
364
+ export function closeSerializerPolicy(input: {
365
+ api: "anthropic-messages" | "openai-completions" | "openai-responses";
366
+ reasoning: boolean;
367
+ vendorCompat: DiscoveredModel["compat"];
368
+ semanticCompat?: ReasoningPolicy["compat"];
369
+ semanticLevels?: DiscoveredModel["thinkingLevelMap"];
370
+ catalogLevels?: DiscoveredModel["thinkingLevelMap"];
371
+ requireChatCarrier?: boolean;
372
+ allowInferredChatCarrier?: boolean;
373
+ acceptsResponsesReasoningControl?: boolean;
374
+ // For callers with no level evidence at all. Distinct from "no candidate map":
375
+ // this states the no-level conclusion explicitly, so a caller spreading the
376
+ // policy over an earlier model cannot leave a stale map behind.
377
+ denyLevels?: boolean;
378
+ }): SerializerPolicy {
379
+ const {
380
+ api,
381
+ reasoning,
382
+ vendorCompat,
383
+ semanticCompat,
384
+ semanticLevels,
385
+ catalogLevels,
386
+ requireChatCarrier,
387
+ allowInferredChatCarrier = true,
388
+ acceptsResponsesReasoningControl,
389
+ denyLevels,
390
+ } = input;
391
+ if (api === "anthropic-messages") {
392
+ const messagesCompat = vendorCompat as Model<"anthropic-messages">["compat"];
393
+ const fields = ["forceAdaptiveThinking", "supportsTemperature", "supportsStrictTools"] as const;
394
+ const entries = fields
395
+ .filter((field) => messagesCompat?.[field] !== undefined)
396
+ .map((field) => [field, messagesCompat?.[field]]);
397
+ return {
398
+ reasoning,
399
+ compat: entries.length > 0 ? Object.fromEntries(entries) : undefined,
400
+ ...(reasoning && (denyLevels || catalogLevels)
401
+ ? { thinkingLevelMap: denyLevels ? NO_TRANSMISSIBLE_LEVELS : catalogLevels }
402
+ : {}),
403
+ };
404
+ }
405
+ if (api === "openai-responses") {
406
+ // Responses always serializes a selected level as `reasoning.effort`.
407
+ // Chat-only evidence such as `thinking` cannot authorize that carrier.
408
+ const responsesCompat = vendorCompat as Model<"openai-responses">["compat"];
409
+ const allowedFields = [
410
+ "supportsDeveloperRole",
411
+ "sessionAffinityFormat",
412
+ "supportsLongCacheRetention",
413
+ "supportsStrictMode",
414
+ "supportsOpenAIGrammarTools",
415
+ "supportsAdditionalTools",
416
+ "supportsToolSearch",
417
+ "supportsExplicitPromptCacheMode",
418
+ ] as const satisfies readonly (keyof NonNullable<typeof responsesCompat>)[];
419
+ const entries = allowedFields
420
+ .filter((field) => responsesCompat?.[field] !== undefined)
421
+ .map((field) => [field, responsesCompat?.[field]]);
422
+ const compat = entries.length > 0 ? Object.fromEntries(entries) : undefined;
423
+ if (!reasoning) return { reasoning, compat };
424
+ if (denyLevels) {
425
+ return { reasoning, thinkingLevelMap: NO_TRANSMISSIBLE_LEVELS, compat };
426
+ }
427
+ if (!acceptsResponsesReasoningControl) {
428
+ return { reasoning, thinkingLevelMap: NO_TRANSMISSIBLE_LEVELS, compat };
429
+ }
430
+ return { reasoning, thinkingLevelMap: toResponsesLevels(semanticLevels ?? catalogLevels), compat };
431
+ }
432
+ // A model with no compat at all keeps none: fabricating an empty object here
433
+ // would rewrite every cached model that passes through.
434
+ const stated = vendorCompat !== undefined || semanticCompat !== undefined;
435
+ const merged = { ...(vendorCompat as OpenAICompat), ...semanticCompat } as OpenAICompat;
436
+ const compat = (stated ? merged : undefined) as DiscoveredModel["compat"];
437
+ const deniedCompat = { ...merged, supportsReasoningEffort: false } as DiscoveredModel["compat"];
438
+ if (!reasoning) return { reasoning, compat: denyLevels ? deniedCompat : compat };
439
+ if (denyLevels) {
440
+ return { reasoning, thinkingLevelMap: NO_TRANSMISSIBLE_LEVELS, compat: deniedCompat };
441
+ }
442
+ if (deniesChatEffort(merged)) {
443
+ // The vendor denies effort and named no format: nothing can carry a level.
444
+ return { reasoning, thinkingLevelMap: NO_TRANSMISSIBLE_LEVELS, compat };
445
+ }
446
+ const candidateLevels = semanticLevels ?? catalogLevels;
447
+ if (candidateLevels === undefined) {
448
+ // An absent map enables pi-ai's standard levels, so freshly discovered
449
+ // routes without an explicit carrier must be represented as a denial.
450
+ return requireChatCarrier
451
+ ? { reasoning, thinkingLevelMap: NO_TRANSMISSIBLE_LEVELS, compat }
452
+ : { reasoning, compat };
453
+ }
454
+ if (chatCarrier(merged)) return { reasoning, thinkingLevelMap: candidateLevels, compat };
455
+ if (!allowInferredChatCarrier) {
456
+ return { reasoning, thinkingLevelMap: NO_TRANSMISSIBLE_LEVELS, compat: deniedCompat };
457
+ }
458
+ // Callers that explicitly permit inference may treat their own direct router
459
+ // evidence as a carrier. Route, catalog, and legacy cache evidence must opt out.
460
+ return {
461
+ reasoning,
462
+ thinkingLevelMap: candidateLevels,
463
+ compat: { ...merged, supportsReasoningEffort: true } as DiscoveredModel["compat"],
464
+ };
465
+ }
466
+
467
+ function failClosedReasoning(
468
+ reasoning: boolean,
469
+ replay?: Pick<OpenAICompat, "requiresReasoningContentOnAssistantMessages">,
470
+ ): ReasoningPolicy {
471
+ return {
472
+ reasoning,
473
+ ...(reasoning ? { thinkingLevelMap: NO_TRANSMISSIBLE_LEVELS } : {}),
474
+ compat: { ...replay, supportsReasoningEffort: false },
475
+ };
476
+ }
477
+
478
+ function buildReasoningPolicy(
479
+ semanticModel: SemanticModel | undefined,
480
+ acceptedOpenAIParams: readonly string[],
481
+ reducedReasoning: boolean,
482
+ explicitlyUnsupported: boolean,
483
+ ): ReasoningPolicy {
484
+ const acceptsThinking = acceptedOpenAIParams.includes("thinking");
485
+ const acceptsEffort = acceptedOpenAIParams.includes("reasoning_effort");
486
+ const hasAcceptedControl = acceptsThinking || acceptsEffort;
487
+ const reasoning =
488
+ !explicitlyUnsupported && (reducedReasoning || hasAcceptedControl || semanticModel === "kimi-k2.7-code");
489
+ if (semanticModel === "kimi-k2.5-k2.6") {
490
+ // Binary thinking rides the `thinking` param, so without that accepted
491
+ // param there is no wire mechanism and every level must be denied.
492
+ if (acceptsThinking && reasoning) {
493
+ return {
494
+ reasoning,
495
+ thinkingLevelMap: { ...ONLY_HIGH, off: "off" },
496
+ compat: { thinkingFormat: "deepseek", supportsReasoningEffort: false },
497
+ };
498
+ }
499
+ return failClosedReasoning(reasoning);
500
+ }
501
+ if (semanticModel === "kimi-k2.7-code") {
502
+ // K2.7 Code always reasons and cannot be switched off, so `off` stays
503
+ // denied rather than inventing a disable control. The `high` level only
504
+ // exists when a deployment accepts `thinking` to carry it.
505
+ const replay = { requiresReasoningContentOnAssistantMessages: true } as const;
506
+ if (acceptsThinking && reasoning) {
507
+ return {
508
+ reasoning,
509
+ thinkingLevelMap: ONLY_HIGH,
510
+ compat: { ...replay, thinkingFormat: "deepseek", supportsReasoningEffort: false },
511
+ };
512
+ }
513
+ return failClosedReasoning(reasoning, replay);
514
+ }
515
+ if (semanticModel === "kimi-k3") {
516
+ const replay = { requiresReasoningContentOnAssistantMessages: true } as const;
517
+ if (acceptsEffort && reasoning) {
518
+ return {
519
+ reasoning,
520
+ thinkingLevelMap: {
521
+ off: null,
522
+ minimal: null,
523
+ low: "low",
524
+ medium: null,
525
+ high: "high",
526
+ xhigh: null,
527
+ max: "max",
528
+ },
529
+ compat: { ...replay, thinkingFormat: "openai", supportsReasoningEffort: true },
530
+ };
531
+ }
532
+ return failClosedReasoning(reasoning, replay);
533
+ }
534
+ if (semanticModel === "deepseek-v4") {
535
+ const replay = { requiresReasoningContentOnAssistantMessages: true } as const;
536
+ if (acceptsThinking && acceptsEffort && reasoning) {
537
+ return {
538
+ reasoning,
539
+ thinkingLevelMap: {
540
+ off: "off",
541
+ minimal: null,
542
+ low: null,
543
+ medium: null,
544
+ high: "high",
545
+ xhigh: null,
546
+ max: "max",
547
+ },
548
+ compat: { ...replay, thinkingFormat: "deepseek", supportsReasoningEffort: true },
549
+ };
550
+ }
551
+ if (acceptsEffort && reasoning) {
552
+ return {
553
+ reasoning,
554
+ thinkingLevelMap: {
555
+ off: null,
556
+ minimal: null,
557
+ low: null,
558
+ medium: null,
559
+ high: "high",
560
+ xhigh: null,
561
+ max: "max",
562
+ },
563
+ compat: { ...replay, thinkingFormat: "openai", supportsReasoningEffort: true },
564
+ };
565
+ }
566
+ if (acceptsThinking && reasoning) {
567
+ return {
568
+ reasoning,
569
+ thinkingLevelMap: { ...ONLY_HIGH, off: "off" },
570
+ compat: { ...replay, thinkingFormat: "deepseek", supportsReasoningEffort: false },
571
+ };
572
+ }
573
+ return failClosedReasoning(reasoning, replay);
574
+ }
575
+ // No identified semantic model means no known reasoning contract; the caller
576
+ // ignores this policy entirely and keeps the reduced/catalog metadata.
577
+ return { reasoning: false };
578
+ }
579
+
580
+ function explicitCost(entry: ModelInfoEntry, field: CostField): number | undefined {
581
+ const info = entry.model_info;
582
+ if (!info) return undefined;
583
+ const perToken =
584
+ field === "input"
585
+ ? info.input_cost_per_token
586
+ : field === "output"
587
+ ? info.output_cost_per_token
588
+ : field === "cacheRead"
589
+ ? info.cache_read_input_token_cost
590
+ : info.cache_creation_input_token_cost;
591
+ return perToken === undefined || !Number.isFinite(perToken) || perToken < 0 ? undefined : perToken * 1_000_000;
592
+ }
593
+
594
+ function resolvedCost(
595
+ entry: ModelInfoEntry,
596
+ catalog: CatalogResolution | undefined,
597
+ field: CostField,
598
+ ): number | undefined {
599
+ return explicitCost(entry, field) ?? catalog?.cost?.[field];
600
+ }
601
+
602
+ function min(values: readonly number[]): number {
603
+ return Math.min(...values);
604
+ }
605
+
606
+ function conservativeLimit(explicit: number | undefined, catalog: number | undefined): number | undefined {
607
+ const valid = [explicitLimit(explicit), explicitLimit(catalog)].filter(
608
+ (value): value is number => value !== undefined,
609
+ );
610
+ return valid.length > 0 ? min(valid) : undefined;
611
+ }
612
+
613
+ function unanimous<T>(values: readonly (T | undefined)[]): T | undefined {
614
+ const first = values[0];
615
+ return first !== undefined && values.every((value) => value === first) ? first : undefined;
616
+ }
617
+
618
+ function ratesAboveThreshold(cost: ModelCost, threshold: number): ModelCost {
619
+ let matchedThreshold = -1;
620
+ let matches: ModelCostTier[] = [];
621
+ for (const tier of cost.tiers ?? []) {
622
+ if (tier.inputTokensAbove < 0 || tier.inputTokensAbove > threshold) continue;
623
+ if (tier.inputTokensAbove > matchedThreshold) {
624
+ matchedThreshold = tier.inputTokensAbove;
625
+ matches = [tier];
626
+ } else if (tier.inputTokensAbove === matchedThreshold) {
627
+ // Pi uses the first duplicate threshold. Taking the per-field maximum is
628
+ // conservative if malformed catalog data supplies conflicting duplicates.
629
+ matches.push(tier);
630
+ }
631
+ }
632
+ if (matches.length === 0) return cost;
633
+ return {
634
+ input: Math.max(cost.input, ...matches.map((tier) => tier.input)),
635
+ output: Math.max(cost.output, ...matches.map((tier) => tier.output)),
636
+ cacheRead: Math.max(cost.cacheRead, ...matches.map((tier) => tier.cacheRead)),
637
+ cacheWrite: Math.max(cost.cacheWrite, ...matches.map((tier) => tier.cacheWrite)),
638
+ };
639
+ }
640
+
641
+ // Builds the per-field upper envelope of complete request-wide price ladders.
642
+ // Every source threshold is retained because crossing it can change which
643
+ // deployment is most expensive, even when the ladders use different breakpoints.
644
+ export function conservativeCostTiers(costs: readonly ModelCost[]): ModelCost["tiers"] {
645
+ const thresholds = [
646
+ ...new Set(
647
+ costs.flatMap((cost) =>
648
+ (cost.tiers ?? [])
649
+ .map((tier) => tier.inputTokensAbove)
650
+ .filter((threshold) => Number.isFinite(threshold) && threshold >= 0),
651
+ ),
652
+ ),
653
+ ].sort((left, right) => left - right);
654
+ if (thresholds.length === 0) return undefined;
655
+
656
+ return thresholds.map((inputTokensAbove) => {
657
+ const rates = costs.map((cost) => ratesAboveThreshold(cost, inputTokensAbove));
658
+ return {
659
+ inputTokensAbove,
660
+ input: Math.max(...rates.map((rate) => rate.input)),
661
+ output: Math.max(...rates.map((rate) => rate.output)),
662
+ cacheRead: Math.max(...rates.map((rate) => rate.cacheRead)),
663
+ cacheWrite: Math.max(...rates.map((rate) => rate.cacheWrite)),
664
+ };
665
+ });
666
+ }
667
+
668
+ interface CanonicalModelInfoDeployments {
669
+ candidates: ModelInfoEntry[];
670
+ candidateModes: ReturnType<typeof normalizedMode>[];
671
+ routable: ModelInfoEntry[];
672
+ }
673
+
674
+ function canonicalModelInfoDeployments(entries: readonly ModelInfoEntry[]): CanonicalModelInfoDeployments {
675
+ // A group is addressed by its public route name, so a row without a readable one
676
+ // cannot participate. Enforce that here so every authority consumer observes the
677
+ // same filtered and deduplicated set.
678
+ const candidates = uniqueDeployments(entries.filter((entry) => wireString(entry.model_name)));
679
+ const candidateModes = candidates.map((entry) => normalizedMode(entry.model_info?.mode));
680
+ return {
681
+ candidates,
682
+ candidateModes,
683
+ routable: candidates.filter((_, index) => candidateModes[index] !== "unsupported"),
684
+ };
685
+ }
686
+
687
+ export function hasMixedIncompatibleDeploymentModes(entries: readonly ModelInfoEntry[]): boolean {
688
+ const { candidateModes } = canonicalModelInfoDeployments(entries);
689
+ return candidateModes.includes("unsupported") && candidateModes.some((mode) => mode !== "unsupported");
690
+ }
691
+
692
+ const KIMI_FAMILY_PATTERN = /(?:^|[./_-])(?:moonshotai|moonshot|kimi)(?:$|[./_:-])/i;
693
+ const FORCED_THINKING_PATTERN = /(?:^|[./_-])thinking(?:$|[./_:-])/i;
694
+
695
+ function kimiDeploymentEvidence(entry: ModelInfoEntry): {
696
+ identified: boolean;
697
+ forcedThinking: boolean;
698
+ moonshotTransport: boolean;
699
+ } {
700
+ const identities = [
701
+ entry.litellm_params?.model,
702
+ entry.litellm_params?.custom_llm_provider,
703
+ entry.model_info?.base_model,
704
+ entry.model_info?.litellm_provider,
705
+ ]
706
+ .map((candidate) => wireString(candidate)?.trim())
707
+ .filter((candidate): candidate is string => Boolean(candidate));
708
+ const kimi = identities.filter((identity) => KIMI_FAMILY_PATTERN.test(identity));
709
+ const routingModel = wireString(entry.litellm_params?.model)?.trim();
710
+ const routingProviders = [
711
+ wireString(entry.litellm_params?.custom_llm_provider)?.trim(),
712
+ routingModel?.includes("/") ? routingModel.split("/", 1)[0] : undefined,
713
+ ]
714
+ .filter((provider): provider is string => Boolean(provider))
715
+ .map((provider) => provider.toLowerCase());
716
+ return {
717
+ identified: kimi.length > 0,
718
+ forcedThinking: kimi.some((identity) => FORCED_THINKING_PATTERN.test(identity)),
719
+ // Visibility parameters are accepted by Moonshot's API, not by every host
720
+ // that serves a Kimi model. Both declared routing signals must name Moonshot.
721
+ moonshotTransport:
722
+ routingProviders.length > 0 &&
723
+ routingProviders.every((provider) => provider === "moonshot" || provider === "moonshotai"),
724
+ };
725
+ }
726
+
727
+ export function reduceModelGroup(
728
+ entries: readonly ModelInfoEntry[],
729
+ resolveCatalog: CatalogResolver,
730
+ ): ReducedModelGroup | undefined {
731
+ const { candidates, candidateModes, routable: deployments } = canonicalModelInfoDeployments(entries);
732
+ if (candidates.length === 0) return undefined;
733
+ // Every deployment behind a public route must accept a chat-style request.
734
+ // Dropping an explicitly incompatible sibling would publish a route that can
735
+ // still select an embedding or other non-chat deployment.
736
+ if (candidateModes.includes("unsupported")) return undefined;
737
+ const catalogs = deployments.map((entry) => resolveCatalog(entry));
738
+ const catalogProvider = unanimous(catalogs.map((catalog) => catalog?.provider));
739
+ const catalogModelIds = catalogs.map((catalog) => catalog?.catalogModelId);
740
+ const hasCatalogModelIdentity = catalogModelIds.some((id) => id !== undefined);
741
+ const catalogModelId = unanimous(catalogModelIds);
742
+ // Provider-only test resolvers preserve the reducer's public unit-test contract;
743
+ // production catalog resolutions always include the concrete model identity.
744
+ const hasCatalogAuthority =
745
+ catalogProvider !== undefined && (!hasCatalogModelIdentity || catalogModelId !== undefined);
746
+ const catalogAuthority = hasCatalogAuthority ? catalogs : catalogs.map(() => undefined);
747
+ const catalogAuthorityAmbiguous =
748
+ !hasCatalogAuthority &&
749
+ catalogs.some((catalog) => catalog?.provider !== undefined || catalog?.catalogModelId !== undefined);
750
+ const semanticModel = unanimous(catalogs.map((catalog) => catalog?.semanticModel));
751
+ const semanticFamily = unanimous(catalogs.map((catalog) => catalog?.semanticFamily));
752
+ const messagesCompat = unanimous(catalogs.map((catalog) => stableJson(catalog?.messagesCompat)));
753
+ const messagesEndpointAllowed = deployments.every((entry) => {
754
+ const endpoints = entry.model_info?.supported_endpoints;
755
+ return endpoints === undefined || (Array.isArray(endpoints) && endpoints.includes("/v1/messages"));
756
+ });
757
+ const api = candidateModes.every((mode) => mode === "responses")
758
+ ? "openai-responses"
759
+ : candidateModes.every((mode) => mode === "chat") &&
760
+ messagesEndpointAllowed &&
761
+ semanticFamily === "claude" &&
762
+ messagesCompat
763
+ ? "anthropic-messages"
764
+ : "openai-completions";
765
+ const reasoningEvidence = deployments.map(
766
+ (entry, index) => wireBoolean(entry.model_info?.supports_reasoning) ?? catalogAuthority[index]?.reasoning,
767
+ );
768
+ const visionEvidence = deployments.map(
769
+ (entry, index) => wireBoolean(entry.model_info?.supports_vision) ?? catalogAuthority[index]?.vision,
770
+ );
771
+ const contextWindowEvidence = deployments.map((entry, index) =>
772
+ conservativeLimit(entry.model_info?.max_input_tokens, catalogAuthority[index]?.contextWindow),
773
+ );
774
+ const maxTokensEvidence = deployments.map((entry, index) =>
775
+ conservativeLimit(entry.model_info?.max_output_tokens, catalogAuthority[index]?.maxTokens),
776
+ );
777
+ const reasoning = reasoningEvidence.every((value) => value ?? false);
778
+ const explicitlyUnsupported = deployments.some(
779
+ (entry) => wireBoolean(entry.model_info?.supports_reasoning) === false,
780
+ );
781
+ const vision = visionEvidence.every((value) => value ?? false);
782
+ const contextWindow = min(contextWindowEvidence.map((value) => value ?? DEFAULT_CONTEXT_WINDOW));
783
+ const maxTokens = min(maxTokensEvidence.map((value) => value ?? DEFAULT_MAX_TOKENS));
784
+
785
+ const costValues = COST_FIELDS.map((field) =>
786
+ deployments.map((entry, index) => resolvedCost(entry, catalogAuthority[index], field)),
787
+ );
788
+ const completeCostFields = costValues.map((values) => values.every((value) => value !== undefined));
789
+ const hasCompleteCost = completeCostFields.every(Boolean);
790
+ // A defaulted capability or limit is as much a gap as a missing price: the
791
+ // incomplete marker covers everything the reducer had to assume.
792
+ const hasCompleteMetadata =
793
+ hasCompleteCost &&
794
+ reasoningEvidence.every((value) => value !== undefined) &&
795
+ visionEvidence.every((value) => value !== undefined) &&
796
+ contextWindowEvidence.every((value) => value !== undefined) &&
797
+ maxTokensEvidence.every((value) => value !== undefined);
798
+ const cost: DiscoveredModel["cost"] = {
799
+ input: completeCostFields[0] ? Math.max(...(costValues[0] as number[])) : 0,
800
+ output: completeCostFields[1] ? Math.max(...(costValues[1] as number[])) : 0,
801
+ cacheRead: completeCostFields[2] ? Math.max(...(costValues[2] as number[])) : 0,
802
+ cacheWrite: completeCostFields[3] ? Math.max(...(costValues[3] as number[])) : 0,
803
+ };
804
+ if (hasCompleteCost && hasCatalogAuthority) {
805
+ const deploymentCosts = deployments.map((entry, index) => {
806
+ const baseCost = {
807
+ input: costValues[0][index] as number,
808
+ output: costValues[1][index] as number,
809
+ cacheRead: costValues[2][index] as number,
810
+ cacheWrite: costValues[3][index] as number,
811
+ };
812
+ const explicitFields = new Set(COST_FIELDS.filter((field) => explicitCost(entry, field) !== undefined));
813
+ const catalogTiers = catalogAuthority[index]?.cost?.tiers;
814
+ // An explicit field replaces catalog pricing for that field at every
815
+ // threshold. Unaffected fields retain their catalog ladder; otherwise a
816
+ // partial router override could hide a known higher catalog rate.
817
+ const tiers =
818
+ catalogTiers && explicitFields.size < COST_FIELDS.length
819
+ ? catalogTiers.map((tier) => ({
820
+ inputTokensAbove: tier.inputTokensAbove,
821
+ input: explicitFields.has("input") ? baseCost.input : tier.input,
822
+ output: explicitFields.has("output") ? baseCost.output : tier.output,
823
+ cacheRead: explicitFields.has("cacheRead") ? baseCost.cacheRead : tier.cacheRead,
824
+ cacheWrite: explicitFields.has("cacheWrite") ? baseCost.cacheWrite : tier.cacheWrite,
825
+ }))
826
+ : undefined;
827
+ return { ...baseCost, ...(tiers ? { tiers } : {}) };
828
+ });
829
+ const tiers = conservativeCostTiers(deploymentCosts);
830
+ if (tiers) cost.tiers = tiers;
831
+ }
832
+ const acceptedOpenAIParams = intersectParams(deployments);
833
+ const acceptsResponsesReasoningControl = acceptedOpenAIParams.includes("reasoning_effort");
834
+ const publicEfforts = catalogAuthority.map(
835
+ (catalog) =>
836
+ catalog?.effortLevels ??
837
+ (catalog?.thinkingLevelMap
838
+ ? Object.entries(catalog.thinkingLevelMap)
839
+ .filter(([, value]) => value !== null)
840
+ .map(([level]) => level)
841
+ : undefined),
842
+ );
843
+ const evidenceLevelMap = reasoningLevelMap(deployments, publicEfforts, [
844
+ intersectThinkingLevelMaps(catalogAuthority.map((catalog) => catalog?.thinkingLevelMap)),
845
+ ]);
846
+ let thinkingLevelMap = reasoning && acceptsResponsesReasoningControl ? evidenceLevelMap : undefined;
847
+ if (api === "anthropic-messages") {
848
+ thinkingLevelMap = intersectThinkingLevelMaps(catalogs.map((catalog) => catalog?.messagesThinkingLevelMap));
849
+ // Native serializer restrictions apply even when catalog pricing is withheld.
850
+ // Router flags can also deny Pi's implicit default levels, never add a level.
851
+ for (const [level, flag] of Object.entries(LITELLM_LEVEL_FLAGS) as Array<
852
+ [keyof typeof LITELLM_LEVEL_FLAGS, (typeof LITELLM_LEVEL_FLAGS)[keyof typeof LITELLM_LEVEL_FLAGS]]
853
+ >) {
854
+ const reported = deployments.map((entry) => wireBoolean(entry.model_info?.[flag]));
855
+ if (
856
+ reported.some((value) => value === false) ||
857
+ ((level === "xhigh" || level === "max") &&
858
+ reported.some((value) => value !== undefined) &&
859
+ !reported.every((value) => value === true))
860
+ ) {
861
+ thinkingLevelMap ??= {};
862
+ thinkingLevelMap[level] = null;
863
+ }
864
+ }
865
+ }
866
+ const kimiEvidence = deployments.map((entry) => kimiDeploymentEvidence(entry));
867
+ const unanimousNormalKimi = kimiEvidence.every((evidence) => evidence.identified && !evidence.forcedThinking);
868
+ const unanimousMoonshotTransport = kimiEvidence.every((evidence) => evidence.moonshotTransport);
869
+
870
+ const id = wireString(deployments[0]?.model_name);
871
+ if (id === undefined) return undefined;
872
+
873
+ const semanticReasoningPolicy = buildReasoningPolicy(
874
+ semanticModel,
875
+ acceptedOpenAIParams,
876
+ reasoning,
877
+ explicitlyUnsupported,
878
+ );
879
+ // A semantic policy that denies the effort carrier describes a generation with
880
+ // binary or fixed thinking. When no public catalog states effort levels, that
881
+ // contract stays in force even though a host accepts `reasoning_effort`;
882
+ // otherwise Pi's defaults would turn a single on/off switch into five
883
+ // selectable levels. Positive public level evidence still governs, as it does
884
+ // for level-based generations whose accepted levels vary by host: Moonshot
885
+ // rejects `medium` on K3 while Azure Foundry accepts it.
886
+ const semanticMap = semanticReasoningPolicy.thinkingLevelMap;
887
+ const hasEvidenceLevels = Object.values(evidenceLevelMap ?? {}).some((level) => level !== null);
888
+ const semanticBinary = semanticReasoningPolicy.compat?.supportsReasoningEffort === false && !hasEvidenceLevels;
889
+ const explicitOffDenial = deployments.some(
890
+ (entry) => wireBoolean(entry.model_info?.supports_none_reasoning_effort) === false,
891
+ );
892
+ const reasoningPolicy = semanticMap
893
+ ? {
894
+ ...semanticReasoningPolicy,
895
+ thinkingLevelMap: !acceptsResponsesReasoningControl
896
+ ? semanticMap
897
+ : semanticBinary
898
+ ? { ...semanticMap, ...deniedLevels(evidenceLevelMap) }
899
+ : {
900
+ ...evidenceLevelMap,
901
+ ...(semanticMap.off !== undefined ? { off: explicitOffDenial ? null : semanticMap.off } : {}),
902
+ },
903
+ }
904
+ : semanticReasoningPolicy;
905
+
906
+ return {
907
+ id,
908
+ api,
909
+ reasoning,
910
+ acceptsResponsesReasoningControl,
911
+ ...(thinkingLevelMap ? { thinkingLevelMap } : {}),
912
+ vision,
913
+ contextWindow,
914
+ maxTokens,
915
+ cost,
916
+ hasCompleteCost,
917
+ hasCompleteMetadata,
918
+ ...(hasCatalogAuthority ? { catalogProvider } : {}),
919
+ ...(semanticModel ? { semanticModel } : {}),
920
+ ...(semanticFamily ? { semanticFamily } : {}),
921
+ ...(messagesCompat ? { messagesCompat: JSON.parse(messagesCompat) } : {}),
922
+ ...(catalogAuthorityAmbiguous ? { catalogAuthorityAmbiguous: true } : {}),
923
+ deploymentFamilies: catalogs.map((catalog) => catalog?.semanticFamily),
924
+ normalizeThinkTags: unanimousNormalKimi,
925
+ suppressReasoningVisibility: unanimousNormalKimi && unanimousMoonshotTransport,
926
+ acceptedOpenAIParams,
927
+ reasoningPolicy,
928
+ };
929
+ }
930
+
931
+ export function catalogResolution(provider: string, model: Model<Api>): CatalogResolution {
932
+ return {
933
+ provider,
934
+ catalogModelId: model.id,
935
+ reasoning: model.reasoning,
936
+ effortLevels: model.thinkingLevelMap
937
+ ? Object.entries(model.thinkingLevelMap)
938
+ .filter(([, value]) => value !== null)
939
+ .map(([level]) => level)
940
+ : undefined,
941
+ thinkingLevelMap: model.thinkingLevelMap,
942
+ vision: model.input.includes("image"),
943
+ contextWindow: model.contextWindow,
944
+ maxTokens: model.maxTokens,
945
+ cost: model.cost,
946
+ };
947
+ }