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/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,
@@ -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
- // Deployments expose the gpt-5.5 route under varying names (`llm-gateway/gpt-5.5`,
90
- // bare `gpt-5.5`, dated ids like `gpt-5.5-20260504143601`); match them all so the
91
- // tool+reasoning workaround survives route renames.
92
- const GPT55_MODEL_PATTERN = /(?:^|\/)gpt-5\.5(?:$|[-.])/i;
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
- export function isGpt55Model(modelId: string): boolean {
99
- return GPT55_MODEL_PATTERN.test(modelId);
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
- function supportsResponses(entry: ModelInfoEntry): boolean {
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
- if (Array.isArray(endpoints)) return endpoints.some((endpoint) => endpoint === "/v1/responses");
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(modelId: string, modeOrEntry?: string | null | ModelInfoEntry): 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 ? { messagesCompat: compat, messagesThinkingLevelMap: model.thinkingLevelMap } : {};
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" ? reduced.messagesCompat : vendorCompat,
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 modelPolicy = requestPolicy(
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
- // 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,
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
- publicCatalog: PublicCatalog | undefined,
885
- ): DiscoveredModel | undefined {
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
- const parents = matchingRows
1012
+ return matchingRows
897
1013
  .filter((row) => row.model_name === selectedPattern)
898
- .map((row) => resolveWildcardRow(row, model.id));
899
- const selected = mapFromModelInfoGroup(parents, publicCatalog);
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 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))
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 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;
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 {