pi-provider-litellm 3.2.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/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 { LITELLM_DISCOVERY_VERSION, resolveBackendIdentity, resolveCatalogProvider } from "./backend-identity.js";
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,
@@ -134,10 +141,43 @@ export function completionsCompat(
134
141
  return { supportsStore: false };
135
142
  }
136
143
 
137
- function supportsResponses(entry: ModelInfoEntry): boolean {
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 {
138
159
  const endpoints = entry.model_info?.supported_endpoints;
139
- if (Array.isArray(endpoints)) return endpoints.some((endpoint) => endpoint === "/v1/responses");
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;
140
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;
141
181
 
142
182
  const identity = resolveBackendIdentity(entry);
143
183
  if (identity?.family !== "openai") return false;
@@ -157,12 +197,16 @@ function supportsResponses(entry: ModelInfoEntry): boolean {
157
197
  return !azureAdapter;
158
198
  }
159
199
 
160
- export function modelProtocol(modelId: string, modeOrEntry?: string | null | ModelInfoEntry): ModelProtocol {
200
+ export function modelProtocol(
201
+ modelId: string,
202
+ modeOrEntry?: string | null | ModelInfoEntry,
203
+ proxyVersion?: ProxyVersion,
204
+ ): ModelProtocol {
161
205
  const selectedEntry =
162
206
  typeof modeOrEntry === "object" && modeOrEntry !== null
163
207
  ? { ...modeOrEntry, model_name: undefined }
164
208
  : { model_name: modelId, model_info: { mode: modeOrEntry } };
165
- return supportsResponses(selectedEntry)
209
+ return supportsResponses(selectedEntry, proxyVersion)
166
210
  ? { api: "openai-responses", compat: responsesCompat(modelId) }
167
211
  : { api: "openai-completions", compat: completionsCompat(modelId) };
168
212
  }
@@ -324,10 +368,20 @@ function messagesCompatOf(model: Model<Api>): MessagesBackendCompat | undefined
324
368
  const carried: MessagesBackendCompat = {};
325
369
  if (compat?.forceAdaptiveThinking !== undefined) carried.forceAdaptiveThinking = compat.forceAdaptiveThinking;
326
370
  if (compat?.supportsTemperature !== undefined) carried.supportsTemperature = compat.supportsTemperature;
327
- if (compat?.supportsStrictTools !== undefined) carried.supportsStrictTools = compat.supportsStrictTools;
328
371
  return carried;
329
372
  }
330
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
+
331
385
  function anthropicBackendLookupIds(id: string): string[] {
332
386
  const routed = (id.split("/").pop() ?? id).toLowerCase();
333
387
  const base = routed.replace(/^(?:[a-z0-9-]+\.)*anthropic[./]/, "");
@@ -353,7 +407,7 @@ const CLAUDE_MODEL_PATTERN = /(?:^|[./_-])(?:claude|opus|sonnet|haiku|fable)(?:$
353
407
 
354
408
  function nativeMessagesCatalog(
355
409
  entry: ModelInfoEntry,
356
- ): Pick<CatalogResolution, "messagesCompat" | "messagesThinkingLevelMap"> {
410
+ ): Pick<CatalogResolution, "messagesCompat" | "messagesStrictTools" | "messagesThinkingLevelMap"> {
357
411
  const adapter = wireString(entry.model_info?.litellm_provider)?.trim().toLowerCase();
358
412
  if (!adapter || !CLAUDE_CAPABLE_ADAPTERS.has(adapter) || deploymentFamily(entry) !== "claude") return {};
359
413
  const candidates = [entry.litellm_params?.model, entry.model_info?.base_model]
@@ -366,7 +420,13 @@ function nativeMessagesCatalog(
366
420
  if (models.some((model) => !model) || new Set(models.map((model) => model?.id)).size !== 1) return {};
367
421
  const model = models[0]!;
368
422
  const compat = messagesCompatOf(model);
369
- return compat ? { messagesCompat: compat, messagesThinkingLevelMap: model.thinkingLevelMap } : {};
423
+ return compat
424
+ ? {
425
+ messagesCompat: compat,
426
+ messagesStrictTools: messagesStrictToolEvidence(entry, model),
427
+ messagesThinkingLevelMap: model.thinkingLevelMap,
428
+ }
429
+ : {};
370
430
  }
371
431
 
372
432
  const ADAPTER_CATALOG_PROVIDERS: Readonly<Record<string, BuiltinProvider>> = {
@@ -601,6 +661,7 @@ function mapFromModelInfoGroup(
601
661
  defaultedContextRoutes?: Set<string>;
602
662
  denyLevels?: boolean;
603
663
  allowMessages?: boolean;
664
+ proxyVersion?: ProxyVersion;
604
665
  } = {},
605
666
  ): DiscoveredModel | undefined {
606
667
  const reduced = reduceModelGroup(entries, (entry) => {
@@ -614,7 +675,7 @@ function mapFromModelInfoGroup(
614
675
  const model = new Set(generations).size === 1 && family !== "conflicting" ? generations[0] : undefined;
615
676
  return {
616
677
  ...catalog,
617
- ...(options.allowMessages === false ? { messagesCompat: undefined } : {}),
678
+ ...(options.allowMessages === false ? { messagesCompat: undefined, messagesStrictTools: undefined } : {}),
618
679
  ...(family ? { semanticFamily: family } : {}),
619
680
  ...(model ? { semanticModel: model } : {}),
620
681
  };
@@ -623,7 +684,7 @@ function mapFromModelInfoGroup(
623
684
  if (reduced.contextWindowDefaulted) options.defaultedContextRoutes?.add(reduced.id);
624
685
  if (reduced.catalogAuthorityAmbiguous) options.ambiguousRoutes?.push(reduced.id);
625
686
  if (reduced.deploymentFamilies.includes("conflicting")) options.conflictingFamilyRoutes?.push(reduced.id);
626
- const protocols = entries.map((entry) => modelProtocol(reduced.id, entry));
687
+ const protocols = entries.map((entry) => modelProtocol(reduced.id, entry, options.proxyVersion));
627
688
  const protocol = protocols.find((candidate) => candidate.api === "openai-completions") ?? protocols[0]!;
628
689
  const api = reduced.api === "anthropic-messages" ? reduced.api : protocol.api;
629
690
  const families = new Set(entries.map((entry) => resolveBackendIdentity({ ...entry, model_name: undefined })?.family));
@@ -645,10 +706,17 @@ function mapFromModelInfoGroup(
645
706
  family === undefined ? undefined : completionsCompat(reduced.id, family),
646
707
  ),
647
708
  );
709
+ const messagesCompat =
710
+ api === "anthropic-messages"
711
+ ? {
712
+ ...reduced.messagesCompat,
713
+ ...(reduced.messagesStrictTools !== undefined ? { supportsStrictTools: reduced.messagesStrictTools } : {}),
714
+ }
715
+ : undefined;
648
716
  const policy = closeSerializerPolicy({
649
717
  api,
650
718
  reasoning,
651
- vendorCompat: api === "anthropic-messages" ? reduced.messagesCompat : vendorCompat,
719
+ vendorCompat: api === "anthropic-messages" ? messagesCompat : vendorCompat,
652
720
  semanticCompat: reduced.acceptsResponsesReasoningControl
653
721
  ? { ...reasoningPolicy?.compat, supportsReasoningEffort: true }
654
722
  : reasoningPolicy?.compat,
@@ -692,12 +760,23 @@ function mapFromModelInfoGroup(
692
760
  : {}),
693
761
  ...(families.size === 1 && family ? { litellmBackendFamily: family } : {}),
694
762
  ...(modelPolicy ? { litellmPolicy: modelPolicy } : {}),
695
- };
763
+ // closeSerializerPolicy derives compat from `api`, a correlation its return type cannot carry.
764
+ } as DiscoveredModel;
696
765
  }
697
766
 
698
767
  // An evidence-free fallback entry has no deployment or adapter evidence. Its route name
699
768
  // authorizes nothing; the Pi catalog entry for its id supplies its protocol and presentation
700
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
+
701
780
  function catalogProtocol(modelId: string, catalogModel: Model<Api> | undefined): ModelProtocol {
702
781
  return catalogModel?.api === "openai-responses"
703
782
  ? { api: "openai-responses", compat: responsesCompat(modelId) }
@@ -781,6 +860,14 @@ async function discoverFromHealth(
781
860
  const publicCatalog = deployments.some((deployment) => deployment && !deployment.synthetic)
782
861
  ? await loadDiscoveryPublicCatalog(options)
783
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;
784
871
  const incompatibleModeRoutes: string[] = [];
785
872
  const ambiguousRoutes: string[] = [];
786
873
  const conflictingFamilyRoutes: string[] = [];
@@ -799,6 +886,7 @@ async function discoverFromHealth(
799
886
  withheldRepairRoutes,
800
887
  denyLevels: group.some(({ denyLevels }) => denyLevels),
801
888
  allowMessages: false,
889
+ proxyVersion,
802
890
  });
803
891
  })
804
892
  .filter((model): model is DiscoveredModel => model !== undefined);
@@ -874,16 +962,14 @@ function wildcardPatternSpecificity(pattern: string): readonly [number, number]
874
962
  return [escaped.length, complexity];
875
963
  }
876
964
 
877
- // A concrete id expanded from a wildcard route inherits the deployment evidence from the
878
- // most-specific matching route, using LiteLLM's pattern order. Rows sharing that route still
879
- // vote together, so any deployment needing Chat keeps the child on Chat.
880
- function applyWildcardEvidence(
881
- 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,
882
969
  wildcardRows: readonly ModelInfoEntry[],
883
970
  publishedWildcardIds: ReadonlySet<string>,
884
- publicCatalog: PublicCatalog | undefined,
885
- ): DiscoveredModel | undefined {
886
- 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));
887
973
  const selectedPattern = matchingRows
888
974
  .map((row) => row.model_name!)
889
975
  .sort((left, right) => {
@@ -893,10 +979,24 @@ function applyWildcardEvidence(
893
979
  return rightLength - leftLength || leftComplexity - rightComplexity || left.localeCompare(right);
894
980
  })[0];
895
981
  if (!selectedPattern || !publishedWildcardIds.has(selectedPattern)) return undefined;
896
- const parents = matchingRows
982
+ return matchingRows
897
983
  .filter((row) => row.model_name === selectedPattern)
898
- .map((row) => resolveWildcardRow(row, model.id));
899
- const selected = mapFromModelInfoGroup(parents, publicCatalog);
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 });
900
1000
  if (!selected) return undefined;
901
1001
  const policies = [model.litellmPolicy, selected.litellmPolicy].filter((policy) => policy !== undefined);
902
1002
  const combinedPolicy =
@@ -926,7 +1026,7 @@ function applyWildcardEvidence(
926
1026
  ...(selected.litellmBackendFamily ? { litellmBackendFamily: selected.litellmBackendFamily } : {}),
927
1027
  ...(combinedPolicy ? { litellmPolicy: combinedPolicy } : {}),
928
1028
  ...(selected.litellmResponsesReasoningControl ? { litellmResponsesReasoningControl: true as const } : {}),
929
- };
1029
+ } as DiscoveredModel;
930
1030
  }
931
1031
 
932
1032
  function mapFromWildcardExpansion(
@@ -1033,6 +1133,18 @@ export async function discoverModels(
1033
1133
  groups.set(route, group);
1034
1134
  }
1035
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;
1036
1148
  const ambiguousRoutes: string[] = [];
1037
1149
  const incompatibleModeRoutes: string[] = [];
1038
1150
  const conflictingFamilyRoutes: string[] = [];
@@ -1047,6 +1159,7 @@ export async function discoverModels(
1047
1159
  conflictingFamilyRoutes,
1048
1160
  withheldRepairRoutes,
1049
1161
  defaultedContextRoutes,
1162
+ proxyVersion,
1050
1163
  }),
1051
1164
  deploymentFamilies: group.map(deploymentFamily),
1052
1165
  };
@@ -1084,19 +1197,33 @@ export async function discoverModels(
1084
1197
  const listResult = await fetchJson<ModelsListResponse>(`${base}/v1/models`, apiKey, options);
1085
1198
  if (listResult.ok && wildcards.length > 0) {
1086
1199
  const seen = new Set(models.map((model) => model.id));
1087
- const expanded = (listResult.data.data ?? [])
1088
- .filter((entry) => {
1089
- const id = wireString(entry.id);
1090
- return (
1091
- id === undefined ||
1092
- (!seen.has(id) &&
1093
- !droppedExactIds.has(id) &&
1094
- !droppedWildcards.some((route) => wildcardMatches(route, id)))
1095
- );
1096
- })
1097
- .map((entry) => mapFromWildcardExpansion(entry, wildcards, withheldRepairRoutes))
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))
1098
1225
  .filter((model): model is DiscoveredModel => model !== undefined)
1099
- .map((model) => applyWildcardEvidence(model, wildcardRows, publishedWildcardIds, publicCatalog))
1226
+ .map((model) => applyWildcardEvidence(model, wildcardRows, publishedWildcardIds, publicCatalog, proxyVersion))
1100
1227
  .filter((model): model is DiscoveredModel => model !== undefined);
1101
1228
  models = [...models, ...expanded];
1102
1229
  }
@@ -1122,7 +1249,7 @@ export async function discoverModels(
1122
1249
  "set model_info.max_input_tokens on every deployment in each route",
1123
1250
  );
1124
1251
  }
1125
- return { source: "model_info", models };
1252
+ return { source: "model_info", models, ...(proxyVersion ? { proxyVersion } : {}) };
1126
1253
  }
1127
1254
  if (![401, 403, 404].includes(infoResult.status)) {
1128
1255
  throw new Error(`/model/info returned ${infoResult.status}`);
@@ -1188,10 +1315,22 @@ function hasMoonshotCompatEvidence(compat: Model<Api>["compat"]): boolean {
1188
1315
  }
1189
1316
 
1190
1317
  export function restoreCachedModelPolicy(model: Model<Api>): Model<Api> {
1191
- const cached = model as Model<Api> & { litellmPolicy?: LiteLLMModelPolicy };
1192
- if (cached.litellmPolicy || !hasMoonshotCompatEvidence(model.compat)) return model;
1193
- const restored: typeof cached = { ...cached, litellmPolicy: moonshotPolicy(model.id) };
1194
- return restored;
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;
1195
1334
  }
1196
1335
 
1197
1336
  function hasResponsesReasoningControl(model: Model<Api>): boolean {