pi-provider-litellm 3.2.0 → 3.4.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 +58 -10
- package/package.json +6 -6
- package/src/backend-identity.ts +17 -1
- package/src/discover.ts +246 -48
- package/src/index.ts +548 -155
- package/src/mcp-tools.ts +191 -9
- package/src/model-groups.ts +16 -12
- package/src/provider.ts +27 -12
- package/src/proxy-version.ts +68 -0
- package/src/skills.ts +10 -0
- package/src/types.ts +25 -1
package/src/discover.ts
CHANGED
|
@@ -2,7 +2,12 @@ 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,
|
|
@@ -20,6 +25,7 @@ import {
|
|
|
20
25
|
type SemanticModel,
|
|
21
26
|
wireString,
|
|
22
27
|
} from "./model-groups.js";
|
|
28
|
+
import { type ProxyVersion, type ProxyVersionFloor, probeProxyVersion, proxyVersionAtLeast } from "./proxy-version.js";
|
|
23
29
|
import { loadPublicCatalog, type PublicCatalog, type PublicCatalogRecord } from "./public-catalog.js";
|
|
24
30
|
import { intersectThinkingLevelMaps } from "./thinking-levels.js";
|
|
25
31
|
import type {
|
|
@@ -28,6 +34,7 @@ import type {
|
|
|
28
34
|
DiscoveryOptions,
|
|
29
35
|
DiscoveryResult,
|
|
30
36
|
HealthResponse,
|
|
37
|
+
LiteLLMModel,
|
|
31
38
|
LiteLLMModelPolicy,
|
|
32
39
|
ModelInfoEntry,
|
|
33
40
|
ModelInfoResponse,
|
|
@@ -86,17 +93,31 @@ export function normalizeBaseUrl(input: string, allowInsecureHttp = false): stri
|
|
|
86
93
|
const ANTHROPIC_MODEL_PATTERN = /(?:^|[-_/.:])(?:anthropic\/|(?:claude|fable|opus|sonnet|haiku)(?=$|[-_/.:]))/i;
|
|
87
94
|
const MOONSHOT_MODEL_PATTERN = /^(moonshotai\/|moonshot\/|kimi[-/])/i;
|
|
88
95
|
const FORCED_THINKING_MODEL_PATTERN = /(?:^|[-/])thinking(?:[-/]|$)/i;
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
const
|
|
96
|
+
// GPT ids appear bare, prefixed, or dated (`gpt-6-sol`, `llm-gateway/gpt-5.5`,
|
|
97
|
+
// `gpt-5.5-20260504143601`); match them all so the tool+reasoning workaround
|
|
98
|
+
// survives renames. `gpt-4o` and `gpt-oss` carry no generation number here.
|
|
99
|
+
const GPT_GENERATION_PATTERN = /(?:^|\/)gpt-(\d+)(?:\.(\d+))?(?=$|[-.])/i;
|
|
93
100
|
|
|
94
101
|
export function isMoonshotModel(modelId: string): boolean {
|
|
95
102
|
return MOONSHOT_MODEL_PATTERN.test(modelId);
|
|
96
103
|
}
|
|
97
104
|
|
|
98
|
-
|
|
99
|
-
|
|
105
|
+
function gptGeneration(modelId: string): [major: number, minor: number] | undefined {
|
|
106
|
+
const match = GPT_GENERATION_PATTERN.exec(modelId);
|
|
107
|
+
return match ? [Number(match[1]), Number(match[2] ?? 0)] : undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// GPT-5.5 and later reject `reasoning_effort` alongside function tools on
|
|
111
|
+
// /v1/chat/completions ("use /v1/responses").
|
|
112
|
+
export function isGpt55OrNewerModel(modelId: string): boolean {
|
|
113
|
+
const generation = gptGeneration(modelId);
|
|
114
|
+
return generation !== undefined && (generation[0] > 5 || (generation[0] === 5 && generation[1] >= 5));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// GPT-6 also rejects an omitted effort there: it falls back to its default effort.
|
|
118
|
+
export function isGpt6OrNewerModel(modelId: string): boolean {
|
|
119
|
+
const generation = gptGeneration(modelId);
|
|
120
|
+
return generation !== undefined && generation[0] >= 6;
|
|
100
121
|
}
|
|
101
122
|
|
|
102
123
|
function shouldSuppressReasoningContent(modelId: string): boolean {
|
|
@@ -134,10 +155,43 @@ export function completionsCompat(
|
|
|
134
155
|
return { supportsStore: false };
|
|
135
156
|
}
|
|
136
157
|
|
|
137
|
-
|
|
158
|
+
// Before v1.103.0 LiteLLM has no native Responses config for the `azure_ai`
|
|
159
|
+
// provider, so every azure_ai deployment reaches `/v1/responses` through the
|
|
160
|
+
// Chat Completions bridge. That bridge crashes on Azure's empty-`choices` stream
|
|
161
|
+
// chunks with "list index out of range" (BerriAI/litellm#34455), while the same
|
|
162
|
+
// deployment streams fine on `/v1/chat/completions`. v1.103.0 guards the bridge
|
|
163
|
+
// and serves azure_ai natively where the host allows it.
|
|
164
|
+
const AZURE_AI_RESPONSES_FLOOR: ProxyVersionFloor = [1, 103, 0];
|
|
165
|
+
|
|
166
|
+
function isAzureAiDeployment(entry: ModelInfoEntry): boolean {
|
|
167
|
+
const adapter = wireString(entry.litellm_params?.custom_llm_provider)?.trim().toLowerCase();
|
|
168
|
+
const configuredModel = wireString(entry.litellm_params?.model)?.trim().toLowerCase();
|
|
169
|
+
return adapter === "azure_ai" || /^azure_ai\//.test(configuredModel ?? "");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function listsResponsesEndpoint(entry: ModelInfoEntry): boolean | undefined {
|
|
138
173
|
const endpoints = entry.model_info?.supported_endpoints;
|
|
139
|
-
|
|
174
|
+
return Array.isArray(endpoints) ? endpoints.some((endpoint) => endpoint === "/v1/responses") : undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// An azure_ai `supported_endpoints` list is copied from the model cost map and
|
|
178
|
+
// describes the model, not the transport LiteLLM uses, so on its own it cannot
|
|
179
|
+
// authorize Responses. These are the only deployments whose transport the proxy
|
|
180
|
+
// version decides; discovery asks for the version only when one is published.
|
|
181
|
+
function transportAwaitsProxyVersion(entry: ModelInfoEntry): boolean {
|
|
182
|
+
return (
|
|
183
|
+
isAzureAiDeployment(entry) &&
|
|
184
|
+
listsResponsesEndpoint(entry) === true &&
|
|
185
|
+
normalizedMode(entry.model_info?.mode) !== "responses"
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function supportsResponses(entry: ModelInfoEntry, proxyVersion?: ProxyVersion): boolean {
|
|
190
|
+
const listsResponses = listsResponsesEndpoint(entry);
|
|
191
|
+
if (listsResponses === false) return false;
|
|
140
192
|
if (normalizedMode(entry.model_info?.mode) === "responses") return true;
|
|
193
|
+
if (transportAwaitsProxyVersion(entry) && !proxyVersionAtLeast(proxyVersion, AZURE_AI_RESPONSES_FLOOR)) return false;
|
|
194
|
+
if (listsResponses === true) return true;
|
|
141
195
|
|
|
142
196
|
const identity = resolveBackendIdentity(entry);
|
|
143
197
|
if (identity?.family !== "openai") return false;
|
|
@@ -157,12 +211,16 @@ function supportsResponses(entry: ModelInfoEntry): boolean {
|
|
|
157
211
|
return !azureAdapter;
|
|
158
212
|
}
|
|
159
213
|
|
|
160
|
-
export function modelProtocol(
|
|
214
|
+
export function modelProtocol(
|
|
215
|
+
modelId: string,
|
|
216
|
+
modeOrEntry?: string | null | ModelInfoEntry,
|
|
217
|
+
proxyVersion?: ProxyVersion,
|
|
218
|
+
): ModelProtocol {
|
|
161
219
|
const selectedEntry =
|
|
162
220
|
typeof modeOrEntry === "object" && modeOrEntry !== null
|
|
163
221
|
? { ...modeOrEntry, model_name: undefined }
|
|
164
222
|
: { model_name: modelId, model_info: { mode: modeOrEntry } };
|
|
165
|
-
return supportsResponses(selectedEntry)
|
|
223
|
+
return supportsResponses(selectedEntry, proxyVersion)
|
|
166
224
|
? { api: "openai-responses", compat: responsesCompat(modelId) }
|
|
167
225
|
: { api: "openai-completions", compat: completionsCompat(modelId) };
|
|
168
226
|
}
|
|
@@ -324,10 +382,20 @@ function messagesCompatOf(model: Model<Api>): MessagesBackendCompat | undefined
|
|
|
324
382
|
const carried: MessagesBackendCompat = {};
|
|
325
383
|
if (compat?.forceAdaptiveThinking !== undefined) carried.forceAdaptiveThinking = compat.forceAdaptiveThinking;
|
|
326
384
|
if (compat?.supportsTemperature !== undefined) carried.supportsTemperature = compat.supportsTemperature;
|
|
327
|
-
if (compat?.supportsStrictTools !== undefined) carried.supportsStrictTools = compat.supportsStrictTools;
|
|
328
385
|
return carried;
|
|
329
386
|
}
|
|
330
387
|
|
|
388
|
+
const ANTHROPIC_ROUTES: ReadonlySet<string> = new Set(["anthropic"]);
|
|
389
|
+
|
|
390
|
+
function messagesStrictToolEvidence(entry: ModelInfoEntry, model: Model<Api>): boolean | undefined {
|
|
391
|
+
// The Anthropic catalog describes the Anthropic API, so only deployments whose
|
|
392
|
+
// declared routing names Anthropic keep its grant. A hosted route (Bedrock,
|
|
393
|
+
// Vertex) can reject tools[].strict for the same model, and LiteLLM reports no
|
|
394
|
+
// per-route strict-tool capability, so it stays unknown.
|
|
395
|
+
if (!routesOnlyThrough(entry, ANTHROPIC_ROUTES) || model.api !== "anthropic-messages") return undefined;
|
|
396
|
+
return (model as Model<"anthropic-messages">).compat?.supportsStrictTools;
|
|
397
|
+
}
|
|
398
|
+
|
|
331
399
|
function anthropicBackendLookupIds(id: string): string[] {
|
|
332
400
|
const routed = (id.split("/").pop() ?? id).toLowerCase();
|
|
333
401
|
const base = routed.replace(/^(?:[a-z0-9-]+\.)*anthropic[./]/, "");
|
|
@@ -353,7 +421,7 @@ const CLAUDE_MODEL_PATTERN = /(?:^|[./_-])(?:claude|opus|sonnet|haiku|fable)(?:$
|
|
|
353
421
|
|
|
354
422
|
function nativeMessagesCatalog(
|
|
355
423
|
entry: ModelInfoEntry,
|
|
356
|
-
): Pick<CatalogResolution, "messagesCompat" | "messagesThinkingLevelMap"> {
|
|
424
|
+
): Pick<CatalogResolution, "messagesCompat" | "messagesStrictTools" | "messagesThinkingLevelMap"> {
|
|
357
425
|
const adapter = wireString(entry.model_info?.litellm_provider)?.trim().toLowerCase();
|
|
358
426
|
if (!adapter || !CLAUDE_CAPABLE_ADAPTERS.has(adapter) || deploymentFamily(entry) !== "claude") return {};
|
|
359
427
|
const candidates = [entry.litellm_params?.model, entry.model_info?.base_model]
|
|
@@ -366,7 +434,13 @@ function nativeMessagesCatalog(
|
|
|
366
434
|
if (models.some((model) => !model) || new Set(models.map((model) => model?.id)).size !== 1) return {};
|
|
367
435
|
const model = models[0]!;
|
|
368
436
|
const compat = messagesCompatOf(model);
|
|
369
|
-
return compat
|
|
437
|
+
return compat
|
|
438
|
+
? {
|
|
439
|
+
messagesCompat: compat,
|
|
440
|
+
messagesStrictTools: messagesStrictToolEvidence(entry, model),
|
|
441
|
+
messagesThinkingLevelMap: model.thinkingLevelMap,
|
|
442
|
+
}
|
|
443
|
+
: {};
|
|
370
444
|
}
|
|
371
445
|
|
|
372
446
|
const ADAPTER_CATALOG_PROVIDERS: Readonly<Record<string, BuiltinProvider>> = {
|
|
@@ -601,6 +675,7 @@ function mapFromModelInfoGroup(
|
|
|
601
675
|
defaultedContextRoutes?: Set<string>;
|
|
602
676
|
denyLevels?: boolean;
|
|
603
677
|
allowMessages?: boolean;
|
|
678
|
+
proxyVersion?: ProxyVersion;
|
|
604
679
|
} = {},
|
|
605
680
|
): DiscoveredModel | undefined {
|
|
606
681
|
const reduced = reduceModelGroup(entries, (entry) => {
|
|
@@ -614,7 +689,7 @@ function mapFromModelInfoGroup(
|
|
|
614
689
|
const model = new Set(generations).size === 1 && family !== "conflicting" ? generations[0] : undefined;
|
|
615
690
|
return {
|
|
616
691
|
...catalog,
|
|
617
|
-
...(options.allowMessages === false ? { messagesCompat: undefined } : {}),
|
|
692
|
+
...(options.allowMessages === false ? { messagesCompat: undefined, messagesStrictTools: undefined } : {}),
|
|
618
693
|
...(family ? { semanticFamily: family } : {}),
|
|
619
694
|
...(model ? { semanticModel: model } : {}),
|
|
620
695
|
};
|
|
@@ -623,7 +698,7 @@ function mapFromModelInfoGroup(
|
|
|
623
698
|
if (reduced.contextWindowDefaulted) options.defaultedContextRoutes?.add(reduced.id);
|
|
624
699
|
if (reduced.catalogAuthorityAmbiguous) options.ambiguousRoutes?.push(reduced.id);
|
|
625
700
|
if (reduced.deploymentFamilies.includes("conflicting")) options.conflictingFamilyRoutes?.push(reduced.id);
|
|
626
|
-
const protocols = entries.map((entry) => modelProtocol(reduced.id, entry));
|
|
701
|
+
const protocols = entries.map((entry) => modelProtocol(reduced.id, entry, options.proxyVersion));
|
|
627
702
|
const protocol = protocols.find((candidate) => candidate.api === "openai-completions") ?? protocols[0]!;
|
|
628
703
|
const api = reduced.api === "anthropic-messages" ? reduced.api : protocol.api;
|
|
629
704
|
const families = new Set(entries.map((entry) => resolveBackendIdentity({ ...entry, model_name: undefined })?.family));
|
|
@@ -645,10 +720,17 @@ function mapFromModelInfoGroup(
|
|
|
645
720
|
family === undefined ? undefined : completionsCompat(reduced.id, family),
|
|
646
721
|
),
|
|
647
722
|
);
|
|
723
|
+
const messagesCompat =
|
|
724
|
+
api === "anthropic-messages"
|
|
725
|
+
? {
|
|
726
|
+
...reduced.messagesCompat,
|
|
727
|
+
...(reduced.messagesStrictTools !== undefined ? { supportsStrictTools: reduced.messagesStrictTools } : {}),
|
|
728
|
+
}
|
|
729
|
+
: undefined;
|
|
648
730
|
const policy = closeSerializerPolicy({
|
|
649
731
|
api,
|
|
650
732
|
reasoning,
|
|
651
|
-
vendorCompat: api === "anthropic-messages" ?
|
|
733
|
+
vendorCompat: api === "anthropic-messages" ? messagesCompat : vendorCompat,
|
|
652
734
|
semanticCompat: reduced.acceptsResponsesReasoningControl
|
|
653
735
|
? { ...reasoningPolicy?.compat, supportsReasoningEffort: true }
|
|
654
736
|
: reasoningPolicy?.compat,
|
|
@@ -668,13 +750,29 @@ function mapFromModelInfoGroup(
|
|
|
668
750
|
!hasConflictingFamily &&
|
|
669
751
|
(reduced.deploymentFamilies.includes("kimi") || (unlabeled && isMoonshotModel(reduced.id)));
|
|
670
752
|
if (moonshotEvidence && !unanimousMoonshot) options.withheldRepairRoutes?.push(reduced.id);
|
|
671
|
-
const
|
|
753
|
+
const familyPolicy = requestPolicy(
|
|
672
754
|
reduced.id,
|
|
673
755
|
reduced.deploymentFamilies,
|
|
674
756
|
reduced.normalizeThinkTags,
|
|
675
757
|
reduced.suppressReasoningVisibility,
|
|
676
758
|
unanimousMoonshot,
|
|
677
759
|
);
|
|
760
|
+
// One GPT-5.5+ deployment is enough: a tool request routed to it fails with
|
|
761
|
+
// reasoning attached, while dropping reasoning elsewhere only loses effort.
|
|
762
|
+
const chatBackendIds =
|
|
763
|
+
api === "openai-completions"
|
|
764
|
+
? entries.flatMap((entry) => resolveBackendIdentity({ ...entry, model_name: undefined })?.modelId ?? [])
|
|
765
|
+
: [];
|
|
766
|
+
const modelPolicy: LiteLLMModelPolicy | undefined = chatBackendIds.some(isGpt55OrNewerModel)
|
|
767
|
+
? {
|
|
768
|
+
normalizeStrictToolMessages: false,
|
|
769
|
+
normalizeThinkTags: false,
|
|
770
|
+
suppressReasoningVisibility: false,
|
|
771
|
+
...familyPolicy,
|
|
772
|
+
dropToolReasoning: true,
|
|
773
|
+
...(chatBackendIds.some(isGpt6OrNewerModel) ? { explicitToolReasoningOff: true } : {}),
|
|
774
|
+
}
|
|
775
|
+
: familyPolicy;
|
|
678
776
|
return {
|
|
679
777
|
id: reduced.id,
|
|
680
778
|
// Reduced groups never borrow the ` (no metadata)` sentinel, which authorizes
|
|
@@ -692,12 +790,23 @@ function mapFromModelInfoGroup(
|
|
|
692
790
|
: {}),
|
|
693
791
|
...(families.size === 1 && family ? { litellmBackendFamily: family } : {}),
|
|
694
792
|
...(modelPolicy ? { litellmPolicy: modelPolicy } : {}),
|
|
695
|
-
|
|
793
|
+
// closeSerializerPolicy derives compat from `api`, a correlation its return type cannot carry.
|
|
794
|
+
} as DiscoveredModel;
|
|
696
795
|
}
|
|
697
796
|
|
|
698
797
|
// An evidence-free fallback entry has no deployment or adapter evidence. Its route name
|
|
699
798
|
// authorizes nothing; the Pi catalog entry for its id supplies its protocol and presentation
|
|
700
799
|
// metadata, while an unknown id stays on Chat Completions until /model/info can be read.
|
|
800
|
+
// Whether a group that will be published has its transport decided by the proxy
|
|
801
|
+
// version. Mapping without collectors leaves no diagnostic behind, and whether a
|
|
802
|
+
// group is published does not depend on the version.
|
|
803
|
+
function publishesVersionGatedTransport(
|
|
804
|
+
group: readonly ModelInfoEntry[],
|
|
805
|
+
publicCatalog: PublicCatalog | undefined,
|
|
806
|
+
): boolean {
|
|
807
|
+
return group.some(transportAwaitsProxyVersion) && mapFromModelInfoGroup(group, publicCatalog) !== undefined;
|
|
808
|
+
}
|
|
809
|
+
|
|
701
810
|
function catalogProtocol(modelId: string, catalogModel: Model<Api> | undefined): ModelProtocol {
|
|
702
811
|
return catalogModel?.api === "openai-responses"
|
|
703
812
|
? { api: "openai-responses", compat: responsesCompat(modelId) }
|
|
@@ -781,6 +890,14 @@ async function discoverFromHealth(
|
|
|
781
890
|
const publicCatalog = deployments.some((deployment) => deployment && !deployment.synthetic)
|
|
782
891
|
? await loadDiscoveryPublicCatalog(options)
|
|
783
892
|
: undefined;
|
|
893
|
+
const proxyVersion = [...groups.values()].some((group) =>
|
|
894
|
+
publishesVersionGatedTransport(
|
|
895
|
+
group.map(({ entry }) => entry),
|
|
896
|
+
publicCatalog,
|
|
897
|
+
),
|
|
898
|
+
)
|
|
899
|
+
? await probeProxyVersion(base, apiKey, options)
|
|
900
|
+
: undefined;
|
|
784
901
|
const incompatibleModeRoutes: string[] = [];
|
|
785
902
|
const ambiguousRoutes: string[] = [];
|
|
786
903
|
const conflictingFamilyRoutes: string[] = [];
|
|
@@ -799,6 +916,7 @@ async function discoverFromHealth(
|
|
|
799
916
|
withheldRepairRoutes,
|
|
800
917
|
denyLevels: group.some(({ denyLevels }) => denyLevels),
|
|
801
918
|
allowMessages: false,
|
|
919
|
+
proxyVersion,
|
|
802
920
|
});
|
|
803
921
|
})
|
|
804
922
|
.filter((model): model is DiscoveredModel => model !== undefined);
|
|
@@ -874,16 +992,14 @@ function wildcardPatternSpecificity(pattern: string): readonly [number, number]
|
|
|
874
992
|
return [escaped.length, complexity];
|
|
875
993
|
}
|
|
876
994
|
|
|
877
|
-
//
|
|
878
|
-
//
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
model: DiscoveredModel,
|
|
995
|
+
// The rows of the most-specific published wildcard route LiteLLM would select for a
|
|
996
|
+
// concrete id, using its pattern order, with the id substituted into each row.
|
|
997
|
+
function selectedWildcardRows(
|
|
998
|
+
modelId: string,
|
|
882
999
|
wildcardRows: readonly ModelInfoEntry[],
|
|
883
1000
|
publishedWildcardIds: ReadonlySet<string>,
|
|
884
|
-
|
|
885
|
-
)
|
|
886
|
-
const matchingRows = wildcardRows.filter((row) => row.model_name && wildcardMatches(row.model_name, model.id));
|
|
1001
|
+
): ModelInfoEntry[] | undefined {
|
|
1002
|
+
const matchingRows = wildcardRows.filter((row) => row.model_name && wildcardMatches(row.model_name, modelId));
|
|
887
1003
|
const selectedPattern = matchingRows
|
|
888
1004
|
.map((row) => row.model_name!)
|
|
889
1005
|
.sort((left, right) => {
|
|
@@ -893,10 +1009,24 @@ function applyWildcardEvidence(
|
|
|
893
1009
|
return rightLength - leftLength || leftComplexity - rightComplexity || left.localeCompare(right);
|
|
894
1010
|
})[0];
|
|
895
1011
|
if (!selectedPattern || !publishedWildcardIds.has(selectedPattern)) return undefined;
|
|
896
|
-
|
|
1012
|
+
return matchingRows
|
|
897
1013
|
.filter((row) => row.model_name === selectedPattern)
|
|
898
|
-
.map((row) => resolveWildcardRow(row,
|
|
899
|
-
|
|
1014
|
+
.map((row) => resolveWildcardRow(row, modelId));
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// A concrete id expanded from a wildcard route inherits the deployment evidence from the
|
|
1018
|
+
// most-specific matching route, using LiteLLM's pattern order. Rows sharing that route still
|
|
1019
|
+
// vote together, so any deployment needing Chat keeps the child on Chat.
|
|
1020
|
+
function applyWildcardEvidence(
|
|
1021
|
+
model: DiscoveredModel,
|
|
1022
|
+
wildcardRows: readonly ModelInfoEntry[],
|
|
1023
|
+
publishedWildcardIds: ReadonlySet<string>,
|
|
1024
|
+
publicCatalog: PublicCatalog | undefined,
|
|
1025
|
+
proxyVersion: ProxyVersion | undefined,
|
|
1026
|
+
): DiscoveredModel | undefined {
|
|
1027
|
+
const parents = selectedWildcardRows(model.id, wildcardRows, publishedWildcardIds);
|
|
1028
|
+
if (!parents) return undefined;
|
|
1029
|
+
const selected = mapFromModelInfoGroup(parents, publicCatalog, { proxyVersion });
|
|
900
1030
|
if (!selected) return undefined;
|
|
901
1031
|
const policies = [model.litellmPolicy, selected.litellmPolicy].filter((policy) => policy !== undefined);
|
|
902
1032
|
const combinedPolicy =
|
|
@@ -908,6 +1038,10 @@ function applyWildcardEvidence(
|
|
|
908
1038
|
...(policies.every((policy) => policy.normalizeGeminiReasoningEffort)
|
|
909
1039
|
? { normalizeGeminiReasoningEffort: true as const }
|
|
910
1040
|
: {}),
|
|
1041
|
+
...(policies.some((policy) => policy.dropToolReasoning) ? { dropToolReasoning: true as const } : {}),
|
|
1042
|
+
...(policies.some((policy) => policy.explicitToolReasoningOff)
|
|
1043
|
+
? { explicitToolReasoningOff: true as const }
|
|
1044
|
+
: {}),
|
|
911
1045
|
}
|
|
912
1046
|
: undefined;
|
|
913
1047
|
const {
|
|
@@ -926,7 +1060,7 @@ function applyWildcardEvidence(
|
|
|
926
1060
|
...(selected.litellmBackendFamily ? { litellmBackendFamily: selected.litellmBackendFamily } : {}),
|
|
927
1061
|
...(combinedPolicy ? { litellmPolicy: combinedPolicy } : {}),
|
|
928
1062
|
...(selected.litellmResponsesReasoningControl ? { litellmResponsesReasoningControl: true as const } : {}),
|
|
929
|
-
};
|
|
1063
|
+
} as DiscoveredModel;
|
|
930
1064
|
}
|
|
931
1065
|
|
|
932
1066
|
function mapFromWildcardExpansion(
|
|
@@ -962,6 +1096,12 @@ function mapFromWildcardExpansion(
|
|
|
962
1096
|
...(modelPolicies.every((policy) => policy?.normalizeGeminiReasoningEffort === true)
|
|
963
1097
|
? { normalizeGeminiReasoningEffort: true as const }
|
|
964
1098
|
: {}),
|
|
1099
|
+
...(api === "openai-completions" && modelPolicies.some((policy) => policy?.dropToolReasoning === true)
|
|
1100
|
+
? { dropToolReasoning: true as const }
|
|
1101
|
+
: {}),
|
|
1102
|
+
...(api === "openai-completions" && modelPolicies.some((policy) => policy?.explicitToolReasoningOff === true)
|
|
1103
|
+
? { explicitToolReasoningOff: true as const }
|
|
1104
|
+
: {}),
|
|
965
1105
|
}
|
|
966
1106
|
: !hasFamilyEvidence && isMoonshotModel(id)
|
|
967
1107
|
? moonshotPolicy(id)
|
|
@@ -1033,6 +1173,18 @@ export async function discoverModels(
|
|
|
1033
1173
|
groups.set(route, group);
|
|
1034
1174
|
}
|
|
1035
1175
|
const publicCatalog = await loadDiscoveryPublicCatalog(options);
|
|
1176
|
+
// A wildcard route publishes nothing until `/v1/models` expands it, so only
|
|
1177
|
+
// exact routes can call for the proxy version this early.
|
|
1178
|
+
let proxyVersionRead = false;
|
|
1179
|
+
const readProxyVersion = (): Promise<ProxyVersion | undefined> => {
|
|
1180
|
+
proxyVersionRead = true;
|
|
1181
|
+
return probeProxyVersion(base, apiKey, options);
|
|
1182
|
+
};
|
|
1183
|
+
let proxyVersion = [...groups].some(
|
|
1184
|
+
([route, group]) => !route.includes("*") && publishesVersionGatedTransport(group, publicCatalog),
|
|
1185
|
+
)
|
|
1186
|
+
? await readProxyVersion()
|
|
1187
|
+
: undefined;
|
|
1036
1188
|
const ambiguousRoutes: string[] = [];
|
|
1037
1189
|
const incompatibleModeRoutes: string[] = [];
|
|
1038
1190
|
const conflictingFamilyRoutes: string[] = [];
|
|
@@ -1047,6 +1199,7 @@ export async function discoverModels(
|
|
|
1047
1199
|
conflictingFamilyRoutes,
|
|
1048
1200
|
withheldRepairRoutes,
|
|
1049
1201
|
defaultedContextRoutes,
|
|
1202
|
+
proxyVersion,
|
|
1050
1203
|
}),
|
|
1051
1204
|
deploymentFamilies: group.map(deploymentFamily),
|
|
1052
1205
|
};
|
|
@@ -1084,19 +1237,33 @@ export async function discoverModels(
|
|
|
1084
1237
|
const listResult = await fetchJson<ModelsListResponse>(`${base}/v1/models`, apiKey, options);
|
|
1085
1238
|
if (listResult.ok && wildcards.length > 0) {
|
|
1086
1239
|
const seen = new Set(models.map((model) => model.id));
|
|
1087
|
-
const
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1240
|
+
const candidates = (listResult.data.data ?? []).filter((entry) => {
|
|
1241
|
+
const id = wireString(entry.id);
|
|
1242
|
+
return (
|
|
1243
|
+
id === undefined ||
|
|
1244
|
+
(!seen.has(id) && !droppedExactIds.has(id) && !droppedWildcards.some((route) => wildcardMatches(route, id)))
|
|
1245
|
+
);
|
|
1246
|
+
});
|
|
1247
|
+
let sources = wildcards;
|
|
1248
|
+
const expansionAwaitsProxyVersion = candidates.some((entry) => {
|
|
1249
|
+
const id = wireString(entry.id);
|
|
1250
|
+
if (!id || id.includes("*")) return false;
|
|
1251
|
+
const parents = selectedWildcardRows(id, wildcardRows, publishedWildcardIds);
|
|
1252
|
+
return parents !== undefined && publishesVersionGatedTransport(parents, publicCatalog);
|
|
1253
|
+
});
|
|
1254
|
+
if (!proxyVersionRead && expansionAwaitsProxyVersion) {
|
|
1255
|
+
proxyVersion = await readProxyVersion();
|
|
1256
|
+
// The parents were reduced before the version was known; an expansion
|
|
1257
|
+
// must inherit from parents reduced under the version it is published with.
|
|
1258
|
+
sources = wildcardRoutes.flatMap(({ route, deploymentFamilies }) => {
|
|
1259
|
+
const model = mapFromModelInfoGroup(groups.get(route)!, publicCatalog, { proxyVersion });
|
|
1260
|
+
return model ? [{ model, deploymentFamilies }] : [];
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
const expanded = candidates
|
|
1264
|
+
.map((entry) => mapFromWildcardExpansion(entry, sources, withheldRepairRoutes))
|
|
1098
1265
|
.filter((model): model is DiscoveredModel => model !== undefined)
|
|
1099
|
-
.map((model) => applyWildcardEvidence(model, wildcardRows, publishedWildcardIds, publicCatalog))
|
|
1266
|
+
.map((model) => applyWildcardEvidence(model, wildcardRows, publishedWildcardIds, publicCatalog, proxyVersion))
|
|
1100
1267
|
.filter((model): model is DiscoveredModel => model !== undefined);
|
|
1101
1268
|
models = [...models, ...expanded];
|
|
1102
1269
|
}
|
|
@@ -1122,7 +1289,7 @@ export async function discoverModels(
|
|
|
1122
1289
|
"set model_info.max_input_tokens on every deployment in each route",
|
|
1123
1290
|
);
|
|
1124
1291
|
}
|
|
1125
|
-
return { source: "model_info", models };
|
|
1292
|
+
return { source: "model_info", models, ...(proxyVersion ? { proxyVersion } : {}) };
|
|
1126
1293
|
}
|
|
1127
1294
|
if (![401, 403, 404].includes(infoResult.status)) {
|
|
1128
1295
|
throw new Error(`/model/info returned ${infoResult.status}`);
|
|
@@ -1188,10 +1355,41 @@ function hasMoonshotCompatEvidence(compat: Model<Api>["compat"]): boolean {
|
|
|
1188
1355
|
}
|
|
1189
1356
|
|
|
1190
1357
|
export function restoreCachedModelPolicy(model: Model<Api>): Model<Api> {
|
|
1191
|
-
const cached = model as
|
|
1192
|
-
|
|
1193
|
-
const
|
|
1194
|
-
|
|
1358
|
+
const cached = model as LiteLLMModel;
|
|
1359
|
+
let restored: LiteLLMModel = cached;
|
|
1360
|
+
const messagesCompat = cached.compat as Model<"anthropic-messages">["compat"];
|
|
1361
|
+
if (
|
|
1362
|
+
cached.api === "anthropic-messages" &&
|
|
1363
|
+
cached.litellmDiscoveryVersion !== LITELLM_DISCOVERY_VERSION &&
|
|
1364
|
+
messagesCompat?.supportsStrictTools !== undefined
|
|
1365
|
+
) {
|
|
1366
|
+
const { supportsStrictTools: _staleStrictTools, ...compat } = messagesCompat;
|
|
1367
|
+
// Older discovery copied this value from the direct Anthropic catalog even
|
|
1368
|
+
// for hosted routes. Without the original deployment rows it cannot be
|
|
1369
|
+
// re-proven offline, so fail closed until the forced network refresh below.
|
|
1370
|
+
restored = { ...cached, compat: Object.keys(compat).length > 0 ? compat : undefined };
|
|
1371
|
+
}
|
|
1372
|
+
if (restored.litellmPolicy) return restored;
|
|
1373
|
+
// The cache no longer holds the deployment rows that prove a GPT-5.5+ backend, so an
|
|
1374
|
+
// OpenAI-family Chat entry fails toward the tool-reasoning workaround until refreshed.
|
|
1375
|
+
if (
|
|
1376
|
+
restored.api === "openai-completions" &&
|
|
1377
|
+
restored.litellmBackendFamily === "openai" &&
|
|
1378
|
+
isGpt55OrNewerModel(restored.id)
|
|
1379
|
+
) {
|
|
1380
|
+
return {
|
|
1381
|
+
...restored,
|
|
1382
|
+
litellmPolicy: {
|
|
1383
|
+
normalizeStrictToolMessages: false,
|
|
1384
|
+
normalizeThinkTags: false,
|
|
1385
|
+
suppressReasoningVisibility: false,
|
|
1386
|
+
dropToolReasoning: true,
|
|
1387
|
+
...(isGpt6OrNewerModel(restored.id) ? { explicitToolReasoningOff: true } : {}),
|
|
1388
|
+
},
|
|
1389
|
+
} as LiteLLMModel;
|
|
1390
|
+
}
|
|
1391
|
+
if (!hasMoonshotCompatEvidence(restored.compat)) return restored;
|
|
1392
|
+
return { ...restored, litellmPolicy: moonshotPolicy(restored.id) } as LiteLLMModel;
|
|
1195
1393
|
}
|
|
1196
1394
|
|
|
1197
1395
|
function hasResponsesReasoningControl(model: Model<Api>): boolean {
|