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.
- package/README.md +173 -25
- package/package.json +14 -19
- 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 +2005 -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 +162 -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 -333
- 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 -1184
- 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 -100
- package/dist/types.js +0 -1
package/src/discover.ts
ADDED
|
@@ -0,0 +1,1318 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
3
|
+
import { getModels, getProviders } from "@earendil-works/pi-ai/compat";
|
|
4
|
+
import type { BuiltinProvider } from "@earendil-works/pi-ai/providers/all";
|
|
5
|
+
import { LITELLM_DISCOVERY_VERSION, resolveBackendIdentity, resolveCatalogProvider } from "./backend-identity.js";
|
|
6
|
+
import {
|
|
7
|
+
type CatalogResolution,
|
|
8
|
+
catalogResolution,
|
|
9
|
+
closeSerializerPolicy,
|
|
10
|
+
conservativeCostTiers,
|
|
11
|
+
DEFAULT_CONTEXT_WINDOW,
|
|
12
|
+
DEFAULT_MAX_TOKENS,
|
|
13
|
+
hasMixedIncompatibleDeploymentModes,
|
|
14
|
+
type MessagesBackendCompat,
|
|
15
|
+
meetVendorCompat,
|
|
16
|
+
normalizedMode,
|
|
17
|
+
reduceModelGroup,
|
|
18
|
+
type SemanticFamily,
|
|
19
|
+
type SemanticModel,
|
|
20
|
+
wireString,
|
|
21
|
+
} from "./model-groups.js";
|
|
22
|
+
import { loadPublicCatalog, type PublicCatalog, type PublicCatalogRecord } from "./public-catalog.js";
|
|
23
|
+
import { intersectThinkingLevelMaps } from "./thinking-levels.js";
|
|
24
|
+
import type {
|
|
25
|
+
DiscoveredModel,
|
|
26
|
+
DiscoveredModelFor,
|
|
27
|
+
DiscoveryOptions,
|
|
28
|
+
DiscoveryResult,
|
|
29
|
+
HealthResponse,
|
|
30
|
+
LiteLLMModelPolicy,
|
|
31
|
+
ModelInfoEntry,
|
|
32
|
+
ModelInfoResponse,
|
|
33
|
+
ModelProtocol,
|
|
34
|
+
ModelsListEntry,
|
|
35
|
+
ModelsListResponse,
|
|
36
|
+
} from "./types.js";
|
|
37
|
+
|
|
38
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
39
|
+
const HEALTH_DETAIL_CONCURRENCY = 8;
|
|
40
|
+
const reportedConflictingFamilyRoutes = new Set<string>();
|
|
41
|
+
const reportedWithheldRepairRoutes = new Set<string>();
|
|
42
|
+
interface HealthDeployment {
|
|
43
|
+
entry: ModelInfoEntry;
|
|
44
|
+
denyLevels: boolean;
|
|
45
|
+
synthetic: boolean;
|
|
46
|
+
}
|
|
47
|
+
interface WildcardExpansionSource {
|
|
48
|
+
model: DiscoveredModel;
|
|
49
|
+
deploymentFamilies: readonly CatalogResolution["semanticFamily"][];
|
|
50
|
+
}
|
|
51
|
+
const GENERIC_TRANSPORT_ADAPTERS = new Set([
|
|
52
|
+
"azure",
|
|
53
|
+
"azure_ai",
|
|
54
|
+
"custom_openai",
|
|
55
|
+
"openai",
|
|
56
|
+
"openai_like",
|
|
57
|
+
"text-completion-openai",
|
|
58
|
+
]);
|
|
59
|
+
const PUBLIC_CATALOG_BUDGET_MS = 1000;
|
|
60
|
+
const PUBLIC_CATALOG_PROVIDER_BY_FAMILY = {
|
|
61
|
+
claude: "anthropic",
|
|
62
|
+
deepseek: "deepseek",
|
|
63
|
+
gemini: "gemini",
|
|
64
|
+
kimi: "moonshot",
|
|
65
|
+
openai: "openai",
|
|
66
|
+
} as const;
|
|
67
|
+
const KNOWN_PROVIDER_SET = new Set<string>(getProviders());
|
|
68
|
+
export function normalizeBaseUrl(input: string, allowInsecureHttp = false): string {
|
|
69
|
+
const url = new URL(input);
|
|
70
|
+
const hostname = url.hostname.toLowerCase();
|
|
71
|
+
const loopback =
|
|
72
|
+
hostname === "localhost" || hostname === "[::1]" || (isIP(hostname) === 4 && hostname.startsWith("127."));
|
|
73
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && (loopback || allowInsecureHttp))) {
|
|
74
|
+
throw new Error("LiteLLM base URL must use HTTPS except for loopback hosts");
|
|
75
|
+
}
|
|
76
|
+
return input.replace(/\/+$/, "").replace(/\/v1\/?$/i, "");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Matches both the conventional `anthropic/...` prefix and aliases that
|
|
80
|
+
// LiteLLM deployments commonly assign to Anthropic-backed routes (e.g.
|
|
81
|
+
// `google/claude-sonnet-4-6`, `opus-4.7`, `sonnet-4.6`, `haiku-4.5`). Without
|
|
82
|
+
// the `cacheControlFormat: "anthropic"` flag, pi never relays cache_control
|
|
83
|
+
// markers through the proxy, so prompt caching silently no-ops on Claude models.
|
|
84
|
+
const ANTHROPIC_MODEL_PATTERN = /(?:^|[-_/.:])(?:anthropic\/|(?:claude|fable|opus|sonnet|haiku)(?=$|[-_/.:]))/i;
|
|
85
|
+
const MOONSHOT_MODEL_PATTERN = /^(moonshotai\/|moonshot\/|kimi[-/])/i;
|
|
86
|
+
const FORCED_THINKING_MODEL_PATTERN = /(?:^|[-/])thinking(?:[-/]|$)/i;
|
|
87
|
+
// Deployments expose the gpt-5.5 route under varying names (`llm-gateway/gpt-5.5`,
|
|
88
|
+
// bare `gpt-5.5`, dated ids like `gpt-5.5-20260504143601`); match them all so the
|
|
89
|
+
// tool+reasoning workaround survives route renames.
|
|
90
|
+
const GPT55_MODEL_PATTERN = /(?:^|\/)gpt-5\.5(?:$|[-.])/i;
|
|
91
|
+
|
|
92
|
+
export function isMoonshotModel(modelId: string): boolean {
|
|
93
|
+
return MOONSHOT_MODEL_PATTERN.test(modelId);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function isGpt55Model(modelId: string): boolean {
|
|
97
|
+
return GPT55_MODEL_PATTERN.test(modelId);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function shouldSuppressReasoningContent(modelId: string): boolean {
|
|
101
|
+
return isMoonshotModel(modelId) && !FORCED_THINKING_MODEL_PATTERN.test(modelId);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function emitsThinkTags(modelId: string): boolean {
|
|
105
|
+
return shouldSuppressReasoningContent(modelId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function responsesCompat(modelId: string): DiscoveredModelFor<"openai-responses">["compat"] {
|
|
109
|
+
// Pi's Responses transport has no cacheControlFormat setting and uses
|
|
110
|
+
// Responses-native prompt-cache fields instead of Anthropic cache_control markers.
|
|
111
|
+
return isMoonshotModel(modelId) ? { supportsDeveloperRole: false } : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function completionsCompat(
|
|
115
|
+
modelId: string,
|
|
116
|
+
semanticFamily?: CatalogResolution["semanticFamily"],
|
|
117
|
+
): DiscoveredModelFor<"openai-completions">["compat"] {
|
|
118
|
+
// Conflicting deployment evidence must not re-enable route-name inference.
|
|
119
|
+
if (semanticFamily === "conflicting") return { supportsStore: false };
|
|
120
|
+
if (semanticFamily === "kimi" || (semanticFamily === undefined && isMoonshotModel(modelId))) {
|
|
121
|
+
return {
|
|
122
|
+
supportsStore: false,
|
|
123
|
+
supportsDeveloperRole: false,
|
|
124
|
+
supportsReasoningEffort: false,
|
|
125
|
+
supportsStrictMode: false,
|
|
126
|
+
maxTokensField: "max_tokens",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
if (semanticFamily === "claude" || (semanticFamily === undefined && ANTHROPIC_MODEL_PATTERN.test(modelId))) {
|
|
130
|
+
return { supportsStore: false, cacheControlFormat: "anthropic" };
|
|
131
|
+
}
|
|
132
|
+
return { supportsStore: false };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function supportsResponses(entry: ModelInfoEntry): boolean {
|
|
136
|
+
const endpoints = entry.model_info?.supported_endpoints;
|
|
137
|
+
if (Array.isArray(endpoints)) return endpoints.some((endpoint) => endpoint === "/v1/responses");
|
|
138
|
+
if (normalizedMode(entry.model_info?.mode) === "responses") return true;
|
|
139
|
+
|
|
140
|
+
const identity = resolveBackendIdentity(entry);
|
|
141
|
+
if (identity?.family !== "openai") return false;
|
|
142
|
+
const adapter = wireString(entry.litellm_params?.custom_llm_provider)?.trim().toLowerCase();
|
|
143
|
+
const configuredModel = wireString(entry.litellm_params?.model)?.trim().toLowerCase();
|
|
144
|
+
const reportedProvider = wireString(entry.model_info?.litellm_provider)?.trim().toLowerCase();
|
|
145
|
+
const azureAdapter =
|
|
146
|
+
adapter === "azure" ||
|
|
147
|
+
adapter === "azure_ai" ||
|
|
148
|
+
/^azure(?:_ai)?\//.test(configuredModel ?? "") ||
|
|
149
|
+
reportedProvider === "azure" ||
|
|
150
|
+
reportedProvider === "azure_ai";
|
|
151
|
+
// LiteLLM bridges /v1/responses to chat completions when the provider has no native Responses config
|
|
152
|
+
// (litellm/responses/main.py, _bridges_to_chat_completions), so generic adapters remain eligible for Responses.
|
|
153
|
+
if (!azureAdapter) return true;
|
|
154
|
+
|
|
155
|
+
const apiVersion = entry.litellm_params?.api_version;
|
|
156
|
+
if (apiVersion != null && typeof apiVersion !== "string") return false;
|
|
157
|
+
const version = apiVersion?.trim();
|
|
158
|
+
if (!version) return true;
|
|
159
|
+
const date = version.match(/^(\d{4}-\d{2}-\d{2})(?:-preview)?$/)?.[1];
|
|
160
|
+
return date !== undefined && date >= "2025-03-01";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function modelProtocol(modelId: string, modeOrEntry?: string | null | ModelInfoEntry): ModelProtocol {
|
|
164
|
+
const selectedEntry =
|
|
165
|
+
typeof modeOrEntry === "object" && modeOrEntry !== null
|
|
166
|
+
? { ...modeOrEntry, model_name: undefined }
|
|
167
|
+
: { model_name: modelId, model_info: { mode: modeOrEntry } };
|
|
168
|
+
return supportsResponses(selectedEntry)
|
|
169
|
+
? { api: "openai-responses", compat: responsesCompat(modelId) }
|
|
170
|
+
: { api: "openai-completions", compat: completionsCompat(modelId) };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function buildCompat(modelId: string): DiscoveredModelFor<"openai-completions">["compat"] {
|
|
174
|
+
return completionsCompat(modelId);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function toKnownProvider(provider: string | undefined): BuiltinProvider | undefined {
|
|
178
|
+
if (!provider) return undefined;
|
|
179
|
+
const normalized = provider.trim().toLowerCase();
|
|
180
|
+
return KNOWN_PROVIDER_SET.has(normalized) ? (normalized as BuiltinProvider) : undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Anthropic recognition is derived from the single `catalogLookupIds` rule so a
|
|
184
|
+
// second alias pattern cannot drift away from it. Every Anthropic catalog id and
|
|
185
|
+
// every alias that maps onto one is canonicalized to a `claude-` lookup id,
|
|
186
|
+
// including single-number names and dated snapshots.
|
|
187
|
+
function catalogProviderCandidates(lookupIds: readonly string[], id: string, ownedBy?: string): BuiltinProvider[] {
|
|
188
|
+
const candidates = [toKnownProvider(ownedBy), toKnownProvider(id.split("/")[0])];
|
|
189
|
+
if (lookupIds.some((lookupId) => lookupId.startsWith("claude-"))) candidates.push("anthropic");
|
|
190
|
+
return [...new Set(candidates.filter((provider): provider is BuiltinProvider => provider !== undefined))];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function resolveCatalogModel(
|
|
194
|
+
id: string,
|
|
195
|
+
ownedBy?: string,
|
|
196
|
+
): { provider: BuiltinProvider; model: Model<Api> } | undefined {
|
|
197
|
+
const lookupIds = catalogLookupIds(id);
|
|
198
|
+
for (const provider of catalogProviderCandidates(lookupIds, id, ownedBy)) {
|
|
199
|
+
const model = findCatalogModelInProvider(provider, lookupIds);
|
|
200
|
+
if (model) return { provider, model };
|
|
201
|
+
}
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function findCatalogModel(id: string, ownedBy?: string): Model<Api> | undefined {
|
|
206
|
+
return resolveCatalogModel(id, ownedBy)?.model;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function enrichCachedModel(input: Model<Api>): Model<Api> {
|
|
210
|
+
const restored = restoreCachedModelPolicy(input);
|
|
211
|
+
// A model stored by a release that predates the transmissibility gate carries
|
|
212
|
+
// whatever level map that release published, so the gate applies to the cached
|
|
213
|
+
// map on the way in — not only to catalog metadata on the way out, which every
|
|
214
|
+
// reasoning model skips via the guard below.
|
|
215
|
+
const { thinkingLevelMap: cachedThinkingLevelMap, ...restoredWithoutLevels } = restored;
|
|
216
|
+
const model = {
|
|
217
|
+
...restoredWithoutLevels,
|
|
218
|
+
...closeSerializerPolicy({
|
|
219
|
+
api:
|
|
220
|
+
restored.api === "anthropic-messages"
|
|
221
|
+
? "anthropic-messages"
|
|
222
|
+
: restored.api === "openai-responses"
|
|
223
|
+
? "openai-responses"
|
|
224
|
+
: "openai-completions",
|
|
225
|
+
reasoning: restored.reasoning,
|
|
226
|
+
vendorCompat: restored.compat,
|
|
227
|
+
catalogLevels: cachedThinkingLevelMap,
|
|
228
|
+
requireChatCarrier: true,
|
|
229
|
+
allowInferredChatCarrier: false,
|
|
230
|
+
acceptsResponsesReasoningControl: hasResponsesReasoningControl(restored),
|
|
231
|
+
}),
|
|
232
|
+
} as Model<Api>;
|
|
233
|
+
// Reduced deployment groups use a distinct marker; this sentinel remains
|
|
234
|
+
// exclusive to evidence-free fallback models that may be enriched safely.
|
|
235
|
+
if (
|
|
236
|
+
!model.name.endsWith(" (no metadata)") ||
|
|
237
|
+
model.reasoning ||
|
|
238
|
+
model.thinkingLevelMap !== undefined ||
|
|
239
|
+
model.input.length !== 1 ||
|
|
240
|
+
model.input[0] !== "text" ||
|
|
241
|
+
model.cost.input !== 0 ||
|
|
242
|
+
model.cost.output !== 0 ||
|
|
243
|
+
model.cost.cacheRead !== 0 ||
|
|
244
|
+
model.cost.cacheWrite !== 0 ||
|
|
245
|
+
model.cost.tiers !== undefined ||
|
|
246
|
+
model.contextWindow !== DEFAULT_CONTEXT_WINDOW ||
|
|
247
|
+
model.maxTokens !== DEFAULT_MAX_TOKENS
|
|
248
|
+
) {
|
|
249
|
+
return model;
|
|
250
|
+
}
|
|
251
|
+
const catalogModel = findCatalogModel(model.id);
|
|
252
|
+
if (!catalogModel) return model;
|
|
253
|
+
// Match evidence-free discovery: only an explicit Responses catalog transport
|
|
254
|
+
// changes the protocol; all other catalog APIs continue through Chat.
|
|
255
|
+
const api = catalogModel.api === "openai-responses" ? "openai-responses" : "openai-completions";
|
|
256
|
+
return {
|
|
257
|
+
...model,
|
|
258
|
+
name: catalogModel.name,
|
|
259
|
+
// The cached compat stays as stored, so catalog levels are closed against it
|
|
260
|
+
// rather than trusted because the catalog offered them.
|
|
261
|
+
...closeSerializerPolicy({
|
|
262
|
+
api,
|
|
263
|
+
reasoning: catalogModel.reasoning,
|
|
264
|
+
vendorCompat: catalogProtocol(model.id, catalogModel).compat,
|
|
265
|
+
catalogLevels: catalogModel.thinkingLevelMap,
|
|
266
|
+
requireChatCarrier: true,
|
|
267
|
+
allowInferredChatCarrier: false,
|
|
268
|
+
acceptsResponsesReasoningControl: hasResponsesReasoningControl(model),
|
|
269
|
+
}),
|
|
270
|
+
input: catalogModel.input,
|
|
271
|
+
cost: catalogModel.cost,
|
|
272
|
+
contextWindow: catalogModel.contextWindow,
|
|
273
|
+
maxTokens: catalogModel.maxTokens,
|
|
274
|
+
api,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function undecoratedBackendIds(id: string): string[] {
|
|
279
|
+
const normalized = id.toLowerCase();
|
|
280
|
+
const unprefixed = normalized.includes("/") ? normalized.slice(normalized.indexOf("/") + 1) : normalized;
|
|
281
|
+
const routed = normalized.split("/").pop() ?? normalized;
|
|
282
|
+
const variants = (candidate: string): string[] => {
|
|
283
|
+
const undecorated = candidate.replace(/-v\d+(?::\d+)?$/, "").replace(/@[a-z0-9-]+$/, "");
|
|
284
|
+
return [candidate, candidate.replace(/:\d+$/, ""), undecorated, undecorated.replace(/-\d{8}$/, "")];
|
|
285
|
+
};
|
|
286
|
+
return [...new Set([...variants(unprefixed), ...variants(routed)].filter(Boolean))];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function catalogLookupIds(id: string): string[] {
|
|
290
|
+
const normalized = id.toLowerCase();
|
|
291
|
+
const lookupIds = new Set([normalized]);
|
|
292
|
+
const unprefixed = normalized.includes("/") ? normalized.slice(normalized.indexOf("/") + 1) : normalized;
|
|
293
|
+
lookupIds.add(unprefixed);
|
|
294
|
+
for (const candidate of undecoratedBackendIds(id)) lookupIds.add(candidate);
|
|
295
|
+
|
|
296
|
+
const anthropicAlias = unprefixed.replaceAll(".", "-");
|
|
297
|
+
const match = /^(?:claude-)?(opus|sonnet|haiku)-(\d+)-(\d+)$/.exec(anthropicAlias);
|
|
298
|
+
if (match) lookupIds.add(`claude-${match[1]}-${match[2]}-${match[3]}`);
|
|
299
|
+
if (anthropicAlias === "fable-5" || anthropicAlias === "opus-5") lookupIds.add(`claude-${anthropicAlias}`);
|
|
300
|
+
|
|
301
|
+
return [...lookupIds];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function findCatalogModelInProvider(provider: BuiltinProvider, lookupIds: string[]): Model<Api> | undefined {
|
|
305
|
+
const models = getModels(provider);
|
|
306
|
+
for (const lookupId of lookupIds) {
|
|
307
|
+
const normalized = lookupId.toLowerCase();
|
|
308
|
+
const exact = models.find((model) => model.id.toLowerCase() === normalized);
|
|
309
|
+
if (exact) return exact;
|
|
310
|
+
const qualified = `${provider}/${normalized}`;
|
|
311
|
+
const providerQualified = models.find((model) => model.id.toLowerCase() === qualified);
|
|
312
|
+
if (providerQualified) return providerQualified;
|
|
313
|
+
}
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function messagesCompatOf(model: Model<Api>): MessagesBackendCompat | undefined {
|
|
318
|
+
// Native Messages is safe only when Pi's Anthropic catalog supplies the
|
|
319
|
+
// serializer policy for that generation. Lookup tries the exact id first, then
|
|
320
|
+
// that generation's entry; provider adapter metadata is not a wire contract.
|
|
321
|
+
if (model.api !== "anthropic-messages") return undefined;
|
|
322
|
+
const compat = (model as Model<"anthropic-messages">).compat;
|
|
323
|
+
const carried: MessagesBackendCompat = {};
|
|
324
|
+
if (compat?.forceAdaptiveThinking !== undefined) carried.forceAdaptiveThinking = compat.forceAdaptiveThinking;
|
|
325
|
+
if (compat?.supportsTemperature !== undefined) carried.supportsTemperature = compat.supportsTemperature;
|
|
326
|
+
if (compat?.supportsStrictTools !== undefined) carried.supportsStrictTools = compat.supportsStrictTools;
|
|
327
|
+
return carried;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function anthropicBackendLookupIds(id: string): string[] {
|
|
331
|
+
const routed = (id.split("/").pop() ?? id).toLowerCase();
|
|
332
|
+
const base = routed.replace(/^(?:[a-z0-9-]+\.)*anthropic[./]/, "");
|
|
333
|
+
const lookupIds = new Set(undecoratedBackendIds(base));
|
|
334
|
+
for (const candidate of lookupIds) {
|
|
335
|
+
const generation = /^(claude-[a-z]+-\d+)-\d+$/.exec(candidate)?.[1];
|
|
336
|
+
if (generation) lookupIds.add(generation);
|
|
337
|
+
}
|
|
338
|
+
return [...lookupIds];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// These are the LiteLLM adapters whose native request path can terminate at a
|
|
342
|
+
// Claude backend. Other adapters may expose Claude-like public aliases, but an
|
|
343
|
+
// alias alone is not evidence that LiteLLM accepts the Anthropic Messages schema.
|
|
344
|
+
const CLAUDE_CAPABLE_ADAPTERS = new Set([
|
|
345
|
+
"anthropic",
|
|
346
|
+
"bedrock",
|
|
347
|
+
"bedrock_converse",
|
|
348
|
+
"vertex_ai",
|
|
349
|
+
"vertex_ai-anthropic_models",
|
|
350
|
+
]);
|
|
351
|
+
const CLAUDE_MODEL_PATTERN = /(?:^|[./_-])(?:claude|opus|sonnet|haiku|fable)(?:$|[./_:-])/i;
|
|
352
|
+
|
|
353
|
+
function nativeMessagesCatalog(
|
|
354
|
+
entry: ModelInfoEntry,
|
|
355
|
+
): Pick<CatalogResolution, "messagesCompat" | "messagesThinkingLevelMap"> {
|
|
356
|
+
const adapter = wireString(entry.model_info?.litellm_provider)?.trim().toLowerCase();
|
|
357
|
+
if (!adapter || !CLAUDE_CAPABLE_ADAPTERS.has(adapter) || deploymentFamily(entry) !== "claude") return {};
|
|
358
|
+
const candidates = [entry.litellm_params?.model, entry.model_info?.base_model]
|
|
359
|
+
.map((id) => wireString(id)?.trim())
|
|
360
|
+
.filter((id): id is string => Boolean(id));
|
|
361
|
+
if (candidates.length === 0 || !candidates.some((id) => CLAUDE_MODEL_PATTERN.test(id))) return {};
|
|
362
|
+
const models = candidates.map((id) => findCatalogModelInProvider("anthropic", anthropicBackendLookupIds(id)));
|
|
363
|
+
// Every declared backend must resolve to the same native serializer policy.
|
|
364
|
+
// An unresolved identity cannot be discarded to make the remainder unanimous.
|
|
365
|
+
if (models.some((model) => !model) || new Set(models.map((model) => model?.id)).size !== 1) return {};
|
|
366
|
+
const model = models[0]!;
|
|
367
|
+
const compat = messagesCompatOf(model);
|
|
368
|
+
return compat ? { messagesCompat: compat, messagesThinkingLevelMap: model.thinkingLevelMap } : {};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const ADAPTER_CATALOG_PROVIDERS: Readonly<Record<string, BuiltinProvider>> = {
|
|
372
|
+
anthropic: "anthropic",
|
|
373
|
+
claude: "anthropic",
|
|
374
|
+
azure: "azure-openai-responses",
|
|
375
|
+
azure_ai: "azure-openai-responses",
|
|
376
|
+
bedrock: "amazon-bedrock",
|
|
377
|
+
bedrock_converse: "amazon-bedrock",
|
|
378
|
+
chatgpt: "openai-codex",
|
|
379
|
+
deepseek: "deepseek",
|
|
380
|
+
"fireworks-ai": "fireworks",
|
|
381
|
+
fireworks_ai: "fireworks",
|
|
382
|
+
gemini: "google",
|
|
383
|
+
kimi: "moonshotai",
|
|
384
|
+
moonshot: "moonshotai",
|
|
385
|
+
nvidia_nim: "nvidia",
|
|
386
|
+
openai: "openai",
|
|
387
|
+
together_ai: "together",
|
|
388
|
+
vertex_ai: "google-vertex",
|
|
389
|
+
"vertex_ai-anthropic_models": "google-vertex",
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
function adapterCatalogProvider(adapter: unknown): BuiltinProvider | undefined {
|
|
393
|
+
const normalized = wireString(adapter)?.trim().toLowerCase();
|
|
394
|
+
return normalized ? (ADAPTER_CATALOG_PROVIDERS[normalized] ?? toKnownProvider(normalized)) : undefined;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function adaptPublicCatalogRecord(
|
|
398
|
+
provider: string,
|
|
399
|
+
catalogModelId: string,
|
|
400
|
+
record: PublicCatalogRecord | undefined,
|
|
401
|
+
): CatalogResolution {
|
|
402
|
+
const piProvider = adapterCatalogProvider(record?.provider ?? provider);
|
|
403
|
+
const resolved = piProvider
|
|
404
|
+
? (resolveCatalogModel(record?.modelId ?? catalogModelId, piProvider) ??
|
|
405
|
+
resolveCatalogModel(catalogModelId, piProvider))
|
|
406
|
+
: undefined;
|
|
407
|
+
const piCatalog = resolved ? catalogResolution(resolved.provider, resolved.model) : undefined;
|
|
408
|
+
const piCost = piCatalog?.cost;
|
|
409
|
+
return {
|
|
410
|
+
provider,
|
|
411
|
+
// A models.dev hit names the model canonically even when Pi's catalog does not know it yet,
|
|
412
|
+
// so two spellings of one backend model reduce to one identity instead of a conflict.
|
|
413
|
+
catalogModelId: resolved?.model.id ?? record?.modelId ?? catalogModelId,
|
|
414
|
+
...(piCatalog?.reasoning !== undefined ? { reasoning: piCatalog.reasoning } : {}),
|
|
415
|
+
...(record?.effortLevels ? { reasoning: true, effortLevels: record.effortLevels } : {}),
|
|
416
|
+
...((record?.thinkingLevelMap ?? piCatalog?.thinkingLevelMap)
|
|
417
|
+
? { thinkingLevelMap: record?.thinkingLevelMap ?? piCatalog?.thinkingLevelMap }
|
|
418
|
+
: {}),
|
|
419
|
+
...(record?.modalities
|
|
420
|
+
? { vision: record.modalities.includes("image") }
|
|
421
|
+
: piCatalog?.vision !== undefined
|
|
422
|
+
? { vision: piCatalog.vision }
|
|
423
|
+
: {}),
|
|
424
|
+
...(record?.limits?.context !== undefined
|
|
425
|
+
? { contextWindow: record.limits.context }
|
|
426
|
+
: piCatalog?.contextWindow !== undefined
|
|
427
|
+
? { contextWindow: piCatalog.contextWindow }
|
|
428
|
+
: {}),
|
|
429
|
+
...(record?.limits?.output !== undefined
|
|
430
|
+
? { maxTokens: record.limits.output }
|
|
431
|
+
: piCatalog?.maxTokens !== undefined
|
|
432
|
+
? { maxTokens: piCatalog.maxTokens }
|
|
433
|
+
: {}),
|
|
434
|
+
...(record?.cost || piCost
|
|
435
|
+
? {
|
|
436
|
+
cost: {
|
|
437
|
+
...((record?.cost?.input ?? piCost?.input) !== undefined
|
|
438
|
+
? { input: record?.cost?.input ?? piCost?.input }
|
|
439
|
+
: {}),
|
|
440
|
+
...((record?.cost?.output ?? piCost?.output) !== undefined
|
|
441
|
+
? { output: record?.cost?.output ?? piCost?.output }
|
|
442
|
+
: {}),
|
|
443
|
+
...((record?.cost?.cacheRead ?? piCost?.cacheRead) !== undefined
|
|
444
|
+
? { cacheRead: record?.cost?.cacheRead ?? piCost?.cacheRead }
|
|
445
|
+
: {}),
|
|
446
|
+
...((record?.cost?.cacheWrite ?? piCost?.cacheWrite) !== undefined
|
|
447
|
+
? { cacheWrite: record?.cost?.cacheWrite ?? piCost?.cacheWrite }
|
|
448
|
+
: {}),
|
|
449
|
+
...(piCost?.tiers ? { tiers: piCost.tiers } : {}),
|
|
450
|
+
},
|
|
451
|
+
}
|
|
452
|
+
: {}),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function resolveModelInfoCatalog(
|
|
457
|
+
entry: ModelInfoEntry,
|
|
458
|
+
publicCatalog?: PublicCatalog,
|
|
459
|
+
): CatalogResolution | undefined {
|
|
460
|
+
const hasBackendIdentity = Boolean(
|
|
461
|
+
wireString(entry.model_info?.base_model)?.trim() || wireString(entry.litellm_params?.model)?.trim(),
|
|
462
|
+
);
|
|
463
|
+
if (!hasBackendIdentity) return undefined;
|
|
464
|
+
const identity = resolveBackendIdentity(entry);
|
|
465
|
+
if (!identity) return undefined;
|
|
466
|
+
const provider = resolveCatalogProvider(entry, identity);
|
|
467
|
+
if (provider) {
|
|
468
|
+
const record = publicCatalog?.lookup(provider, identity.modelId);
|
|
469
|
+
return { ...adaptPublicCatalogRecord(provider, identity.qualifiedId, record), ...nativeMessagesCatalog(entry) };
|
|
470
|
+
}
|
|
471
|
+
if (!identity.family) return undefined;
|
|
472
|
+
|
|
473
|
+
const publicProvider = PUBLIC_CATALOG_PROVIDER_BY_FAMILY[identity.family];
|
|
474
|
+
const record = publicCatalog?.lookup(publicProvider, identity.modelId);
|
|
475
|
+
return { ...adaptPublicCatalogRecord(publicProvider, identity.qualifiedId, record), ...nativeMessagesCatalog(entry) };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function awaitWithSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
479
|
+
if (signal.aborted) return Promise.reject(signal.reason);
|
|
480
|
+
return new Promise<T>((resolve, reject) => {
|
|
481
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
482
|
+
const onAbort = () => {
|
|
483
|
+
cleanup();
|
|
484
|
+
reject(signal.reason);
|
|
485
|
+
};
|
|
486
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
487
|
+
promise.then(
|
|
488
|
+
(value) => {
|
|
489
|
+
cleanup();
|
|
490
|
+
resolve(value);
|
|
491
|
+
},
|
|
492
|
+
(error) => {
|
|
493
|
+
cleanup();
|
|
494
|
+
reject(error);
|
|
495
|
+
},
|
|
496
|
+
);
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function awaitEnrichmentWithinBudget<T>(
|
|
501
|
+
promise: Promise<T>,
|
|
502
|
+
signal: AbortSignal | undefined,
|
|
503
|
+
timeoutMs: number,
|
|
504
|
+
): Promise<T | undefined> {
|
|
505
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
506
|
+
const budget = new Promise<undefined>((resolve) => {
|
|
507
|
+
timeout = setTimeout(() => resolve(undefined), timeoutMs);
|
|
508
|
+
});
|
|
509
|
+
try {
|
|
510
|
+
return await Promise.race([signal ? awaitWithSignal(promise, signal) : promise, budget]);
|
|
511
|
+
} finally {
|
|
512
|
+
if (timeout) clearTimeout(timeout);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function loadDiscoveryPublicCatalog(options: DiscoveryOptions): Promise<PublicCatalog | undefined> {
|
|
517
|
+
const publicCatalogPromise = loadPublicCatalog({
|
|
518
|
+
cachePath: options.modelsDevCachePath,
|
|
519
|
+
offline: options.modelsDev === false ? true : undefined,
|
|
520
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
521
|
+
});
|
|
522
|
+
return awaitEnrichmentWithinBudget(
|
|
523
|
+
publicCatalogPromise,
|
|
524
|
+
options.signal,
|
|
525
|
+
Math.min(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, PUBLIC_CATALOG_BUDGET_MS),
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function fetchJson<T>(
|
|
530
|
+
url: string,
|
|
531
|
+
apiKey: string,
|
|
532
|
+
options: DiscoveryOptions,
|
|
533
|
+
): Promise<{ ok: true; data: T } | { ok: false; status: number }> {
|
|
534
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
535
|
+
const response = await fetch(url, {
|
|
536
|
+
headers: { ...options.headers, Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
537
|
+
signal: options.signal
|
|
538
|
+
? AbortSignal.any([options.signal, AbortSignal.timeout(timeoutMs)])
|
|
539
|
+
: AbortSignal.timeout(timeoutMs),
|
|
540
|
+
});
|
|
541
|
+
if (!response.ok) return { ok: false, status: response.status };
|
|
542
|
+
const data = (await response.json()) as T;
|
|
543
|
+
return { ok: true, data };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const DIAGNOSTIC_ROUTE_SAMPLE = 3;
|
|
547
|
+
|
|
548
|
+
function reportBoundedRoutes(
|
|
549
|
+
reported: Set<string>,
|
|
550
|
+
routes: readonly string[],
|
|
551
|
+
describe: (count: number) => string,
|
|
552
|
+
): void {
|
|
553
|
+
const unreported = [...new Set(routes)].filter((route) => !reported.has(route));
|
|
554
|
+
if (unreported.length === 0) return;
|
|
555
|
+
for (const route of unreported) reported.add(route);
|
|
556
|
+
const hidden = unreported.length - DIAGNOSTIC_ROUTE_SAMPLE;
|
|
557
|
+
const sample = unreported.slice(0, DIAGNOSTIC_ROUTE_SAMPLE).join(", ");
|
|
558
|
+
process.stderr.write(`${describe(unreported.length)}: ${sample}${hidden > 0 ? ` (+${hidden} more)` : ""}\n`);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Reported routes, so a persistent misconfiguration is announced once rather than on
|
|
562
|
+
// every background refresh and every `/model` open. Keyed by route rather than a
|
|
563
|
+
// single flag so a newly ambiguous route is still reported. Mirrors the once-per-
|
|
564
|
+
// process diagnostic set in src/index.ts.
|
|
565
|
+
const reportedAmbiguousRoutes = new Set<string>();
|
|
566
|
+
|
|
567
|
+
// Withholding catalog authority can be invisible in the model name when the router
|
|
568
|
+
// supplies complete prices; limits and other catalog-derived metadata may still use
|
|
569
|
+
// conservative defaults. Report that degradation regardless of
|
|
570
|
+
// LITELLM_VERBOSE_DISCOVERY, carrying only a count and bounded public route ids.
|
|
571
|
+
function reportAmbiguousCatalogAuthority(routes: readonly string[]): void {
|
|
572
|
+
reportBoundedRoutes(
|
|
573
|
+
reportedAmbiguousRoutes,
|
|
574
|
+
routes,
|
|
575
|
+
(count) =>
|
|
576
|
+
`LiteLLM discovery: ${count} route group(s) have missing or conflicting deployment provider evidence; ` +
|
|
577
|
+
"catalog limits, pricing, and reasoning metadata are withheld",
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const reportedIncompatibleModeRoutes = new Set<string>();
|
|
582
|
+
|
|
583
|
+
function reportIncompatibleDeploymentModes(routes: readonly string[]): void {
|
|
584
|
+
reportBoundedRoutes(
|
|
585
|
+
reportedIncompatibleModeRoutes,
|
|
586
|
+
routes,
|
|
587
|
+
(count) =>
|
|
588
|
+
`LiteLLM discovery: ${count} route group(s) mix chat-style and explicitly incompatible deployment modes; ` +
|
|
589
|
+
"the routes are withheld because not every deployment can accept chat requests",
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function mapFromModelInfoGroup(
|
|
594
|
+
entries: readonly ModelInfoEntry[],
|
|
595
|
+
publicCatalog: PublicCatalog | undefined,
|
|
596
|
+
options: {
|
|
597
|
+
ambiguousRoutes?: string[];
|
|
598
|
+
conflictingFamilyRoutes?: string[];
|
|
599
|
+
withheldRepairRoutes?: string[];
|
|
600
|
+
denyLevels?: boolean;
|
|
601
|
+
allowMessages?: boolean;
|
|
602
|
+
} = {},
|
|
603
|
+
): DiscoveredModel | undefined {
|
|
604
|
+
const reduced = reduceModelGroup(entries, (entry) => {
|
|
605
|
+
const catalog = resolveModelInfoCatalog(entry, publicCatalog);
|
|
606
|
+
const family = deploymentFamily(entry);
|
|
607
|
+
const generations = [entry.litellm_params?.model, entry.model_info?.base_model]
|
|
608
|
+
.map((id) => wireString(id)?.trim())
|
|
609
|
+
.filter((id): id is string => Boolean(id))
|
|
610
|
+
.map(semanticModel)
|
|
611
|
+
.filter((model): model is SemanticModel => model !== undefined);
|
|
612
|
+
const model = new Set(generations).size === 1 && family !== "conflicting" ? generations[0] : undefined;
|
|
613
|
+
return {
|
|
614
|
+
...catalog,
|
|
615
|
+
...(options.allowMessages === false ? { messagesCompat: undefined } : {}),
|
|
616
|
+
...(family ? { semanticFamily: family } : {}),
|
|
617
|
+
...(model ? { semanticModel: model } : {}),
|
|
618
|
+
};
|
|
619
|
+
});
|
|
620
|
+
if (!reduced) return undefined;
|
|
621
|
+
if (reduced.catalogAuthorityAmbiguous) options.ambiguousRoutes?.push(reduced.id);
|
|
622
|
+
if (reduced.deploymentFamilies.includes("conflicting")) options.conflictingFamilyRoutes?.push(reduced.id);
|
|
623
|
+
const protocols = entries.map((entry) => modelProtocol(reduced.id, entry));
|
|
624
|
+
const protocol = protocols.find((candidate) => candidate.api === "openai-completions") ?? protocols[0]!;
|
|
625
|
+
const api = reduced.api === "anthropic-messages" ? reduced.api : protocol.api;
|
|
626
|
+
const families = new Set(entries.map((entry) => resolveBackendIdentity({ ...entry, model_name: undefined })?.family));
|
|
627
|
+
const [family] = families;
|
|
628
|
+
const hasBackendEvidence = entries.some(hasReadableBackendEvidence);
|
|
629
|
+
const reasoningPolicy =
|
|
630
|
+
api !== "anthropic-messages" && reduced.semanticModel && hasBackendEvidence ? reduced.reasoningPolicy : undefined;
|
|
631
|
+
const reasoning = reasoningPolicy?.reasoning ?? reduced.reasoning;
|
|
632
|
+
// The semantic policy's compat only applies on Chat, so its level map must be
|
|
633
|
+
// gated the same way. Vendor compatibility is reduced from deployment
|
|
634
|
+
// evidence; route text is consulted only when no deployment identifies a
|
|
635
|
+
// family. This gives vanity Kimi routes the complete strict-schema block while
|
|
636
|
+
// withholding shape-changing fields from mixed or unidentified groups.
|
|
637
|
+
const unlabeled = reduced.deploymentFamilies.every((family) => family === undefined);
|
|
638
|
+
const vendorCompat = unlabeled
|
|
639
|
+
? buildCompat(reduced.id)
|
|
640
|
+
: meetVendorCompat(
|
|
641
|
+
reduced.deploymentFamilies.map((family) =>
|
|
642
|
+
family === undefined ? undefined : completionsCompat(reduced.id, family),
|
|
643
|
+
),
|
|
644
|
+
);
|
|
645
|
+
const policy = closeSerializerPolicy({
|
|
646
|
+
api,
|
|
647
|
+
reasoning,
|
|
648
|
+
vendorCompat: api === "anthropic-messages" ? reduced.messagesCompat : vendorCompat,
|
|
649
|
+
semanticCompat: reduced.acceptsResponsesReasoningControl
|
|
650
|
+
? { ...reasoningPolicy?.compat, supportsReasoningEffort: true }
|
|
651
|
+
: reasoningPolicy?.compat,
|
|
652
|
+
semanticLevels: reasoningPolicy?.thinkingLevelMap,
|
|
653
|
+
catalogLevels: reduced.thinkingLevelMap,
|
|
654
|
+
requireChatCarrier: true,
|
|
655
|
+
// Effort levels require a declared reasoning_effort carrier; public or LiteLLM
|
|
656
|
+
// level evidence alone does not authorize adding an unsupported request field.
|
|
657
|
+
allowInferredChatCarrier: false,
|
|
658
|
+
acceptsResponsesReasoningControl: reduced.acceptsResponsesReasoningControl,
|
|
659
|
+
denyLevels: options.denyLevels,
|
|
660
|
+
});
|
|
661
|
+
const hasConflictingFamily = reduced.deploymentFamilies.includes("conflicting");
|
|
662
|
+
const unanimousMoonshot =
|
|
663
|
+
reduced.deploymentFamilies.length > 0 && reduced.deploymentFamilies.every((family) => family === "kimi");
|
|
664
|
+
const moonshotEvidence =
|
|
665
|
+
!hasConflictingFamily &&
|
|
666
|
+
(reduced.deploymentFamilies.includes("kimi") || (unlabeled && isMoonshotModel(reduced.id)));
|
|
667
|
+
if (moonshotEvidence && !unanimousMoonshot) options.withheldRepairRoutes?.push(reduced.id);
|
|
668
|
+
const modelPolicy = requestPolicy(
|
|
669
|
+
reduced.id,
|
|
670
|
+
reduced.deploymentFamilies,
|
|
671
|
+
reduced.normalizeThinkTags,
|
|
672
|
+
reduced.suppressReasoningVisibility,
|
|
673
|
+
unanimousMoonshot,
|
|
674
|
+
);
|
|
675
|
+
return {
|
|
676
|
+
id: reduced.id,
|
|
677
|
+
// Reduced groups never borrow the ` (no metadata)` sentinel, which authorizes
|
|
678
|
+
// catalog re-derivation from the model id during offline cache reads.
|
|
679
|
+
name: reduced.hasCompleteMetadata ? reduced.id : `${reduced.id} (incomplete metadata)`,
|
|
680
|
+
...policy,
|
|
681
|
+
input: reduced.vision ? ["text", "image"] : ["text"],
|
|
682
|
+
cost: reduced.cost,
|
|
683
|
+
contextWindow: reduced.contextWindow,
|
|
684
|
+
maxTokens: reduced.maxTokens,
|
|
685
|
+
api,
|
|
686
|
+
litellmDiscoveryVersion: LITELLM_DISCOVERY_VERSION,
|
|
687
|
+
...(api === "openai-responses" && reduced.acceptsResponsesReasoningControl
|
|
688
|
+
? { litellmResponsesReasoningControl: true as const }
|
|
689
|
+
: {}),
|
|
690
|
+
...(families.size === 1 && family ? { litellmBackendFamily: family } : {}),
|
|
691
|
+
...(modelPolicy ? { litellmPolicy: modelPolicy } : {}),
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// An evidence-free fallback entry has no deployment or adapter evidence. Its route name
|
|
696
|
+
// authorizes nothing; the Pi catalog entry for its id supplies its protocol and presentation
|
|
697
|
+
// metadata, while an unknown id stays on Chat Completions until /model/info can be read.
|
|
698
|
+
function catalogProtocol(modelId: string, catalogModel: Model<Api> | undefined): ModelProtocol {
|
|
699
|
+
return catalogModel?.api === "openai-responses"
|
|
700
|
+
? { api: "openai-responses", compat: responsesCompat(modelId) }
|
|
701
|
+
: { api: "openai-completions", compat: completionsCompat(modelId) };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function mapFromModelsList(entry: ModelsListEntry): DiscoveredModel | undefined {
|
|
705
|
+
const id = wireString(entry.id);
|
|
706
|
+
if (!id) return undefined;
|
|
707
|
+
const ownedBy = wireString(entry.owned_by);
|
|
708
|
+
const catalogModel = findCatalogModel(id, ownedBy);
|
|
709
|
+
const api = catalogModel?.api === "openai-responses" ? "openai-responses" : "openai-completions";
|
|
710
|
+
return {
|
|
711
|
+
id,
|
|
712
|
+
name: catalogModel?.name ?? `${id} (no metadata)`,
|
|
713
|
+
...closeSerializerPolicy({
|
|
714
|
+
api,
|
|
715
|
+
reasoning: catalogModel?.reasoning ?? false,
|
|
716
|
+
vendorCompat: catalogProtocol(id, catalogModel).compat,
|
|
717
|
+
catalogLevels: catalogModel?.thinkingLevelMap,
|
|
718
|
+
requireChatCarrier: true,
|
|
719
|
+
allowInferredChatCarrier: false,
|
|
720
|
+
}),
|
|
721
|
+
input: catalogModel?.input ?? ["text"],
|
|
722
|
+
cost: catalogModel?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
723
|
+
contextWindow: catalogModel?.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
724
|
+
maxTokens: catalogModel?.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
725
|
+
api,
|
|
726
|
+
litellmDiscoveryVersion: LITELLM_DISCOVERY_VERSION,
|
|
727
|
+
...(isMoonshotModel(id) ? { litellmPolicy: moonshotPolicy(id) } : {}),
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async function discoverFromHealth(
|
|
732
|
+
base: string,
|
|
733
|
+
apiKey: string,
|
|
734
|
+
options: DiscoveryOptions & { onProgress?: (message: string) => void; silent?: boolean },
|
|
735
|
+
): Promise<DiscoveredModel[]> {
|
|
736
|
+
const progress = options.silent ? undefined : options.onProgress;
|
|
737
|
+
progress?.("Querying /health endpoint...");
|
|
738
|
+
const healthResult = await fetchJson<HealthResponse>(`${base}/health`, apiKey, options);
|
|
739
|
+
if (!healthResult.ok) return [];
|
|
740
|
+
const endpoints = (healthResult.data.healthy_endpoints ?? []).filter((entry) =>
|
|
741
|
+
Boolean(wireString(entry.model)?.trim() || wireString(entry.model_id)?.trim()),
|
|
742
|
+
);
|
|
743
|
+
progress?.(`Discovered ${endpoints.length} model endpoints, fetching details...`);
|
|
744
|
+
let completed = 0;
|
|
745
|
+
let next = 0;
|
|
746
|
+
const deployments: (HealthDeployment | undefined)[] = new Array(endpoints.length);
|
|
747
|
+
const worker = async (): Promise<void> => {
|
|
748
|
+
while (next < endpoints.length) {
|
|
749
|
+
const index = next++;
|
|
750
|
+
const endpoint = endpoints[index];
|
|
751
|
+
const route = wireString(endpoint.model)?.trim() || undefined;
|
|
752
|
+
const deploymentId = wireString(endpoint.model_id)?.trim() || undefined;
|
|
753
|
+
let detail: ModelInfoEntry | undefined;
|
|
754
|
+
if (deploymentId) {
|
|
755
|
+
const infoResult = await fetchJson<ModelInfoResponse>(
|
|
756
|
+
`${base}/model/info?litellm_model_id=${encodeURIComponent(deploymentId)}`,
|
|
757
|
+
apiKey,
|
|
758
|
+
options,
|
|
759
|
+
);
|
|
760
|
+
detail = infoResult.ok ? infoResult.data.data?.[0] : undefined;
|
|
761
|
+
}
|
|
762
|
+
completed++;
|
|
763
|
+
if (completed % 10 === 0 || completed === endpoints.length) {
|
|
764
|
+
progress?.(`Fetched ${completed}/${endpoints.length} models...`);
|
|
765
|
+
}
|
|
766
|
+
deployments[index] = healthDeployment(detail, route, deploymentId);
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
await Promise.all(Array.from({ length: Math.min(HEALTH_DETAIL_CONCURRENCY, endpoints.length) }, () => worker()));
|
|
770
|
+
const groups = new Map<string, HealthDeployment[]>();
|
|
771
|
+
for (const deployment of deployments) {
|
|
772
|
+
const route = wireString(deployment?.entry.model_name);
|
|
773
|
+
if (!deployment || !route) continue;
|
|
774
|
+
const group = groups.get(route) ?? [];
|
|
775
|
+
group.push(deployment);
|
|
776
|
+
groups.set(route, group);
|
|
777
|
+
}
|
|
778
|
+
const publicCatalog = deployments.some((deployment) => deployment && !deployment.synthetic)
|
|
779
|
+
? await loadDiscoveryPublicCatalog(options)
|
|
780
|
+
: undefined;
|
|
781
|
+
const incompatibleModeRoutes: string[] = [];
|
|
782
|
+
const ambiguousRoutes: string[] = [];
|
|
783
|
+
const conflictingFamilyRoutes: string[] = [];
|
|
784
|
+
const withheldRepairRoutes: string[] = [];
|
|
785
|
+
const discovered = [...groups.values()]
|
|
786
|
+
.map((group) => {
|
|
787
|
+
const entries = group.map(({ entry }) => entry);
|
|
788
|
+
if (hasMixedIncompatibleDeploymentModes(entries)) {
|
|
789
|
+
const route = wireString(entries[0]?.model_name);
|
|
790
|
+
if (route) incompatibleModeRoutes.push(route);
|
|
791
|
+
}
|
|
792
|
+
if (group.every((deployment) => deployment.synthetic)) return mapFromModelsList({ id: entries[0]!.model_name! });
|
|
793
|
+
return mapFromModelInfoGroup(entries, publicCatalog, {
|
|
794
|
+
ambiguousRoutes,
|
|
795
|
+
conflictingFamilyRoutes,
|
|
796
|
+
withheldRepairRoutes,
|
|
797
|
+
denyLevels: group.some(({ denyLevels }) => denyLevels),
|
|
798
|
+
allowMessages: false,
|
|
799
|
+
});
|
|
800
|
+
})
|
|
801
|
+
.filter((model): model is DiscoveredModel => model !== undefined);
|
|
802
|
+
reportIncompatibleDeploymentModes(incompatibleModeRoutes);
|
|
803
|
+
reportAmbiguousCatalogAuthority(ambiguousRoutes);
|
|
804
|
+
reportConflictingFamilyEvidence(conflictingFamilyRoutes);
|
|
805
|
+
reportWithheldToolRepair(withheldRepairRoutes);
|
|
806
|
+
reportWithheldToolRepairForModels(discovered);
|
|
807
|
+
return discovered;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function deduplicateModels(models: DiscoveredModel[]): DiscoveredModel[] {
|
|
811
|
+
const entries = new Map<string, { model: DiscoveredModel; suppressions: boolean[] }>();
|
|
812
|
+
for (const model of models) {
|
|
813
|
+
const existing = entries.get(model.id);
|
|
814
|
+
if (existing) {
|
|
815
|
+
existing.suppressions.push(model.suppressReasoningContent === true);
|
|
816
|
+
} else {
|
|
817
|
+
entries.set(model.id, { model, suppressions: [model.suppressReasoningContent === true] });
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return [...entries.values()].map(({ model, suppressions }) => {
|
|
821
|
+
const deduplicated = { ...model };
|
|
822
|
+
if (aggregateSuppressionEvidence(suppressions)) deduplicated.suppressReasoningContent = true;
|
|
823
|
+
else delete deduplicated.suppressReasoningContent;
|
|
824
|
+
return deduplicated;
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
export function wildcardMatches(route: string, modelId: string): boolean {
|
|
829
|
+
const segments = route.split("*");
|
|
830
|
+
let offset = 0;
|
|
831
|
+
for (const [index, segment] of segments.entries()) {
|
|
832
|
+
if (segment === "") continue;
|
|
833
|
+
const found = modelId.indexOf(segment, offset);
|
|
834
|
+
if (found < 0 || (index === 0 && found !== 0)) return false;
|
|
835
|
+
offset = found + segment.length;
|
|
836
|
+
}
|
|
837
|
+
const suffix = segments.at(-1);
|
|
838
|
+
return suffix === "" || modelId.endsWith(suffix ?? "");
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// LiteLLM serves a wildcard route by substituting the requested id's wildcard portion into
|
|
842
|
+
// the deployment's own `model` (`azure/*` + `azure/gpt-5.5` → `azure/gpt-5.5`), so the row a
|
|
843
|
+
// child actually runs on names that concrete backend model.
|
|
844
|
+
function resolveWildcardRow(row: ModelInfoEntry, modelId: string): ModelInfoEntry {
|
|
845
|
+
const route = wireString(row.model_name) ?? "";
|
|
846
|
+
const backend = wireString(row.litellm_params?.model);
|
|
847
|
+
if (!route.includes("*") || !backend?.includes("*")) return { ...row, model_name: modelId };
|
|
848
|
+
// Capture each `*` segment of the route against modelId, then substitute those captures
|
|
849
|
+
// positionally into the backend's own wildcards, so a route with 2+ stars (e.g. "team/*-*")
|
|
850
|
+
// resolves every star instead of only the first.
|
|
851
|
+
const escaped = route.split("*").map((segment) => segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
852
|
+
const match = modelId.match(new RegExp(`^${escaped.join("(.*)")}$`));
|
|
853
|
+
if (!match) return { ...row, model_name: modelId };
|
|
854
|
+
const captures = match.slice(1);
|
|
855
|
+
let index = 0;
|
|
856
|
+
return {
|
|
857
|
+
...row,
|
|
858
|
+
model_name: modelId,
|
|
859
|
+
litellm_params: { ...row.litellm_params, model: backend.replace(/\*/g, () => captures[index++] ?? "") },
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function wildcardPatternSpecificity(pattern: string): readonly [number, number] {
|
|
864
|
+
const escaped = [...pattern]
|
|
865
|
+
.map((character) => {
|
|
866
|
+
if (character === "*") return "(.*)";
|
|
867
|
+
return /[()[\]{}?+\-|^$\\.&~#\s]/.test(character) ? `\\${character}` : character;
|
|
868
|
+
})
|
|
869
|
+
.join("");
|
|
870
|
+
const complexity = [...escaped].filter((character) => "*+?\\^$|()".includes(character)).length;
|
|
871
|
+
return [escaped.length, complexity];
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// A concrete id expanded from a wildcard route inherits the deployment evidence from the
|
|
875
|
+
// most-specific matching route, using LiteLLM's pattern order. Rows sharing that route still
|
|
876
|
+
// vote together, so any deployment needing Chat keeps the child on Chat.
|
|
877
|
+
function applyWildcardEvidence(
|
|
878
|
+
model: DiscoveredModel,
|
|
879
|
+
wildcardRows: readonly ModelInfoEntry[],
|
|
880
|
+
publishedWildcardIds: ReadonlySet<string>,
|
|
881
|
+
publicCatalog: PublicCatalog | undefined,
|
|
882
|
+
): DiscoveredModel | undefined {
|
|
883
|
+
const matchingRows = wildcardRows.filter((row) => row.model_name && wildcardMatches(row.model_name, model.id));
|
|
884
|
+
const selectedPattern = matchingRows
|
|
885
|
+
.map((row) => row.model_name!)
|
|
886
|
+
.sort((left, right) => {
|
|
887
|
+
const [leftLength, leftComplexity] = wildcardPatternSpecificity(left);
|
|
888
|
+
const [rightLength, rightComplexity] = wildcardPatternSpecificity(right);
|
|
889
|
+
// Fewer wildcards (lower complexity) is more specific when escaped length ties.
|
|
890
|
+
return rightLength - leftLength || leftComplexity - rightComplexity || left.localeCompare(right);
|
|
891
|
+
})[0];
|
|
892
|
+
if (!selectedPattern || !publishedWildcardIds.has(selectedPattern)) return undefined;
|
|
893
|
+
const parents = matchingRows
|
|
894
|
+
.filter((row) => row.model_name === selectedPattern)
|
|
895
|
+
.map((row) => resolveWildcardRow(row, model.id));
|
|
896
|
+
const selected = mapFromModelInfoGroup(parents, publicCatalog);
|
|
897
|
+
if (!selected) return undefined;
|
|
898
|
+
const policies = [model.litellmPolicy, selected.litellmPolicy].filter((policy) => policy !== undefined);
|
|
899
|
+
const combinedPolicy =
|
|
900
|
+
policies.length > 0
|
|
901
|
+
? {
|
|
902
|
+
normalizeStrictToolMessages: policies.every((policy) => policy.normalizeStrictToolMessages),
|
|
903
|
+
normalizeThinkTags: policies.every((policy) => policy.normalizeThinkTags),
|
|
904
|
+
suppressReasoningVisibility: policies.every((policy) => policy.suppressReasoningVisibility),
|
|
905
|
+
...(policies.every((policy) => policy.normalizeGeminiReasoningEffort)
|
|
906
|
+
? { normalizeGeminiReasoningEffort: true as const }
|
|
907
|
+
: {}),
|
|
908
|
+
}
|
|
909
|
+
: undefined;
|
|
910
|
+
const {
|
|
911
|
+
litellmBackendFamily: _family,
|
|
912
|
+
litellmResponsesReasoningControl: _control,
|
|
913
|
+
litellmPolicy: _policy,
|
|
914
|
+
thinkingLevelMap: _levels,
|
|
915
|
+
...rest
|
|
916
|
+
} = model;
|
|
917
|
+
return {
|
|
918
|
+
...rest,
|
|
919
|
+
api: selected.api,
|
|
920
|
+
reasoning: selected.reasoning,
|
|
921
|
+
compat: selected.compat,
|
|
922
|
+
...(selected.thinkingLevelMap ? { thinkingLevelMap: selected.thinkingLevelMap } : {}),
|
|
923
|
+
...(selected.litellmBackendFamily ? { litellmBackendFamily: selected.litellmBackendFamily } : {}),
|
|
924
|
+
...(combinedPolicy ? { litellmPolicy: combinedPolicy } : {}),
|
|
925
|
+
...(selected.litellmResponsesReasoningControl ? { litellmResponsesReasoningControl: true as const } : {}),
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function mapFromWildcardExpansion(
|
|
930
|
+
entry: ModelsListEntry,
|
|
931
|
+
wildcards: readonly WildcardExpansionSource[],
|
|
932
|
+
withheldRepairRoutes?: string[],
|
|
933
|
+
): DiscoveredModel | undefined {
|
|
934
|
+
const id = wireString(entry.id);
|
|
935
|
+
if (!id || id.includes("*")) return undefined;
|
|
936
|
+
const matchingSources = wildcards.filter(({ model }) => wildcardMatches(model.id, id));
|
|
937
|
+
if (matchingSources.length === 0) return undefined;
|
|
938
|
+
const matches = matchingSources.map(({ model }) => model);
|
|
939
|
+
const api = matches.every((model) => model.api === "openai-responses") ? "openai-responses" : "openai-completions";
|
|
940
|
+
const reasoning = matches.every((model) => model.reasoning);
|
|
941
|
+
const thinkingLevelMap = reasoning
|
|
942
|
+
? intersectThinkingLevelMaps(matches.map((model) => model.thinkingLevelMap))
|
|
943
|
+
: undefined;
|
|
944
|
+
const hasFamilyEvidence = matchingSources.some(({ deploymentFamilies }) =>
|
|
945
|
+
deploymentFamilies.some((family) => family !== undefined),
|
|
946
|
+
);
|
|
947
|
+
const vendorCompat = hasFamilyEvidence ? meetVendorCompat(matches.map((model) => model.compat)) : buildCompat(id);
|
|
948
|
+
const modelPolicies = matches.map((model) => model.litellmPolicy);
|
|
949
|
+
const hasModelPolicy = modelPolicies.some((policy) => policy !== undefined);
|
|
950
|
+
const modelPolicy = hasModelPolicy
|
|
951
|
+
? {
|
|
952
|
+
normalizeStrictToolMessages: modelPolicies.every((policy) => policy?.normalizeStrictToolMessages === true),
|
|
953
|
+
normalizeThinkTags:
|
|
954
|
+
!FORCED_THINKING_MODEL_PATTERN.test(id) &&
|
|
955
|
+
modelPolicies.every((policy) => policy?.normalizeThinkTags === true),
|
|
956
|
+
suppressReasoningVisibility:
|
|
957
|
+
!FORCED_THINKING_MODEL_PATTERN.test(id) &&
|
|
958
|
+
modelPolicies.every((policy) => policy?.suppressReasoningVisibility === true),
|
|
959
|
+
...(modelPolicies.every((policy) => policy?.normalizeGeminiReasoningEffort === true)
|
|
960
|
+
? { normalizeGeminiReasoningEffort: true as const }
|
|
961
|
+
: {}),
|
|
962
|
+
}
|
|
963
|
+
: !hasFamilyEvidence && isMoonshotModel(id)
|
|
964
|
+
? moonshotPolicy(id)
|
|
965
|
+
: undefined;
|
|
966
|
+
const policy = closeSerializerPolicy({
|
|
967
|
+
api,
|
|
968
|
+
reasoning,
|
|
969
|
+
vendorCompat,
|
|
970
|
+
catalogLevels: thinkingLevelMap,
|
|
971
|
+
requireChatCarrier: true,
|
|
972
|
+
allowInferredChatCarrier: false,
|
|
973
|
+
acceptsResponsesReasoningControl:
|
|
974
|
+
api === "openai-responses" && matches.every((model) => model.litellmResponsesReasoningControl === true),
|
|
975
|
+
});
|
|
976
|
+
const hasKimiEvidence = matchingSources.some(({ deploymentFamilies }) => deploymentFamilies.includes("kimi"));
|
|
977
|
+
const routeOnlyMoonshot = !hasFamilyEvidence && isMoonshotModel(id);
|
|
978
|
+
if (modelPolicy?.normalizeStrictToolMessages === false && (hasKimiEvidence || routeOnlyMoonshot)) {
|
|
979
|
+
withheldRepairRoutes?.push(id);
|
|
980
|
+
}
|
|
981
|
+
// A concrete /v1/models id may enrich presentation only. It never contributes
|
|
982
|
+
// reasoning levels, compatibility carriers, or request policy to the child.
|
|
983
|
+
const catalogModel = findCatalogModel(id, wireString(entry.owned_by));
|
|
984
|
+
const parentIncomplete = matches.some((model) => model.name.endsWith(" (incomplete metadata)"));
|
|
985
|
+
const base = catalogModel?.name ?? id;
|
|
986
|
+
// Preserve every known tier even when a sibling has incomplete metadata. Omitting
|
|
987
|
+
// a complete sibling's higher tier would understate the known worst-case rate;
|
|
988
|
+
// the incomplete marker continues to signal that the resulting envelope is partial.
|
|
989
|
+
const tiers = conservativeCostTiers(matches.map((model) => model.cost));
|
|
990
|
+
return {
|
|
991
|
+
id,
|
|
992
|
+
name: parentIncomplete ? `${base} (incomplete metadata)` : base,
|
|
993
|
+
...policy,
|
|
994
|
+
input: matches.every((model) => model.input.includes("image")) ? ["text", "image"] : ["text"],
|
|
995
|
+
cost: {
|
|
996
|
+
input: Math.max(...matches.map((model) => model.cost.input)),
|
|
997
|
+
output: Math.max(...matches.map((model) => model.cost.output)),
|
|
998
|
+
cacheRead: Math.max(...matches.map((model) => model.cost.cacheRead)),
|
|
999
|
+
cacheWrite: Math.max(...matches.map((model) => model.cost.cacheWrite)),
|
|
1000
|
+
...(tiers ? { tiers } : {}),
|
|
1001
|
+
},
|
|
1002
|
+
contextWindow: Math.min(...matches.map((model) => model.contextWindow)),
|
|
1003
|
+
maxTokens: Math.min(...matches.map((model) => model.maxTokens)),
|
|
1004
|
+
api,
|
|
1005
|
+
litellmDiscoveryVersion: LITELLM_DISCOVERY_VERSION,
|
|
1006
|
+
...(api === "openai-responses" && matches.every((model) => model.litellmResponsesReasoningControl === true)
|
|
1007
|
+
? { litellmResponsesReasoningControl: true as const }
|
|
1008
|
+
: {}),
|
|
1009
|
+
...(modelPolicy ? { litellmPolicy: modelPolicy } : {}),
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
export async function discoverModels(
|
|
1014
|
+
baseUrl: string,
|
|
1015
|
+
apiKey: string,
|
|
1016
|
+
options: DiscoveryOptions & { onProgress?: (message: string) => void; silent?: boolean } = {},
|
|
1017
|
+
): Promise<DiscoveryResult> {
|
|
1018
|
+
const base = normalizeBaseUrl(baseUrl, options.allowInsecureHttp);
|
|
1019
|
+
const progress = options.silent ? undefined : options.onProgress;
|
|
1020
|
+
progress?.("Querying /model/info endpoint...");
|
|
1021
|
+
const infoResult = await fetchJson<ModelInfoResponse>(`${base}/model/info`, apiKey, options);
|
|
1022
|
+
if (infoResult.ok) {
|
|
1023
|
+
const groups = new Map<string, ModelInfoEntry[]>();
|
|
1024
|
+
for (const entry of infoResult.data.data ?? []) {
|
|
1025
|
+
// A route without a readable public name cannot be grouped or addressed.
|
|
1026
|
+
const route = wireString(entry.model_name);
|
|
1027
|
+
if (!route) continue;
|
|
1028
|
+
const group = groups.get(route) ?? [];
|
|
1029
|
+
group.push(entry);
|
|
1030
|
+
groups.set(route, group);
|
|
1031
|
+
}
|
|
1032
|
+
const publicCatalog = await loadDiscoveryPublicCatalog(options);
|
|
1033
|
+
const ambiguousRoutes: string[] = [];
|
|
1034
|
+
const incompatibleModeRoutes: string[] = [];
|
|
1035
|
+
const conflictingFamilyRoutes: string[] = [];
|
|
1036
|
+
const withheldRepairRoutes: string[] = [];
|
|
1037
|
+
const reducedGroups = [...groups.entries()].map(([route, group]) => {
|
|
1038
|
+
if (hasMixedIncompatibleDeploymentModes(group)) incompatibleModeRoutes.push(route);
|
|
1039
|
+
return {
|
|
1040
|
+
route,
|
|
1041
|
+
model: mapFromModelInfoGroup(group, publicCatalog, {
|
|
1042
|
+
ambiguousRoutes,
|
|
1043
|
+
conflictingFamilyRoutes,
|
|
1044
|
+
withheldRepairRoutes,
|
|
1045
|
+
}),
|
|
1046
|
+
deploymentFamilies: group.map(deploymentFamily),
|
|
1047
|
+
};
|
|
1048
|
+
});
|
|
1049
|
+
let models = reducedGroups
|
|
1050
|
+
.map(({ model }) => model)
|
|
1051
|
+
.filter((model): model is DiscoveredModel => model !== undefined);
|
|
1052
|
+
reportIncompatibleDeploymentModes(incompatibleModeRoutes);
|
|
1053
|
+
reportAmbiguousCatalogAuthority(ambiguousRoutes);
|
|
1054
|
+
reportConflictingFamilyEvidence(conflictingFamilyRoutes);
|
|
1055
|
+
reportWithheldToolRepair(withheldRepairRoutes);
|
|
1056
|
+
// LiteLLM's /model/info does NOT expand wildcard model_name entries (e.g.
|
|
1057
|
+
// "lemonade/*" backed by model: openai/* + check_provider_endpoint: true)
|
|
1058
|
+
// — it returns the literal wildcard only. The discovered ids live in
|
|
1059
|
+
// /v1/models instead. When /model/info contains any wildcard id, also query
|
|
1060
|
+
// /v1/models and merge the expanded (non-wildcard) entries in, dropping the
|
|
1061
|
+
// raw wildcard row so it doesn't surface as a phantom model choice.
|
|
1062
|
+
// Ref: docs.litellm.ai/docs/proxy/model_discovery
|
|
1063
|
+
const wildcardRoutes = reducedGroups.filter(({ route }) => route.includes("*"));
|
|
1064
|
+
if (wildcardRoutes.length > 0) {
|
|
1065
|
+
const wildcards = wildcardRoutes
|
|
1066
|
+
.filter((source): source is typeof source & { model: DiscoveredModel } => source.model !== undefined)
|
|
1067
|
+
.map(({ model, deploymentFamilies }) => ({ model, deploymentFamilies }));
|
|
1068
|
+
const droppedRoutes = reducedGroups.filter(({ model }) => model === undefined).map(({ route }) => route);
|
|
1069
|
+
const wildcardRows = wildcardRoutes.flatMap(({ route }) => groups.get(route)!);
|
|
1070
|
+
const publishedWildcardIds = new Set(wildcards.map(({ model }) => model.id));
|
|
1071
|
+
// Exact exclusions are bounded to the same public id: `/v1/models` lacks
|
|
1072
|
+
// deployment identity, so a differently named id for that deployment is unknowable.
|
|
1073
|
+
const droppedExactIds = new Set(droppedRoutes.filter((route) => !route.includes("*")));
|
|
1074
|
+
const droppedWildcards = droppedRoutes.filter((route) => route.includes("*"));
|
|
1075
|
+
// A wildcard row is not addressable. Remove it before expansion so a failed
|
|
1076
|
+
// `/v1/models` request cannot leak the literal wildcard into the selector.
|
|
1077
|
+
models = models.filter((model) => !model.id.includes("*"));
|
|
1078
|
+
progress?.("/model/info has wildcard entries, expanding via /v1/models...");
|
|
1079
|
+
const listResult = await fetchJson<ModelsListResponse>(`${base}/v1/models`, apiKey, options);
|
|
1080
|
+
if (listResult.ok && wildcards.length > 0) {
|
|
1081
|
+
const seen = new Set(models.map((model) => model.id));
|
|
1082
|
+
const expanded = (listResult.data.data ?? [])
|
|
1083
|
+
.filter((entry) => {
|
|
1084
|
+
const id = wireString(entry.id);
|
|
1085
|
+
return (
|
|
1086
|
+
id === undefined ||
|
|
1087
|
+
(!seen.has(id) &&
|
|
1088
|
+
!droppedExactIds.has(id) &&
|
|
1089
|
+
!droppedWildcards.some((route) => wildcardMatches(route, id)))
|
|
1090
|
+
);
|
|
1091
|
+
})
|
|
1092
|
+
.map((entry) => mapFromWildcardExpansion(entry, wildcards, withheldRepairRoutes))
|
|
1093
|
+
.filter((model): model is DiscoveredModel => model !== undefined)
|
|
1094
|
+
.map((model) => applyWildcardEvidence(model, wildcardRows, publishedWildcardIds, publicCatalog))
|
|
1095
|
+
.filter((model): model is DiscoveredModel => model !== undefined);
|
|
1096
|
+
models = [...models, ...expanded];
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
reportWithheldToolRepair(withheldRepairRoutes);
|
|
1100
|
+
return { source: "model_info", models: deduplicateModels(models) };
|
|
1101
|
+
}
|
|
1102
|
+
if (![401, 403, 404].includes(infoResult.status)) {
|
|
1103
|
+
throw new Error(`/model/info returned ${infoResult.status}`);
|
|
1104
|
+
}
|
|
1105
|
+
progress?.("/model/info unavailable, trying /v1/models...");
|
|
1106
|
+
const listResult = await fetchJson<ModelsListResponse>(`${base}/v1/models`, apiKey, options);
|
|
1107
|
+
if (!listResult.ok) {
|
|
1108
|
+
if ([401, 403, 404].includes(listResult.status)) {
|
|
1109
|
+
progress?.("/v1/models unavailable, falling back to /health endpoint...");
|
|
1110
|
+
const models = await discoverFromHealth(base, apiKey, options);
|
|
1111
|
+
if (models.length > 0) return { source: "health", models: deduplicateModels(models) };
|
|
1112
|
+
}
|
|
1113
|
+
throw new Error(`/v1/models returned ${listResult.status}`);
|
|
1114
|
+
}
|
|
1115
|
+
const models = (listResult.data.data ?? [])
|
|
1116
|
+
.map(mapFromModelsList)
|
|
1117
|
+
.filter((m): m is DiscoveredModel => m !== undefined);
|
|
1118
|
+
return { source: "models_list", models: deduplicateModels(models) };
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
export function moonshotPolicy(modelId: string, strictToolRepair = false): LiteLLMModelPolicy {
|
|
1122
|
+
return {
|
|
1123
|
+
normalizeStrictToolMessages: strictToolRepair,
|
|
1124
|
+
normalizeThinkTags: !FORCED_THINKING_MODEL_PATTERN.test(modelId),
|
|
1125
|
+
// Route-only fallback cannot authorize request-side reasoning suppression.
|
|
1126
|
+
suppressReasoningVisibility: false,
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function requestPolicy(
|
|
1131
|
+
modelId: string,
|
|
1132
|
+
deploymentFamilies: readonly CatalogResolution["semanticFamily"][],
|
|
1133
|
+
normalizeThinkTags: boolean,
|
|
1134
|
+
suppressReasoningVisibility: boolean,
|
|
1135
|
+
strictToolRepair: boolean,
|
|
1136
|
+
): LiteLLMModelPolicy | undefined {
|
|
1137
|
+
// A contradiction within one deployment is evidence against applying any
|
|
1138
|
+
// family-specific request policy; do not discard it like a missing label.
|
|
1139
|
+
if (deploymentFamilies.includes("conflicting")) return undefined;
|
|
1140
|
+
const evidenced = deploymentFamilies.filter((family) => family !== undefined);
|
|
1141
|
+
if (evidenced.length === deploymentFamilies.length && evidenced.every((family) => family === "gemini")) {
|
|
1142
|
+
return {
|
|
1143
|
+
normalizeStrictToolMessages: false,
|
|
1144
|
+
normalizeThinkTags: false,
|
|
1145
|
+
suppressReasoningVisibility: false,
|
|
1146
|
+
normalizeGeminiReasoningEffort: true,
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
if (evidenced.includes("kimi")) {
|
|
1150
|
+
return {
|
|
1151
|
+
normalizeStrictToolMessages: strictToolRepair,
|
|
1152
|
+
normalizeThinkTags,
|
|
1153
|
+
suppressReasoningVisibility,
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
if (evidenced.length === 0 && isMoonshotModel(modelId)) return moonshotPolicy(modelId);
|
|
1157
|
+
return undefined;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function hasMoonshotCompatEvidence(compat: Model<Api>["compat"]): boolean {
|
|
1161
|
+
const openAICompat = compat as Model<"openai-completions">["compat"];
|
|
1162
|
+
return openAICompat?.maxTokensField === "max_tokens" && openAICompat.supportsStrictMode === false;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
export function restoreCachedModelPolicy(model: Model<Api>): Model<Api> {
|
|
1166
|
+
const cached = model as Model<Api> & { litellmPolicy?: LiteLLMModelPolicy };
|
|
1167
|
+
if (cached.litellmPolicy || !hasMoonshotCompatEvidence(model.compat)) return model;
|
|
1168
|
+
const restored: typeof cached = { ...cached, litellmPolicy: moonshotPolicy(model.id) };
|
|
1169
|
+
return restored;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function hasResponsesReasoningControl(model: Model<Api>): boolean {
|
|
1173
|
+
return (
|
|
1174
|
+
(model as Model<Api> & Pick<DiscoveredModel, "litellmResponsesReasoningControl">)
|
|
1175
|
+
.litellmResponsesReasoningControl === true
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function semanticModel(id: string): SemanticModel | undefined {
|
|
1180
|
+
const value = resolveBackendIdentity({ litellm_params: { model: id } })?.modelId.toLowerCase() ?? "";
|
|
1181
|
+
if (/(?:^|[./_-])kimi[-_/]?k?2[._-]?[56](?:$|[./_:-])/.test(value)) return "kimi-k2.5-k2.6";
|
|
1182
|
+
if (/(?:^|[./_-])kimi[-_/]?k?2[._-]?7[./_-]?(?:code|highspeed)(?:$|[./_:-])/.test(value)) {
|
|
1183
|
+
return "kimi-k2.7-code";
|
|
1184
|
+
}
|
|
1185
|
+
if (/(?:^|[./_-])kimi[-_/]?k?3(?:$|[./_:-])/.test(value)) return "kimi-k3";
|
|
1186
|
+
if (/(?:^|[./_-])deepseek[-_/]?v?4(?:$|[./_:-])/.test(value)) return "deepseek-v4";
|
|
1187
|
+
return undefined;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function semanticFamily(id: string): SemanticFamily | undefined {
|
|
1191
|
+
return resolveBackendIdentity({ litellm_params: { model: id } })?.family;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function deploymentFamily(entry: ModelInfoEntry): CatalogResolution["semanticFamily"] {
|
|
1195
|
+
if (
|
|
1196
|
+
(wireString(entry.litellm_params?.model) || wireString(entry.model_info?.base_model)) &&
|
|
1197
|
+
!resolveBackendIdentity({ ...entry, model_name: undefined })
|
|
1198
|
+
)
|
|
1199
|
+
return "conflicting";
|
|
1200
|
+
const identityFamilies = [
|
|
1201
|
+
entry.litellm_params?.model,
|
|
1202
|
+
entry.litellm_params?.custom_llm_provider,
|
|
1203
|
+
entry.model_info?.base_model,
|
|
1204
|
+
]
|
|
1205
|
+
.map((candidate) => wireString(candidate)?.trim())
|
|
1206
|
+
.filter((candidate): candidate is string => Boolean(candidate))
|
|
1207
|
+
.map(semanticFamily)
|
|
1208
|
+
.filter((family): family is SemanticFamily => family !== undefined);
|
|
1209
|
+
const distinctIdentityFamilies = new Set(identityFamilies);
|
|
1210
|
+
if (distinctIdentityFamilies.size > 1) return "conflicting";
|
|
1211
|
+
|
|
1212
|
+
const identityFamily = identityFamilies[0];
|
|
1213
|
+
const adapter = wireString(entry.model_info?.litellm_provider)?.trim().toLowerCase();
|
|
1214
|
+
const adapterFamily = adapter
|
|
1215
|
+
? GENERIC_TRANSPORT_ADAPTERS.has(adapter)
|
|
1216
|
+
? "openai"
|
|
1217
|
+
: semanticFamily(adapter)
|
|
1218
|
+
: undefined;
|
|
1219
|
+
if (!identityFamily) return adapterFamily;
|
|
1220
|
+
// OpenAI-compatible and Azure adapters describe transport, not the backend
|
|
1221
|
+
// vendor. They remain useful fallback evidence for opaque identities, but must
|
|
1222
|
+
// not contradict a model/base identity such as `openai/kimi-k2.5`.
|
|
1223
|
+
if (!adapterFamily || (adapter && GENERIC_TRANSPORT_ADAPTERS.has(adapter))) return identityFamily;
|
|
1224
|
+
return adapterFamily === identityFamily ? identityFamily : "conflicting";
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function reportConflictingFamilyEvidence(routes: readonly string[]): void {
|
|
1228
|
+
reportBoundedRoutes(
|
|
1229
|
+
reportedConflictingFamilyRoutes,
|
|
1230
|
+
routes,
|
|
1231
|
+
(count) =>
|
|
1232
|
+
`LiteLLM discovery: ${count} route group(s) have conflicting deployment family evidence; ` +
|
|
1233
|
+
"family-specific compatibility and request policy are withheld",
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function reportWithheldToolRepair(routes: readonly string[]): void {
|
|
1238
|
+
reportBoundedRoutes(
|
|
1239
|
+
reportedWithheldRepairRoutes,
|
|
1240
|
+
routes,
|
|
1241
|
+
(count) =>
|
|
1242
|
+
`LiteLLM discovery: ${count} route group(s) look Moonshot-backed but not every deployment evidences it; ` +
|
|
1243
|
+
"strict tool-message repair is withheld because it rewrites outbound messages and is unproven for a " +
|
|
1244
|
+
"deployment that has not identified its backend. Moonshot tool calls on these routes may fail until every " +
|
|
1245
|
+
"deployment declares its backend",
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
function reportWithheldToolRepairForModels(models: readonly DiscoveredModel[]): void {
|
|
1250
|
+
reportWithheldToolRepair(
|
|
1251
|
+
models
|
|
1252
|
+
.filter(
|
|
1253
|
+
(model) =>
|
|
1254
|
+
model.litellmPolicy?.normalizeStrictToolMessages === false &&
|
|
1255
|
+
model.litellmPolicy.normalizeGeminiReasoningEffort !== true,
|
|
1256
|
+
)
|
|
1257
|
+
.map((model) => model.id),
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function hasReadableBackendEvidence(entry: ModelInfoEntry): boolean {
|
|
1262
|
+
return [
|
|
1263
|
+
entry.litellm_params?.model,
|
|
1264
|
+
entry.litellm_params?.custom_llm_provider,
|
|
1265
|
+
entry.model_info?.base_model,
|
|
1266
|
+
entry.model_info?.litellm_provider,
|
|
1267
|
+
].some((candidate) => Boolean(wireString(candidate)?.trim()));
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function healthDeployment(
|
|
1271
|
+
detail: ModelInfoEntry | undefined,
|
|
1272
|
+
fallbackRoute: string | undefined,
|
|
1273
|
+
deploymentId: string | undefined,
|
|
1274
|
+
): HealthDeployment | undefined {
|
|
1275
|
+
const detailRoute = wireString(detail?.model_name)?.trim() || undefined;
|
|
1276
|
+
// /health exposes backend litellm_params.model values. Correlated detail owns
|
|
1277
|
+
// the public model_name; the health value is only a fallback when detail lacks it.
|
|
1278
|
+
const route = detailRoute || fallbackRoute?.trim();
|
|
1279
|
+
if (!route) return undefined;
|
|
1280
|
+
if (!detail) {
|
|
1281
|
+
return {
|
|
1282
|
+
entry: {
|
|
1283
|
+
model_name: route,
|
|
1284
|
+
model_info: {
|
|
1285
|
+
...(deploymentId ? { id: deploymentId } : {}),
|
|
1286
|
+
mode: findCatalogModel(route)?.api === "openai-responses" ? "responses" : "chat",
|
|
1287
|
+
},
|
|
1288
|
+
},
|
|
1289
|
+
// The route name authorizes nothing but transport, which the Pi catalog
|
|
1290
|
+
// supplies for this evidence-free entry. Levels stay denied and no catalog
|
|
1291
|
+
// metadata is granted.
|
|
1292
|
+
denyLevels: true,
|
|
1293
|
+
synthetic: true,
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
return {
|
|
1297
|
+
entry: {
|
|
1298
|
+
...detail,
|
|
1299
|
+
model_name: route,
|
|
1300
|
+
model_info: {
|
|
1301
|
+
...detail.model_info,
|
|
1302
|
+
...(wireString(detail.model_info?.id)?.trim() ? {} : deploymentId ? { id: deploymentId } : {}),
|
|
1303
|
+
},
|
|
1304
|
+
},
|
|
1305
|
+
// Detail without a public route cannot authorize selectable levels.
|
|
1306
|
+
denyLevels: detailRoute === undefined,
|
|
1307
|
+
synthetic: false,
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function aggregateSuppressionEvidence(evidence: Iterable<boolean>): boolean {
|
|
1312
|
+
let hasEvidence = false;
|
|
1313
|
+
for (const suppress of evidence) {
|
|
1314
|
+
hasEvidence = true;
|
|
1315
|
+
if (!suppress) return false;
|
|
1316
|
+
}
|
|
1317
|
+
return hasEvidence;
|
|
1318
|
+
}
|