pi-provider-litellm 2.3.0 → 3.0.1
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.
- package/README.md +148 -24
- package/package.json +13 -18
- package/src/backend-identity.ts +100 -0
- package/src/cost.ts +52 -0
- package/src/discover.ts +1318 -0
- package/src/gcloud-token.ts +160 -0
- package/src/index.ts +2044 -0
- package/src/mcp-tools.ts +1341 -0
- package/src/model-groups.ts +947 -0
- package/src/protocols.ts +42 -0
- package/src/provider.ts +248 -0
- package/src/public-catalog.ts +272 -0
- package/src/skills.ts +275 -0
- package/src/thinking-levels.ts +35 -0
- package/src/types.ts +164 -0
- package/dist/cost.d.ts +0 -2
- package/dist/cost.js +0 -49
- package/dist/discover.d.ts +0 -12
- package/dist/discover.js +0 -380
- package/dist/gcloud-token-cli.d.ts +0 -1
- package/dist/gcloud-token-cli.js +0 -8
- package/dist/gcloud-token.d.ts +0 -7
- package/dist/gcloud-token.js +0 -133
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -1226
- package/dist/litellm.d.ts +0 -1
- package/dist/litellm.js +0 -10
- package/dist/mcp-tools.d.ts +0 -5
- package/dist/mcp-tools.js +0 -181
- package/dist/provider.d.ts +0 -16
- package/dist/provider.js +0 -40
- package/dist/skills.d.ts +0 -14
- package/dist/skills.js +0 -225
- package/dist/types.d.ts +0 -108
- package/dist/types.js +0 -1
|
@@ -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
|
+
// Derived from the level table rather than restated: a second hand-written copy
|
|
173
|
+
// silently dropped `supports_medium_reasoning_effort` and
|
|
174
|
+
// `supports_high_reasoning_effort`, so those router flags were never read.
|
|
175
|
+
const LITELLM_LEVEL_FLAGS = Object.fromEntries(THINKING_LEVEL_DEFINITIONS.map(([level, , flag]) => [level, flag])) as {
|
|
176
|
+
[Definition in (typeof THINKING_LEVEL_DEFINITIONS)[number] as Definition[0]]: Definition[2];
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
function normalizeEffort(level: string): (typeof EXTENDED_LEVELS)[number] | undefined {
|
|
180
|
+
const normalized = level === "none" ? "off" : level;
|
|
181
|
+
return EXTENDED_LEVELS.find((candidate) => candidate === normalized);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// A public effort list is complete: listed standard levels are enabled and omitted
|
|
185
|
+
// ones are denied. Without a public list, standard levels stay absent so Pi keeps
|
|
186
|
+
// its defaults. LiteLLM flags may add or remove levels, while xhigh/max always
|
|
187
|
+
// require an explicit LiteLLM opt-in.
|
|
188
|
+
function reasoningLevelMap(
|
|
189
|
+
entries: readonly ModelInfoEntry[],
|
|
190
|
+
catalogEfforts: readonly (readonly string[] | undefined)[],
|
|
191
|
+
catalogMaps: readonly DiscoveredModel["thinkingLevelMap"][],
|
|
192
|
+
): DiscoveredModel["thinkingLevelMap"] {
|
|
193
|
+
const publicSets = catalogEfforts
|
|
194
|
+
.filter((levels): levels is readonly string[] => levels !== undefined)
|
|
195
|
+
.map((levels) => new Set(levels.map(normalizeEffort).filter((level) => level !== undefined)))
|
|
196
|
+
.filter((levels) => levels.size > 0);
|
|
197
|
+
const map: NonNullable<DiscoveredModel["thinkingLevelMap"]> = {};
|
|
198
|
+
if (publicSets.length > 0) {
|
|
199
|
+
for (const level of ["off", "minimal", "low", "medium", "high"] as const) {
|
|
200
|
+
map[level] = publicSets.every((set) => set.has(level)) ? (level === "off" ? "none" : level) : null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// A catalog level map is direct evidence even when its selectable projection
|
|
204
|
+
// is empty. Preserve its denials independently from public effort lists, whose
|
|
205
|
+
// unrecognized values intentionally have no opinion.
|
|
206
|
+
for (const catalogMap of catalogMaps) Object.assign(map, deniedLevels(catalogMap));
|
|
207
|
+
|
|
208
|
+
for (const [level, flag] of Object.entries(LITELLM_LEVEL_FLAGS) as Array<
|
|
209
|
+
[keyof typeof LITELLM_LEVEL_FLAGS, (typeof LITELLM_LEVEL_FLAGS)[keyof typeof LITELLM_LEVEL_FLAGS]]
|
|
210
|
+
>) {
|
|
211
|
+
const reported = entries.map((entry) => wireBoolean(entry.model_info?.[flag]));
|
|
212
|
+
if (reported.some((value) => value === false)) map[level] = null;
|
|
213
|
+
else if (reported.length > 0 && reported.every((value) => value === true)) {
|
|
214
|
+
map[level] = level === "off" ? "none" : level;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (const level of ["xhigh", "max"] as const) {
|
|
218
|
+
const flag = LITELLM_LEVEL_FLAGS[level];
|
|
219
|
+
if (!entries.every((entry) => wireBoolean(entry.model_info?.[flag]) === true)) map[level] = null;
|
|
220
|
+
}
|
|
221
|
+
return map;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function deniedLevels(map: DiscoveredModel["thinkingLevelMap"]): Partial<Record<string, null>> {
|
|
225
|
+
return Object.fromEntries(
|
|
226
|
+
Object.entries(map ?? {})
|
|
227
|
+
.filter(([, level]) => level === null)
|
|
228
|
+
.map(([level]) => [level, null]),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function normalizeParams(params: unknown): Set<string> {
|
|
233
|
+
if (!Array.isArray(params)) return new Set();
|
|
234
|
+
const named = params.map((param) => wireString(param)?.trim()).filter((param) => Boolean(param));
|
|
235
|
+
return new Set(named as string[]);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function acceptedParams(entry: ModelInfoEntry): Set<string> {
|
|
239
|
+
const params = normalizeParams(entry.model_info?.supported_openai_params);
|
|
240
|
+
for (const param of normalizeParams(entry.litellm_params?.allowed_openai_params)) params.add(param);
|
|
241
|
+
return params;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function intersectParams(entries: readonly ModelInfoEntry[]): string[] {
|
|
245
|
+
const [first, ...rest] = entries.map(acceptedParams);
|
|
246
|
+
if (!first) return [];
|
|
247
|
+
return [...first].filter((param) => rest.every((params) => params.has(param))).sort();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const ONLY_HIGH = {
|
|
251
|
+
off: null,
|
|
252
|
+
minimal: null,
|
|
253
|
+
low: null,
|
|
254
|
+
medium: null,
|
|
255
|
+
high: "high",
|
|
256
|
+
xhigh: null,
|
|
257
|
+
max: null,
|
|
258
|
+
} as const;
|
|
259
|
+
|
|
260
|
+
// pi-ai reads an ABSENT thinkingLevelMap as "every standard level supported"
|
|
261
|
+
// (getSupportedThinkingLevels), so a policy that cannot transmit any level has
|
|
262
|
+
// to deny each one explicitly instead of omitting the map.
|
|
263
|
+
export const NO_TRANSMISSIBLE_LEVELS = {
|
|
264
|
+
off: null,
|
|
265
|
+
minimal: null,
|
|
266
|
+
low: null,
|
|
267
|
+
medium: null,
|
|
268
|
+
high: null,
|
|
269
|
+
xhigh: null,
|
|
270
|
+
max: null,
|
|
271
|
+
} as const;
|
|
272
|
+
|
|
273
|
+
// Efforts the Responses API accepts. pi-ai passes an unmapped level through
|
|
274
|
+
// verbatim and reads `thinkingLevelMap.off` as the disable value, so a Chat-shaped
|
|
275
|
+
// map would emit `off` as an effort. `none` is the disable spelling — pi-ai's own
|
|
276
|
+
// `openai/gpt-5.5` entry maps `off` to it, and its `openai/gpt-5.6-*` entries map
|
|
277
|
+
// `max` to itself, so `max` is a real Responses effort and not a Chat-only value.
|
|
278
|
+
const RESPONSES_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
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
|
+
}
|