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/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 { 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,
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
- 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 {
136
159
  const endpoints = entry.model_info?.supported_endpoints;
137
- 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;
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
- // 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";
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(modelId: string, modeOrEntry?: string | null | ModelInfoEntry): 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 !== DEFAULT_CONTEXT_WINDOW ||
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
- if (!catalogModel) return model;
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 ? { messagesCompat: compat, messagesThinkingLevelMap: model.thinkingLevelMap } : {};
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" ? reduced.messagesCompat : vendorCompat,
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 ?? DEFAULT_CONTEXT_WINDOW,
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
- // 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,
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
- publicCatalog: PublicCatalog | undefined,
882
- ): DiscoveredModel | undefined {
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
- const parents = matchingRows
982
+ return matchingRows
894
983
  .filter((row) => row.model_name === selectedPattern)
895
- .map((row) => resolveWildcardRow(row, model.id));
896
- 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 });
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 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))
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
- return { source: "model_info", models: deduplicateModels(models) };
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 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;
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 {