pi-provider-litellm 3.1.0 → 3.3.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 +72 -14
- package/package.json +6 -6
- package/src/backend-identity.ts +31 -5
- package/src/discover.ts +220 -56
- package/src/index.ts +721 -197
- package/src/mcp-tools.ts +225 -30
- package/src/model-groups.ts +44 -13
- package/src/provider.ts +27 -12
- package/src/proxy-version.ts +68 -0
- package/src/public-catalog.ts +15 -8
- package/src/skills.ts +33 -76
- package/src/types.ts +16 -0
package/src/discover.ts
CHANGED
|
@@ -2,15 +2,21 @@ import { isIP } from "node:net";
|
|
|
2
2
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
3
3
|
import { getModels, getProviders } from "@earendil-works/pi-ai/compat";
|
|
4
4
|
import type { BuiltinProvider } from "@earendil-works/pi-ai/providers/all";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
LITELLM_DISCOVERY_VERSION,
|
|
7
|
+
resolveBackendIdentity,
|
|
8
|
+
resolveCatalogProvider,
|
|
9
|
+
routesOnlyThrough,
|
|
10
|
+
} from "./backend-identity.js";
|
|
6
11
|
import {
|
|
7
12
|
type CatalogResolution,
|
|
8
13
|
catalogResolution,
|
|
9
14
|
closeSerializerPolicy,
|
|
10
15
|
conservativeCostTiers,
|
|
11
|
-
DEFAULT_CONTEXT_WINDOW,
|
|
12
16
|
DEFAULT_MAX_TOKENS,
|
|
17
|
+
defaultContextWindow,
|
|
13
18
|
hasMixedIncompatibleDeploymentModes,
|
|
19
|
+
isFallbackContextWindow,
|
|
14
20
|
type MessagesBackendCompat,
|
|
15
21
|
meetVendorCompat,
|
|
16
22
|
normalizedMode,
|
|
@@ -19,6 +25,7 @@ import {
|
|
|
19
25
|
type SemanticModel,
|
|
20
26
|
wireString,
|
|
21
27
|
} from "./model-groups.js";
|
|
28
|
+
import { type ProxyVersion, type ProxyVersionFloor, probeProxyVersion, proxyVersionAtLeast } from "./proxy-version.js";
|
|
22
29
|
import { loadPublicCatalog, type PublicCatalog, type PublicCatalogRecord } from "./public-catalog.js";
|
|
23
30
|
import { intersectThinkingLevelMaps } from "./thinking-levels.js";
|
|
24
31
|
import type {
|
|
@@ -27,6 +34,7 @@ import type {
|
|
|
27
34
|
DiscoveryOptions,
|
|
28
35
|
DiscoveryResult,
|
|
29
36
|
HealthResponse,
|
|
37
|
+
LiteLLMModel,
|
|
30
38
|
LiteLLMModelPolicy,
|
|
31
39
|
ModelInfoEntry,
|
|
32
40
|
ModelInfoResponse,
|
|
@@ -39,6 +47,7 @@ const DEFAULT_TIMEOUT_MS = 5000;
|
|
|
39
47
|
const HEALTH_DETAIL_CONCURRENCY = 8;
|
|
40
48
|
const reportedConflictingFamilyRoutes = new Set<string>();
|
|
41
49
|
const reportedWithheldRepairRoutes = new Set<string>();
|
|
50
|
+
const reportedDefaultedContextRoutes = new Set<string>();
|
|
42
51
|
interface HealthDeployment {
|
|
43
52
|
entry: ModelInfoEntry;
|
|
44
53
|
denyLevels: boolean;
|
|
@@ -132,10 +141,43 @@ export function completionsCompat(
|
|
|
132
141
|
return { supportsStore: false };
|
|
133
142
|
}
|
|
134
143
|
|
|
135
|
-
|
|
144
|
+
// Before v1.103.0 LiteLLM has no native Responses config for the `azure_ai`
|
|
145
|
+
// provider, so every azure_ai deployment reaches `/v1/responses` through the
|
|
146
|
+
// Chat Completions bridge. That bridge crashes on Azure's empty-`choices` stream
|
|
147
|
+
// chunks with "list index out of range" (BerriAI/litellm#34455), while the same
|
|
148
|
+
// deployment streams fine on `/v1/chat/completions`. v1.103.0 guards the bridge
|
|
149
|
+
// and serves azure_ai natively where the host allows it.
|
|
150
|
+
const AZURE_AI_RESPONSES_FLOOR: ProxyVersionFloor = [1, 103, 0];
|
|
151
|
+
|
|
152
|
+
function isAzureAiDeployment(entry: ModelInfoEntry): boolean {
|
|
153
|
+
const adapter = wireString(entry.litellm_params?.custom_llm_provider)?.trim().toLowerCase();
|
|
154
|
+
const configuredModel = wireString(entry.litellm_params?.model)?.trim().toLowerCase();
|
|
155
|
+
return adapter === "azure_ai" || /^azure_ai\//.test(configuredModel ?? "");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function listsResponsesEndpoint(entry: ModelInfoEntry): boolean | undefined {
|
|
136
159
|
const endpoints = entry.model_info?.supported_endpoints;
|
|
137
|
-
|
|
160
|
+
return Array.isArray(endpoints) ? endpoints.some((endpoint) => endpoint === "/v1/responses") : undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// An azure_ai `supported_endpoints` list is copied from the model cost map and
|
|
164
|
+
// describes the model, not the transport LiteLLM uses, so on its own it cannot
|
|
165
|
+
// authorize Responses. These are the only deployments whose transport the proxy
|
|
166
|
+
// version decides; discovery asks for the version only when one is published.
|
|
167
|
+
function transportAwaitsProxyVersion(entry: ModelInfoEntry): boolean {
|
|
168
|
+
return (
|
|
169
|
+
isAzureAiDeployment(entry) &&
|
|
170
|
+
listsResponsesEndpoint(entry) === true &&
|
|
171
|
+
normalizedMode(entry.model_info?.mode) !== "responses"
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function supportsResponses(entry: ModelInfoEntry, proxyVersion?: ProxyVersion): boolean {
|
|
176
|
+
const listsResponses = listsResponsesEndpoint(entry);
|
|
177
|
+
if (listsResponses === false) return false;
|
|
138
178
|
if (normalizedMode(entry.model_info?.mode) === "responses") return true;
|
|
179
|
+
if (transportAwaitsProxyVersion(entry) && !proxyVersionAtLeast(proxyVersion, AZURE_AI_RESPONSES_FLOOR)) return false;
|
|
180
|
+
if (listsResponses === true) return true;
|
|
139
181
|
|
|
140
182
|
const identity = resolveBackendIdentity(entry);
|
|
141
183
|
if (identity?.family !== "openai") return false;
|
|
@@ -148,24 +190,23 @@ function supportsResponses(entry: ModelInfoEntry): boolean {
|
|
|
148
190
|
/^azure(?:_ai)?\//.test(configuredModel ?? "") ||
|
|
149
191
|
reportedProvider === "azure" ||
|
|
150
192
|
reportedProvider === "azure_ai";
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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";
|
|
193
|
+
// An Azure API version describes the API surface, not whether this deployment
|
|
194
|
+
// serves Responses. Require the explicit mode/endpoint evidence above instead
|
|
195
|
+
// of promoting a working Chat route to an unavailable endpoint.
|
|
196
|
+
// Generic adapters remain eligible for LiteLLM's Responses-to-Chat bridge.
|
|
197
|
+
return !azureAdapter;
|
|
161
198
|
}
|
|
162
199
|
|
|
163
|
-
export function modelProtocol(
|
|
200
|
+
export function modelProtocol(
|
|
201
|
+
modelId: string,
|
|
202
|
+
modeOrEntry?: string | null | ModelInfoEntry,
|
|
203
|
+
proxyVersion?: ProxyVersion,
|
|
204
|
+
): ModelProtocol {
|
|
164
205
|
const selectedEntry =
|
|
165
206
|
typeof modeOrEntry === "object" && modeOrEntry !== null
|
|
166
207
|
? { ...modeOrEntry, model_name: undefined }
|
|
167
208
|
: { model_name: modelId, model_info: { mode: modeOrEntry } };
|
|
168
|
-
return supportsResponses(selectedEntry)
|
|
209
|
+
return supportsResponses(selectedEntry, proxyVersion)
|
|
169
210
|
? { api: "openai-responses", compat: responsesCompat(modelId) }
|
|
170
211
|
: { api: "openai-completions", compat: completionsCompat(modelId) };
|
|
171
212
|
}
|
|
@@ -231,7 +272,9 @@ export function enrichCachedModel(input: Model<Api>): Model<Api> {
|
|
|
231
272
|
}),
|
|
232
273
|
} as Model<Api>;
|
|
233
274
|
// Reduced deployment groups use a distinct marker; this sentinel remains
|
|
234
|
-
// exclusive to evidence-free fallback models that may be enriched safely.
|
|
275
|
+
// exclusive to evidence-free fallback models that may be enriched safely. A window is
|
|
276
|
+
// fallback evidence when it matches either default, so a cached entry still qualifies
|
|
277
|
+
// after an operator configures LITELLM_DEFAULT_CONTEXT_WINDOW.
|
|
235
278
|
if (
|
|
236
279
|
!model.name.endsWith(" (no metadata)") ||
|
|
237
280
|
model.reasoning ||
|
|
@@ -243,13 +286,15 @@ export function enrichCachedModel(input: Model<Api>): Model<Api> {
|
|
|
243
286
|
model.cost.cacheRead !== 0 ||
|
|
244
287
|
model.cost.cacheWrite !== 0 ||
|
|
245
288
|
model.cost.tiers !== undefined ||
|
|
246
|
-
model.contextWindow
|
|
289
|
+
!isFallbackContextWindow(model.contextWindow) ||
|
|
247
290
|
model.maxTokens !== DEFAULT_MAX_TOKENS
|
|
248
291
|
) {
|
|
249
292
|
return model;
|
|
250
293
|
}
|
|
251
294
|
const catalogModel = findCatalogModel(model.id);
|
|
252
|
-
|
|
295
|
+
// The gate above proved this window is an assumption, so it is re-derived rather than
|
|
296
|
+
// restored: the cache is seeded offline, where discovery never re-reads the setting.
|
|
297
|
+
if (!catalogModel) return { ...model, contextWindow: defaultContextWindow() };
|
|
253
298
|
// Match evidence-free discovery: only an explicit Responses catalog transport
|
|
254
299
|
// changes the protocol; all other catalog APIs continue through Chat.
|
|
255
300
|
const api = catalogModel.api === "openai-responses" ? "openai-responses" : "openai-completions";
|
|
@@ -323,10 +368,20 @@ function messagesCompatOf(model: Model<Api>): MessagesBackendCompat | undefined
|
|
|
323
368
|
const carried: MessagesBackendCompat = {};
|
|
324
369
|
if (compat?.forceAdaptiveThinking !== undefined) carried.forceAdaptiveThinking = compat.forceAdaptiveThinking;
|
|
325
370
|
if (compat?.supportsTemperature !== undefined) carried.supportsTemperature = compat.supportsTemperature;
|
|
326
|
-
if (compat?.supportsStrictTools !== undefined) carried.supportsStrictTools = compat.supportsStrictTools;
|
|
327
371
|
return carried;
|
|
328
372
|
}
|
|
329
373
|
|
|
374
|
+
const ANTHROPIC_ROUTES: ReadonlySet<string> = new Set(["anthropic"]);
|
|
375
|
+
|
|
376
|
+
function messagesStrictToolEvidence(entry: ModelInfoEntry, model: Model<Api>): boolean | undefined {
|
|
377
|
+
// The Anthropic catalog describes the Anthropic API, so only deployments whose
|
|
378
|
+
// declared routing names Anthropic keep its grant. A hosted route (Bedrock,
|
|
379
|
+
// Vertex) can reject tools[].strict for the same model, and LiteLLM reports no
|
|
380
|
+
// per-route strict-tool capability, so it stays unknown.
|
|
381
|
+
if (!routesOnlyThrough(entry, ANTHROPIC_ROUTES) || model.api !== "anthropic-messages") return undefined;
|
|
382
|
+
return (model as Model<"anthropic-messages">).compat?.supportsStrictTools;
|
|
383
|
+
}
|
|
384
|
+
|
|
330
385
|
function anthropicBackendLookupIds(id: string): string[] {
|
|
331
386
|
const routed = (id.split("/").pop() ?? id).toLowerCase();
|
|
332
387
|
const base = routed.replace(/^(?:[a-z0-9-]+\.)*anthropic[./]/, "");
|
|
@@ -352,7 +407,7 @@ const CLAUDE_MODEL_PATTERN = /(?:^|[./_-])(?:claude|opus|sonnet|haiku|fable)(?:$
|
|
|
352
407
|
|
|
353
408
|
function nativeMessagesCatalog(
|
|
354
409
|
entry: ModelInfoEntry,
|
|
355
|
-
): Pick<CatalogResolution, "messagesCompat" | "messagesThinkingLevelMap"> {
|
|
410
|
+
): Pick<CatalogResolution, "messagesCompat" | "messagesStrictTools" | "messagesThinkingLevelMap"> {
|
|
356
411
|
const adapter = wireString(entry.model_info?.litellm_provider)?.trim().toLowerCase();
|
|
357
412
|
if (!adapter || !CLAUDE_CAPABLE_ADAPTERS.has(adapter) || deploymentFamily(entry) !== "claude") return {};
|
|
358
413
|
const candidates = [entry.litellm_params?.model, entry.model_info?.base_model]
|
|
@@ -365,7 +420,13 @@ function nativeMessagesCatalog(
|
|
|
365
420
|
if (models.some((model) => !model) || new Set(models.map((model) => model?.id)).size !== 1) return {};
|
|
366
421
|
const model = models[0]!;
|
|
367
422
|
const compat = messagesCompatOf(model);
|
|
368
|
-
return compat
|
|
423
|
+
return compat
|
|
424
|
+
? {
|
|
425
|
+
messagesCompat: compat,
|
|
426
|
+
messagesStrictTools: messagesStrictToolEvidence(entry, model),
|
|
427
|
+
messagesThinkingLevelMap: model.thinkingLevelMap,
|
|
428
|
+
}
|
|
429
|
+
: {};
|
|
369
430
|
}
|
|
370
431
|
|
|
371
432
|
const ADAPTER_CATALOG_PROVIDERS: Readonly<Record<string, BuiltinProvider>> = {
|
|
@@ -597,8 +658,10 @@ function mapFromModelInfoGroup(
|
|
|
597
658
|
ambiguousRoutes?: string[];
|
|
598
659
|
conflictingFamilyRoutes?: string[];
|
|
599
660
|
withheldRepairRoutes?: string[];
|
|
661
|
+
defaultedContextRoutes?: Set<string>;
|
|
600
662
|
denyLevels?: boolean;
|
|
601
663
|
allowMessages?: boolean;
|
|
664
|
+
proxyVersion?: ProxyVersion;
|
|
602
665
|
} = {},
|
|
603
666
|
): DiscoveredModel | undefined {
|
|
604
667
|
const reduced = reduceModelGroup(entries, (entry) => {
|
|
@@ -612,15 +675,16 @@ function mapFromModelInfoGroup(
|
|
|
612
675
|
const model = new Set(generations).size === 1 && family !== "conflicting" ? generations[0] : undefined;
|
|
613
676
|
return {
|
|
614
677
|
...catalog,
|
|
615
|
-
...(options.allowMessages === false ? { messagesCompat: undefined } : {}),
|
|
678
|
+
...(options.allowMessages === false ? { messagesCompat: undefined, messagesStrictTools: undefined } : {}),
|
|
616
679
|
...(family ? { semanticFamily: family } : {}),
|
|
617
680
|
...(model ? { semanticModel: model } : {}),
|
|
618
681
|
};
|
|
619
682
|
});
|
|
620
683
|
if (!reduced) return undefined;
|
|
684
|
+
if (reduced.contextWindowDefaulted) options.defaultedContextRoutes?.add(reduced.id);
|
|
621
685
|
if (reduced.catalogAuthorityAmbiguous) options.ambiguousRoutes?.push(reduced.id);
|
|
622
686
|
if (reduced.deploymentFamilies.includes("conflicting")) options.conflictingFamilyRoutes?.push(reduced.id);
|
|
623
|
-
const protocols = entries.map((entry) => modelProtocol(reduced.id, entry));
|
|
687
|
+
const protocols = entries.map((entry) => modelProtocol(reduced.id, entry, options.proxyVersion));
|
|
624
688
|
const protocol = protocols.find((candidate) => candidate.api === "openai-completions") ?? protocols[0]!;
|
|
625
689
|
const api = reduced.api === "anthropic-messages" ? reduced.api : protocol.api;
|
|
626
690
|
const families = new Set(entries.map((entry) => resolveBackendIdentity({ ...entry, model_name: undefined })?.family));
|
|
@@ -642,10 +706,17 @@ function mapFromModelInfoGroup(
|
|
|
642
706
|
family === undefined ? undefined : completionsCompat(reduced.id, family),
|
|
643
707
|
),
|
|
644
708
|
);
|
|
709
|
+
const messagesCompat =
|
|
710
|
+
api === "anthropic-messages"
|
|
711
|
+
? {
|
|
712
|
+
...reduced.messagesCompat,
|
|
713
|
+
...(reduced.messagesStrictTools !== undefined ? { supportsStrictTools: reduced.messagesStrictTools } : {}),
|
|
714
|
+
}
|
|
715
|
+
: undefined;
|
|
645
716
|
const policy = closeSerializerPolicy({
|
|
646
717
|
api,
|
|
647
718
|
reasoning,
|
|
648
|
-
vendorCompat: api === "anthropic-messages" ?
|
|
719
|
+
vendorCompat: api === "anthropic-messages" ? messagesCompat : vendorCompat,
|
|
649
720
|
semanticCompat: reduced.acceptsResponsesReasoningControl
|
|
650
721
|
? { ...reasoningPolicy?.compat, supportsReasoningEffort: true }
|
|
651
722
|
: reasoningPolicy?.compat,
|
|
@@ -689,12 +760,23 @@ function mapFromModelInfoGroup(
|
|
|
689
760
|
: {}),
|
|
690
761
|
...(families.size === 1 && family ? { litellmBackendFamily: family } : {}),
|
|
691
762
|
...(modelPolicy ? { litellmPolicy: modelPolicy } : {}),
|
|
692
|
-
|
|
763
|
+
// closeSerializerPolicy derives compat from `api`, a correlation its return type cannot carry.
|
|
764
|
+
} as DiscoveredModel;
|
|
693
765
|
}
|
|
694
766
|
|
|
695
767
|
// An evidence-free fallback entry has no deployment or adapter evidence. Its route name
|
|
696
768
|
// authorizes nothing; the Pi catalog entry for its id supplies its protocol and presentation
|
|
697
769
|
// metadata, while an unknown id stays on Chat Completions until /model/info can be read.
|
|
770
|
+
// Whether a group that will be published has its transport decided by the proxy
|
|
771
|
+
// version. Mapping without collectors leaves no diagnostic behind, and whether a
|
|
772
|
+
// group is published does not depend on the version.
|
|
773
|
+
function publishesVersionGatedTransport(
|
|
774
|
+
group: readonly ModelInfoEntry[],
|
|
775
|
+
publicCatalog: PublicCatalog | undefined,
|
|
776
|
+
): boolean {
|
|
777
|
+
return group.some(transportAwaitsProxyVersion) && mapFromModelInfoGroup(group, publicCatalog) !== undefined;
|
|
778
|
+
}
|
|
779
|
+
|
|
698
780
|
function catalogProtocol(modelId: string, catalogModel: Model<Api> | undefined): ModelProtocol {
|
|
699
781
|
return catalogModel?.api === "openai-responses"
|
|
700
782
|
? { api: "openai-responses", compat: responsesCompat(modelId) }
|
|
@@ -720,7 +802,7 @@ function mapFromModelsList(entry: ModelsListEntry): DiscoveredModel | undefined
|
|
|
720
802
|
}),
|
|
721
803
|
input: catalogModel?.input ?? ["text"],
|
|
722
804
|
cost: catalogModel?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
723
|
-
contextWindow: catalogModel?.contextWindow ??
|
|
805
|
+
contextWindow: catalogModel?.contextWindow ?? defaultContextWindow(),
|
|
724
806
|
maxTokens: catalogModel?.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
725
807
|
api,
|
|
726
808
|
litellmDiscoveryVersion: LITELLM_DISCOVERY_VERSION,
|
|
@@ -778,6 +860,14 @@ async function discoverFromHealth(
|
|
|
778
860
|
const publicCatalog = deployments.some((deployment) => deployment && !deployment.synthetic)
|
|
779
861
|
? await loadDiscoveryPublicCatalog(options)
|
|
780
862
|
: undefined;
|
|
863
|
+
const proxyVersion = [...groups.values()].some((group) =>
|
|
864
|
+
publishesVersionGatedTransport(
|
|
865
|
+
group.map(({ entry }) => entry),
|
|
866
|
+
publicCatalog,
|
|
867
|
+
),
|
|
868
|
+
)
|
|
869
|
+
? await probeProxyVersion(base, apiKey, options)
|
|
870
|
+
: undefined;
|
|
781
871
|
const incompatibleModeRoutes: string[] = [];
|
|
782
872
|
const ambiguousRoutes: string[] = [];
|
|
783
873
|
const conflictingFamilyRoutes: string[] = [];
|
|
@@ -796,6 +886,7 @@ async function discoverFromHealth(
|
|
|
796
886
|
withheldRepairRoutes,
|
|
797
887
|
denyLevels: group.some(({ denyLevels }) => denyLevels),
|
|
798
888
|
allowMessages: false,
|
|
889
|
+
proxyVersion,
|
|
799
890
|
});
|
|
800
891
|
})
|
|
801
892
|
.filter((model): model is DiscoveredModel => model !== undefined);
|
|
@@ -871,16 +962,14 @@ function wildcardPatternSpecificity(pattern: string): readonly [number, number]
|
|
|
871
962
|
return [escaped.length, complexity];
|
|
872
963
|
}
|
|
873
964
|
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
model: DiscoveredModel,
|
|
965
|
+
// The rows of the most-specific published wildcard route LiteLLM would select for a
|
|
966
|
+
// concrete id, using its pattern order, with the id substituted into each row.
|
|
967
|
+
function selectedWildcardRows(
|
|
968
|
+
modelId: string,
|
|
879
969
|
wildcardRows: readonly ModelInfoEntry[],
|
|
880
970
|
publishedWildcardIds: ReadonlySet<string>,
|
|
881
|
-
|
|
882
|
-
)
|
|
883
|
-
const matchingRows = wildcardRows.filter((row) => row.model_name && wildcardMatches(row.model_name, model.id));
|
|
971
|
+
): ModelInfoEntry[] | undefined {
|
|
972
|
+
const matchingRows = wildcardRows.filter((row) => row.model_name && wildcardMatches(row.model_name, modelId));
|
|
884
973
|
const selectedPattern = matchingRows
|
|
885
974
|
.map((row) => row.model_name!)
|
|
886
975
|
.sort((left, right) => {
|
|
@@ -890,10 +979,24 @@ function applyWildcardEvidence(
|
|
|
890
979
|
return rightLength - leftLength || leftComplexity - rightComplexity || left.localeCompare(right);
|
|
891
980
|
})[0];
|
|
892
981
|
if (!selectedPattern || !publishedWildcardIds.has(selectedPattern)) return undefined;
|
|
893
|
-
|
|
982
|
+
return matchingRows
|
|
894
983
|
.filter((row) => row.model_name === selectedPattern)
|
|
895
|
-
.map((row) => resolveWildcardRow(row,
|
|
896
|
-
|
|
984
|
+
.map((row) => resolveWildcardRow(row, modelId));
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// A concrete id expanded from a wildcard route inherits the deployment evidence from the
|
|
988
|
+
// most-specific matching route, using LiteLLM's pattern order. Rows sharing that route still
|
|
989
|
+
// vote together, so any deployment needing Chat keeps the child on Chat.
|
|
990
|
+
function applyWildcardEvidence(
|
|
991
|
+
model: DiscoveredModel,
|
|
992
|
+
wildcardRows: readonly ModelInfoEntry[],
|
|
993
|
+
publishedWildcardIds: ReadonlySet<string>,
|
|
994
|
+
publicCatalog: PublicCatalog | undefined,
|
|
995
|
+
proxyVersion: ProxyVersion | undefined,
|
|
996
|
+
): DiscoveredModel | undefined {
|
|
997
|
+
const parents = selectedWildcardRows(model.id, wildcardRows, publishedWildcardIds);
|
|
998
|
+
if (!parents) return undefined;
|
|
999
|
+
const selected = mapFromModelInfoGroup(parents, publicCatalog, { proxyVersion });
|
|
897
1000
|
if (!selected) return undefined;
|
|
898
1001
|
const policies = [model.litellmPolicy, selected.litellmPolicy].filter((policy) => policy !== undefined);
|
|
899
1002
|
const combinedPolicy =
|
|
@@ -923,7 +1026,7 @@ function applyWildcardEvidence(
|
|
|
923
1026
|
...(selected.litellmBackendFamily ? { litellmBackendFamily: selected.litellmBackendFamily } : {}),
|
|
924
1027
|
...(combinedPolicy ? { litellmPolicy: combinedPolicy } : {}),
|
|
925
1028
|
...(selected.litellmResponsesReasoningControl ? { litellmResponsesReasoningControl: true as const } : {}),
|
|
926
|
-
};
|
|
1029
|
+
} as DiscoveredModel;
|
|
927
1030
|
}
|
|
928
1031
|
|
|
929
1032
|
function mapFromWildcardExpansion(
|
|
@@ -1030,10 +1133,23 @@ export async function discoverModels(
|
|
|
1030
1133
|
groups.set(route, group);
|
|
1031
1134
|
}
|
|
1032
1135
|
const publicCatalog = await loadDiscoveryPublicCatalog(options);
|
|
1136
|
+
// A wildcard route publishes nothing until `/v1/models` expands it, so only
|
|
1137
|
+
// exact routes can call for the proxy version this early.
|
|
1138
|
+
let proxyVersionRead = false;
|
|
1139
|
+
const readProxyVersion = (): Promise<ProxyVersion | undefined> => {
|
|
1140
|
+
proxyVersionRead = true;
|
|
1141
|
+
return probeProxyVersion(base, apiKey, options);
|
|
1142
|
+
};
|
|
1143
|
+
let proxyVersion = [...groups].some(
|
|
1144
|
+
([route, group]) => !route.includes("*") && publishesVersionGatedTransport(group, publicCatalog),
|
|
1145
|
+
)
|
|
1146
|
+
? await readProxyVersion()
|
|
1147
|
+
: undefined;
|
|
1033
1148
|
const ambiguousRoutes: string[] = [];
|
|
1034
1149
|
const incompatibleModeRoutes: string[] = [];
|
|
1035
1150
|
const conflictingFamilyRoutes: string[] = [];
|
|
1036
1151
|
const withheldRepairRoutes: string[] = [];
|
|
1152
|
+
const defaultedContextRoutes = new Set<string>();
|
|
1037
1153
|
const reducedGroups = [...groups.entries()].map(([route, group]) => {
|
|
1038
1154
|
if (hasMixedIncompatibleDeploymentModes(group)) incompatibleModeRoutes.push(route);
|
|
1039
1155
|
return {
|
|
@@ -1042,6 +1158,8 @@ export async function discoverModels(
|
|
|
1042
1158
|
ambiguousRoutes,
|
|
1043
1159
|
conflictingFamilyRoutes,
|
|
1044
1160
|
withheldRepairRoutes,
|
|
1161
|
+
defaultedContextRoutes,
|
|
1162
|
+
proxyVersion,
|
|
1045
1163
|
}),
|
|
1046
1164
|
deploymentFamilies: group.map(deploymentFamily),
|
|
1047
1165
|
};
|
|
@@ -1079,25 +1197,59 @@ export async function discoverModels(
|
|
|
1079
1197
|
const listResult = await fetchJson<ModelsListResponse>(`${base}/v1/models`, apiKey, options);
|
|
1080
1198
|
if (listResult.ok && wildcards.length > 0) {
|
|
1081
1199
|
const seen = new Set(models.map((model) => model.id));
|
|
1082
|
-
const
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1200
|
+
const candidates = (listResult.data.data ?? []).filter((entry) => {
|
|
1201
|
+
const id = wireString(entry.id);
|
|
1202
|
+
return (
|
|
1203
|
+
id === undefined ||
|
|
1204
|
+
(!seen.has(id) && !droppedExactIds.has(id) && !droppedWildcards.some((route) => wildcardMatches(route, id)))
|
|
1205
|
+
);
|
|
1206
|
+
});
|
|
1207
|
+
let sources = wildcards;
|
|
1208
|
+
const expansionAwaitsProxyVersion = candidates.some((entry) => {
|
|
1209
|
+
const id = wireString(entry.id);
|
|
1210
|
+
if (!id || id.includes("*")) return false;
|
|
1211
|
+
const parents = selectedWildcardRows(id, wildcardRows, publishedWildcardIds);
|
|
1212
|
+
return parents !== undefined && publishesVersionGatedTransport(parents, publicCatalog);
|
|
1213
|
+
});
|
|
1214
|
+
if (!proxyVersionRead && expansionAwaitsProxyVersion) {
|
|
1215
|
+
proxyVersion = await readProxyVersion();
|
|
1216
|
+
// The parents were reduced before the version was known; an expansion
|
|
1217
|
+
// must inherit from parents reduced under the version it is published with.
|
|
1218
|
+
sources = wildcardRoutes.flatMap(({ route, deploymentFamilies }) => {
|
|
1219
|
+
const model = mapFromModelInfoGroup(groups.get(route)!, publicCatalog, { proxyVersion });
|
|
1220
|
+
return model ? [{ model, deploymentFamilies }] : [];
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
const expanded = candidates
|
|
1224
|
+
.map((entry) => mapFromWildcardExpansion(entry, sources, withheldRepairRoutes))
|
|
1093
1225
|
.filter((model): model is DiscoveredModel => model !== undefined)
|
|
1094
|
-
.map((model) => applyWildcardEvidence(model, wildcardRows, publishedWildcardIds, publicCatalog))
|
|
1226
|
+
.map((model) => applyWildcardEvidence(model, wildcardRows, publishedWildcardIds, publicCatalog, proxyVersion))
|
|
1095
1227
|
.filter((model): model is DiscoveredModel => model !== undefined);
|
|
1096
1228
|
models = [...models, ...expanded];
|
|
1097
1229
|
}
|
|
1098
1230
|
}
|
|
1099
1231
|
reportWithheldToolRepair(withheldRepairRoutes);
|
|
1100
|
-
|
|
1232
|
+
models = deduplicateModels(models);
|
|
1233
|
+
if (process.env.LITELLM_VERBOSE_DISCOVERY === "1") {
|
|
1234
|
+
const defaultedWildcards = wildcardRoutes.filter(({ route }) => defaultedContextRoutes.has(route));
|
|
1235
|
+
// Exact routes override wildcard templates; a tighter measured parent can also win.
|
|
1236
|
+
const defaulted = models.filter((model) =>
|
|
1237
|
+
groups.has(model.id)
|
|
1238
|
+
? defaultedContextRoutes.has(model.id)
|
|
1239
|
+
: defaultedWildcards.some(
|
|
1240
|
+
({ route, model: parent }) =>
|
|
1241
|
+
parent?.contextWindow === model.contextWindow && wildcardMatches(route, model.id),
|
|
1242
|
+
),
|
|
1243
|
+
);
|
|
1244
|
+
reportBoundedRoutes(
|
|
1245
|
+
reportedDefaultedContextRoutes,
|
|
1246
|
+
defaulted.map((model) => JSON.stringify(model.id)),
|
|
1247
|
+
(count) =>
|
|
1248
|
+
`LiteLLM discovery: ${count} route(s) default contextWindow to ${defaultContextWindow()}; ` +
|
|
1249
|
+
"set model_info.max_input_tokens on every deployment in each route",
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
return { source: "model_info", models, ...(proxyVersion ? { proxyVersion } : {}) };
|
|
1101
1253
|
}
|
|
1102
1254
|
if (![401, 403, 404].includes(infoResult.status)) {
|
|
1103
1255
|
throw new Error(`/model/info returned ${infoResult.status}`);
|
|
@@ -1163,10 +1315,22 @@ function hasMoonshotCompatEvidence(compat: Model<Api>["compat"]): boolean {
|
|
|
1163
1315
|
}
|
|
1164
1316
|
|
|
1165
1317
|
export function restoreCachedModelPolicy(model: Model<Api>): Model<Api> {
|
|
1166
|
-
const cached = model as
|
|
1167
|
-
|
|
1168
|
-
const
|
|
1169
|
-
|
|
1318
|
+
const cached = model as LiteLLMModel;
|
|
1319
|
+
let restored: LiteLLMModel = cached;
|
|
1320
|
+
const messagesCompat = cached.compat as Model<"anthropic-messages">["compat"];
|
|
1321
|
+
if (
|
|
1322
|
+
cached.api === "anthropic-messages" &&
|
|
1323
|
+
cached.litellmDiscoveryVersion !== LITELLM_DISCOVERY_VERSION &&
|
|
1324
|
+
messagesCompat?.supportsStrictTools !== undefined
|
|
1325
|
+
) {
|
|
1326
|
+
const { supportsStrictTools: _staleStrictTools, ...compat } = messagesCompat;
|
|
1327
|
+
// Older discovery copied this value from the direct Anthropic catalog even
|
|
1328
|
+
// for hosted routes. Without the original deployment rows it cannot be
|
|
1329
|
+
// re-proven offline, so fail closed until the forced network refresh below.
|
|
1330
|
+
restored = { ...cached, compat: Object.keys(compat).length > 0 ? compat : undefined };
|
|
1331
|
+
}
|
|
1332
|
+
if (restored.litellmPolicy || !hasMoonshotCompatEvidence(restored.compat)) return restored;
|
|
1333
|
+
return { ...restored, litellmPolicy: moonshotPolicy(restored.id) } as LiteLLMModel;
|
|
1170
1334
|
}
|
|
1171
1335
|
|
|
1172
1336
|
function hasResponsesReasoningControl(model: Model<Api>): boolean {
|