pi-provider-litellm 3.0.0 → 3.1.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 CHANGED
@@ -176,6 +176,7 @@ Native Messages authenticates with `x-api-key`; every transport carries the `x-l
176
176
  | `LITELLM_DISCOVERY_TIMEOUT_MS` | `5000` | Background and explicit discovery fetch timeout in ms; `0` disables automatic discovery |
177
177
  | `LITELLM_CLI_JWT_EXPIRATION_HOURS` | `24` | CLI SSO token lifetime fallback for older proxies whose poll response omits `expires_in`; mirror a non-default proxy setting locally |
178
178
  | `LITELLM_VERBOSE_DISCOVERY` | unset | If `1`, enable progress messages during model and MCP discovery (login, refresh, startup), including MCP prepared/registered/dropped counts. Progress messages are off by default; MCP safety diagnostics (see below) are always reported regardless of this setting |
179
+ | `LITELLM_MODELS_DEV` | unset | If `1`, enrich discovered metadata (limits, prices, effort lists) from models.dev, cached for 28 days in `litellm-models-dev.json`. Off by default because LiteLLM's `/model/info` is authoritative; use it when LiteLLM's model map lacks a model's metadata |
179
180
 
180
181
  Only use a trusted `LITELLM_API_KEY_HELPER` or `!command`. Prefer an absolute executable path, keep secrets out of command arguments, and print only the token to stdout without logging it to stderr.
181
182
 
@@ -293,7 +294,7 @@ Before tagging a release, keep `package.json` and `package-lock.json` versions i
293
294
 
294
295
  Dynamic catalogs are persisted by Pi in `~/.pi/agent/models-store.json`. Credentials remain in `~/.pi/agent/auth.json`. Legacy `litellm-models.json` model caches are ignored and never deleted. `litellm-models-dev.json` is the models.dev cache and is refreshed in place.
295
296
 
296
- For every genuine `/model/info` row, including deployment details fetched through `/health`, the extension requests `https://models.dev/api.json` for enrichment and caches the result for 28 days in `~/.pi/agent/litellm-models-dev.json`. Health-only entries without deployment details use the bounded Pi catalog lookup without models.dev enrichment. `PI_OFFLINE` suppresses activation-time discovery and the models.dev request. `LITELLM_OFFLINE=1` also disables LiteLLM discovery; direct discovery callers use only an existing models.dev cache and do not refresh it.
297
+ LiteLLM's `/model/info` is the metadata authority, so models.dev enrichment is an opt-in escape hatch. With `LITELLM_MODELS_DEV=1`, for every genuine `/model/info` row, including deployment details fetched through `/health`, the extension requests `https://models.dev/api.json` for enrichment and caches the result for 28 days in `~/.pi/agent/litellm-models-dev.json`. Without it, discovery neither requests models.dev nor reads that cache; Pi's own catalog still applies. Health-only entries without deployment details use the bounded Pi catalog lookup without models.dev enrichment. `PI_OFFLINE` suppresses activation-time discovery and the models.dev request. `LITELLM_OFFLINE=1` also disables LiteLLM discovery; direct discovery callers use only an existing models.dev cache and do not refresh it.
297
298
 
298
299
  Opening `/model` refreshes configured provider catalogs in the background using Pi's native model lifecycle.
299
300
 
@@ -329,7 +330,7 @@ Discovery policy is versioned in Pi's persisted model catalog. After upgrading,
329
330
  LiteLLM may load-balance one public `model_name` across deployments with different backends or model versions. The extension reduces `/model/info` rows conservatively before publishing one Pi model. A route group that mixes a chat-style deployment with an explicitly incompatible mode such as embedding is withheld entirely and reported with a bounded diagnostic.
330
331
 
331
332
  - Responses is selected only when every deployment supports it under the protocol rules above; a deployment that requires Chat keeps the group on Chat.
332
- - Vision and reasoning are advertised only when every routable deployment resolves them as supported. An explicit `supports_reasoning: false` suppresses all thinking controls, even when catalog or router effort metadata exists. Catalog thinking controls are intersected per level: a level is exposed only when every deployment maps it to the same value, while disagreement or absence becomes an explicit denial. Reasoning selectors additionally require an accepted wire carrier on every deployment. Public effort lists are intersected, explicit LiteLLM denials win, and `xhigh`/`max` need unanimous explicit support. Null or absent standard-effort flags have no opinion.
333
+ - Vision and reasoning are advertised only when every routable deployment resolves them as supported. An explicit `supports_reasoning: false` suppresses all thinking controls, even when catalog or router effort metadata exists. Catalog thinking controls are intersected per level: a level is exposed only when every deployment maps it to the same value, while disagreement or absence becomes an explicit denial. Catalog maps are not complete effort lists, so absence has one exception: an omitted `minimal`, `low`, `medium` or `high` counts as Pi's default spelling of that level. An omitted `off`, `xhigh` or `max` still differs from an explicit mapping. Reasoning selectors additionally require an accepted wire carrier on every deployment. Public effort lists are intersected, explicit LiteLLM denials win, and `xhigh`/`max` need unanimous explicit support. Null or absent standard-effort flags have no opinion. A deployment that declares `model_info.reasoning_effort_levels` answers every level from that list, ahead of its per-level flags, as LiteLLM itself reads it.
333
334
  - Context and output limits use the minimum resolved value across deployments.
334
335
  - Each displayed base-price field uses the maximum only when every deployment resolves that field; unresolved fields remain zero and the model name is suffixed with ` (incomplete metadata)`.
335
336
  - When complete base pricing comes from the catalog under unanimous catalog authority, tiered pricing is the conservative worst-case envelope. Its usable finite non-negative thresholds are the sorted union of every deployment's thresholds, and each price field at each interval is the maximum applicable rate across all deployments, floored by each deployment's base rate. Differing tier breakpoints are therefore preserved rather than treated as incompatible. An explicit `/model/info` base-price field replaces catalog pricing for that field at every threshold; catalog tiers remain for unaffected fields and are omitted only when every base-price field is explicit.
@@ -345,7 +346,9 @@ The ` (incomplete metadata)` suffix marks reduced `/model/info` groups, incomple
345
346
 
346
347
  ### Reasoning controls
347
348
 
348
- Reasoning levels require deployment evidence for the parameter Pi sends: `reasoning_effort` for effort levels or `thinking` for a native thinking switch. Public route names never authorize these controls. Evidence-free fallback models retain catalog presentation metadata but expose no speculative selector.
349
+ Reasoning levels require deployment evidence for the parameter Pi sends: `reasoning_effort` for effort levels or `thinking` for a native thinking switch. Public route names never authorize these controls.
350
+
351
+ LiteLLM omits `supported_openai_params` for a deployment its model map does not describe. Such a deployment keeps the `reasoning_effort` carrier when its `model_info` explicitly sets `supports_reasoning: true`, which is the operator's opt-in. A `supported_openai_params` list that omits `reasoning_effort` still denies effort levels unless `litellm_params.allowed_openai_params` adds it. Kimi and DeepSeek generations are excluded because their contracts name their own carriers. Evidence-free fallback models retain catalog presentation metadata but expose no speculative selector.
349
352
 
350
353
  Kimi K2.5/K2.6 use an on/off thinking switch; K2.7 Code/Highspeed is always thinking. Kimi K3 and DeepSeek V4 use public effort evidence with LiteLLM overrides. Responses levels are translated to valid `reasoning.effort` values; Chat-only compatibility fields never reach Responses models. Legacy cached level maps remain usable while discovery refreshes, and stored Moonshot compatibility can restore safe display normalization.
351
354
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-provider-litellm",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "LiteLLM proxy provider extension for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/discover.ts CHANGED
@@ -399,7 +399,7 @@ function adaptPublicCatalogRecord(
399
399
  catalogModelId: string,
400
400
  record: PublicCatalogRecord | undefined,
401
401
  ): CatalogResolution {
402
- const piProvider = adapterCatalogProvider(record?.provider ?? provider);
402
+ const piProvider = adapterCatalogProvider(record?.piProvider ?? record?.provider ?? provider);
403
403
  const resolved = piProvider
404
404
  ? (resolveCatalogModel(record?.modelId ?? catalogModelId, piProvider) ??
405
405
  resolveCatalogModel(catalogModelId, piProvider))
package/src/index.ts CHANGED
@@ -31,7 +31,14 @@ import {
31
31
  } from "./mcp-tools.js";
32
32
  import { createLiteLLMProvider, DEFAULT_LITELLM_BASE_URL, isPlaceholderHost, toNativeModels } from "./provider.js";
33
33
  import { createSkillsPromptSection, createSkillToolDefinitions, listSkills } from "./skills.js";
34
- import type { LiteLLMApi, LiteLLMModel, LiteLLMModelPolicy, LiteLLMRuntimeAuth, ResolvedCredentials } from "./types.js";
34
+ import type {
35
+ DiscoveryOptions,
36
+ LiteLLMApi,
37
+ LiteLLMModel,
38
+ LiteLLMModelPolicy,
39
+ LiteLLMRuntimeAuth,
40
+ ResolvedCredentials,
41
+ } from "./types.js";
35
42
 
36
43
  const PROVIDER_NAME = "litellm";
37
44
  const SETTINGS_KEY = "litellm";
@@ -44,6 +51,7 @@ const ENV_TIMEOUT = "LITELLM_DISCOVERY_TIMEOUT_MS";
44
51
  const ENV_CLI_JWT_EXPIRATION_HOURS = "LITELLM_CLI_JWT_EXPIRATION_HOURS";
45
52
  const ENV_OFFLINE = "LITELLM_OFFLINE";
46
53
  const ENV_VERBOSE_DISCOVERY = "LITELLM_VERBOSE_DISCOVERY";
54
+ const ENV_MODELS_DEV = "LITELLM_MODELS_DEV";
47
55
  const MODELS_DEV_CACHE_FILENAME = "litellm-models-dev.json";
48
56
  const DEFAULT_TIMEOUT_MS = 5000;
49
57
  const SEED_TIMEOUT_MS = 3000;
@@ -444,6 +452,16 @@ function isHostOffline(): boolean {
444
452
  return process.env.PI_OFFLINE !== undefined;
445
453
  }
446
454
 
455
+ /**
456
+ * LiteLLM's /model/info is the metadata authority, so models.dev is an opt-in escape hatch.
457
+ * Without a cache path and with refresh suppressed, discovery reads neither the models.dev
458
+ * network nor a cache left by an earlier release; Pi's own catalog still applies.
459
+ */
460
+ function modelsDevDiscoveryOptions(): Pick<DiscoveryOptions, "modelsDev" | "modelsDevCachePath"> {
461
+ if (process.env[ENV_MODELS_DEV] !== "1") return { modelsDev: false };
462
+ return { modelsDev: !isHostOffline(), modelsDevCachePath: join(getAgentDir(), MODELS_DEV_CACHE_FILENAME) };
463
+ }
464
+
447
465
  /**
448
466
  * Absolute budget for activation-time discovery. Pi cannot paint its UI until every extension has
449
467
  * activated, so this is deliberately capped rather than following LITELLM_DISCOVERY_TIMEOUT_MS,
@@ -1125,16 +1143,25 @@ async function refreshLiteLLM(
1125
1143
  return { ...credentials, access, expires: tokenExpiresAt(access, EXPIRE_TOKEN_IMMEDIATELY) };
1126
1144
  }
1127
1145
 
1146
+ async function configuredBaseUrl(
1147
+ definition: ProviderDefinition,
1148
+ ctx: { env(name: string): Promise<string | undefined> },
1149
+ credential?: ApiKeyCredential,
1150
+ ): Promise<string | undefined> {
1151
+ return (
1152
+ cleanConfig(credential?.env?.[ENV_BASE_URL]) ??
1153
+ cleanConfig(definition.baseUrl) ??
1154
+ (definition.useDefaultEnv ? cleanConfig(await ctx.env(ENV_BASE_URL)) : undefined)
1155
+ );
1156
+ }
1157
+
1128
1158
  async function resolveApiKeyAuth(
1129
1159
  definition: ProviderDefinition,
1130
1160
  ctx: { env(name: string): Promise<string | undefined> },
1131
1161
  credential?: ApiKeyCredential,
1132
1162
  executeHelpers = true,
1133
1163
  ) {
1134
- const baseUrl =
1135
- cleanConfig(credential?.env?.[ENV_BASE_URL]) ??
1136
- cleanConfig(definition.baseUrl) ??
1137
- (definition.useDefaultEnv ? cleanConfig(await ctx.env(ENV_BASE_URL)) : undefined);
1164
+ const baseUrl = await configuredBaseUrl(definition, ctx, credential);
1138
1165
  const stored = credential?.key
1139
1166
  ? resolveConfigValue(credential.key, { executeCommands: executeHelpers })?.trim()
1140
1167
  : undefined;
@@ -1209,6 +1236,7 @@ function createProviderAuth(
1209
1236
  definition: ProviderDefinition,
1210
1237
  clearOAuthRuntimeRoot?: () => void,
1211
1238
  onLogin?: () => void,
1239
+ oauthRuntimeRoot?: () => { apiKey: string; root: string } | undefined,
1212
1240
  ): ProviderAuth {
1213
1241
  function completeLogin<T extends Credential>(credential: T): T {
1214
1242
  onLogin?.();
@@ -1255,6 +1283,34 @@ function createProviderAuth(
1255
1283
  return fallback ? { type: "api_key", source: fallback } : undefined;
1256
1284
  },
1257
1285
  resolve: async ({ ctx, credential }) => {
1286
+ // Pi re-resolves auth through this api-key path whenever a caller passes an explicit
1287
+ // apiKey override — compaction and summarization both hand back the key they just
1288
+ // resolved — which bypasses the stored OAuth credential carrying the base URL. The
1289
+ // api-key path exports its root as env so it survives that round trip, but OAuth's
1290
+ // toAuth has no env to export, leaving an SSO session no record of its root. Read it
1291
+ // back from auth.json, the same fallback check() and seedModels() use, and export it
1292
+ // so the request carries the root rather than depending on in-memory state. auth.json is
1293
+ // shared, though: another Pi process logging in replaces the stored token while this one
1294
+ // still holds its own, so fall back to the root this process resolved for that exact
1295
+ // token. Only when nothing configures a root at all: a base URL that is set still
1296
+ // resolves, and still fails, on its own terms rather than silently rerouting.
1297
+ if (credential?.key && !(await configuredBaseUrl(definition, ctx, credential))) {
1298
+ const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
1299
+ const remembered = oauthRuntimeRoot?.();
1300
+ const root =
1301
+ stored?.type === "oauth" && stored.access === credential.key
1302
+ ? requireCredentialRoot(resolveCredentialRoot(definition, stored), definition.name)
1303
+ : remembered?.apiKey === credential.key
1304
+ ? remembered.root
1305
+ : undefined;
1306
+ if (root) {
1307
+ return {
1308
+ auth: { apiKey: credential.key, headers: resolveHeaders(definition), baseUrl: root },
1309
+ env: { [ENV_BASE_URL]: root },
1310
+ source: "OAuth",
1311
+ };
1312
+ }
1313
+ }
1258
1314
  clearOAuthRuntimeRoot?.();
1259
1315
  return resolveApiKeyAuth(definition, ctx, credential);
1260
1316
  },
@@ -1828,8 +1884,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1828
1884
  signal,
1829
1885
  headers: auth.headers,
1830
1886
  allowInsecureHttp: auth.allowInsecureHttp,
1831
- modelsDev: !isHostOffline(),
1832
- modelsDevCachePath: join(getAgentDir(), MODELS_DEV_CACHE_FILENAME),
1887
+ ...modelsDevDiscoveryOptions(),
1833
1888
  silent: !isVerboseDiscovery(),
1834
1889
  onProgress: isVerboseDiscovery() ? (message) => process.stderr.write(`LiteLLM: ${message}\n`) : undefined,
1835
1890
  });
@@ -1867,6 +1922,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1867
1922
  definition,
1868
1923
  () => oauthRuntimeRoots.delete(definition.name),
1869
1924
  definition.name === PROVIDER_NAME ? resumeMcpDiscovery : undefined,
1925
+ () => oauthRuntimeRoots.get(definition.name),
1870
1926
  );
1871
1927
  if (auth.oauth) {
1872
1928
  if (definition.name === PROVIDER_NAME) {
@@ -169,19 +169,29 @@ function explicitLimit(value: number | undefined): number | undefined {
169
169
 
170
170
  const EXTENDED_LEVELS = THINKING_LEVEL_DEFINITIONS.map(([level]) => level);
171
171
 
172
- const LITELLM_LEVEL_FLAGS = {
173
- off: "supports_none_reasoning_effort",
174
- minimal: "supports_minimal_reasoning_effort",
175
- low: "supports_low_reasoning_effort",
176
- xhigh: "supports_xhigh_reasoning_effort",
177
- max: "supports_max_reasoning_effort",
178
- } as const;
172
+ // Derived from the level table rather than restated: a second hand-written copy
173
+ // silently dropped `supports_medium_reasoning_effort` and
174
+ // `supports_high_reasoning_effort`, so those router flags were never read.
175
+ const LITELLM_LEVEL_FLAGS = Object.fromEntries(THINKING_LEVEL_DEFINITIONS.map(([level, , flag]) => [level, flag])) as {
176
+ [Definition in (typeof THINKING_LEVEL_DEFINITIONS)[number] as Definition[0]]: Definition[2];
177
+ };
179
178
 
180
179
  function normalizeEffort(level: string): (typeof EXTENDED_LEVELS)[number] | undefined {
181
180
  const normalized = level === "none" ? "off" : level;
182
181
  return EXTENDED_LEVELS.find((candidate) => candidate === normalized);
183
182
  }
184
183
 
184
+ // LiteLLM reads a declared `reasoning_effort_levels` list whole, ahead of the
185
+ // per-level flags, because flags cannot state a set such as low/high/max: medium
186
+ // has no opt-out. A declared list therefore answers every level for its deployment.
187
+ function reportedLevel(entry: ModelInfoEntry, level: (typeof EXTENDED_LEVELS)[number]): boolean | undefined {
188
+ const declared: unknown = entry.model_info?.reasoning_effort_levels;
189
+ if (Array.isArray(declared)) {
190
+ return declared.some((effort) => typeof effort === "string" && normalizeEffort(effort) === level);
191
+ }
192
+ return wireBoolean(entry.model_info?.[LITELLM_LEVEL_FLAGS[level]]);
193
+ }
194
+
185
195
  // A public effort list is complete: listed standard levels are enabled and omitted
186
196
  // ones are denied. Without a public list, standard levels stay absent so Pi keeps
187
197
  // its defaults. LiteLLM flags may add or remove levels, while xhigh/max always
@@ -201,23 +211,25 @@ function reasoningLevelMap(
201
211
  map[level] = publicSets.every((set) => set.has(level)) ? (level === "off" ? "none" : level) : null;
202
212
  }
203
213
  }
204
- // A catalog level map is direct evidence even when its selectable projection
205
- // is empty. Preserve its denials independently from public effort lists, whose
206
- // unrecognized values intentionally have no opinion.
207
- for (const catalogMap of catalogMaps) Object.assign(map, deniedLevels(catalogMap));
208
-
209
- for (const [level, flag] of Object.entries(LITELLM_LEVEL_FLAGS) as Array<
210
- [keyof typeof LITELLM_LEVEL_FLAGS, (typeof LITELLM_LEVEL_FLAGS)[keyof typeof LITELLM_LEVEL_FLAGS]]
211
- >) {
212
- const reported = entries.map((entry) => wireBoolean(entry.model_info?.[flag]));
214
+ // A catalog level map is not a complete list: a null denies, a value supplies the
215
+ // wire spelling, and an omitted standard level keeps Pi's default. Flattening it
216
+ // into a list denied every standard level a map left implicit, such as Codex
217
+ // `gpt-5.6-sol`, which only states `xhigh`, `max`, and `minimal`.
218
+ for (const catalogMap of catalogMaps) {
219
+ for (const [level, value] of Object.entries(catalogMap ?? {}) as [keyof typeof map, string | null][]) {
220
+ if (value === null || (value !== undefined && map[level] !== null)) map[level] = value;
221
+ }
222
+ }
223
+
224
+ for (const level of EXTENDED_LEVELS) {
225
+ const reported = entries.map((entry) => reportedLevel(entry, level));
213
226
  if (reported.some((value) => value === false)) map[level] = null;
214
227
  else if (reported.length > 0 && reported.every((value) => value === true)) {
215
228
  map[level] = level === "off" ? "none" : level;
216
229
  }
217
230
  }
218
231
  for (const level of ["xhigh", "max"] as const) {
219
- const flag = LITELLM_LEVEL_FLAGS[level];
220
- if (!entries.every((entry) => wireBoolean(entry.model_info?.[flag]) === true)) map[level] = null;
232
+ if (!entries.every((entry) => reportedLevel(entry, level) === true)) map[level] = null;
221
233
  }
222
234
  return map;
223
235
  }
@@ -242,6 +254,15 @@ function acceptedParams(entry: ModelInfoEntry): Set<string> {
242
254
  return params;
243
255
  }
244
256
 
257
+ // LiteLLM omits supported_openai_params (or returns null) for a deployment its
258
+ // model map does not describe, so that absence says nothing about the carrier. An
259
+ // explicit `supports_reasoning: true` is the operator's opt-in there, as in
260
+ // LiteLLM's own effort resolution. Present but malformed data is not an absence.
261
+ function optsIntoEffortCarrier(entry: ModelInfoEntry): boolean {
262
+ const supported: unknown = entry.model_info?.supported_openai_params;
263
+ return (supported === undefined || supported === null) && wireBoolean(entry.model_info?.supports_reasoning) === true;
264
+ }
265
+
245
266
  function intersectParams(entries: readonly ModelInfoEntry[]): string[] {
246
267
  const [first, ...rest] = entries.map(acceptedParams);
247
268
  if (!first) return [];
@@ -273,9 +294,10 @@ export const NO_TRANSMISSIBLE_LEVELS = {
273
294
 
274
295
  // Efforts the Responses API accepts. pi-ai passes an unmapped level through
275
296
  // verbatim and reads `thinkingLevelMap.off` as the disable value, so a Chat-shaped
276
- // map would emit `off` or `max` as an effort. `none` is the disable spelling —
277
- // pi-ai's own `openai/gpt-5.5` entry maps `off` to it.
278
- const RESPONSES_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh"]);
297
+ // map would emit `off` as an effort. `none` is the disable spelling — pi-ai's own
298
+ // `openai/gpt-5.5` entry maps `off` to it, and its `openai/gpt-5.6-*` entries map
299
+ // `max` to itself, so `max` is a real Responses effort and not a Chat-only value.
300
+ const RESPONSES_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);
279
301
 
280
302
  // A level map is only meaningful next to the compat that serializes it, and the
281
303
  // two used to travel separately: five call sites each decided whether to copy
@@ -830,16 +852,17 @@ export function reduceModelGroup(
830
852
  if (tiers) cost.tiers = tiers;
831
853
  }
832
854
  const acceptedOpenAIParams = intersectParams(deployments);
833
- const acceptsResponsesReasoningControl = acceptedOpenAIParams.includes("reasoning_effort");
834
- const publicEfforts = catalogAuthority.map(
835
- (catalog) =>
836
- catalog?.effortLevels ??
837
- (catalog?.thinkingLevelMap
838
- ? Object.entries(catalog.thinkingLevelMap)
839
- .filter(([, value]) => value !== null)
840
- .map(([level]) => level)
841
- : undefined),
855
+ // Kimi and DeepSeek generations name their own carriers, so an operator opt-in
856
+ // cannot substitute for the parameter evidence their contracts require.
857
+ const namedCarrierFamily = catalogs.some((catalog) =>
858
+ ["kimi", "deepseek", "conflicting"].includes(catalog?.semanticFamily ?? ""),
842
859
  );
860
+ const acceptsResponsesReasoningControl =
861
+ acceptedOpenAIParams.includes("reasoning_effort") ||
862
+ (!namedCarrierFamily &&
863
+ deployments.length > 0 &&
864
+ deployments.every((entry) => acceptedParams(entry).has("reasoning_effort") || optsIntoEffortCarrier(entry)));
865
+ const publicEfforts = catalogAuthority.map((catalog) => catalog?.effortLevels);
843
866
  const evidenceLevelMap = reasoningLevelMap(deployments, publicEfforts, [
844
867
  intersectThinkingLevelMaps(catalogAuthority.map((catalog) => catalog?.thinkingLevelMap)),
845
868
  ]);
@@ -848,10 +871,8 @@ export function reduceModelGroup(
848
871
  thinkingLevelMap = intersectThinkingLevelMaps(catalogs.map((catalog) => catalog?.messagesThinkingLevelMap));
849
872
  // Native serializer restrictions apply even when catalog pricing is withheld.
850
873
  // Router flags can also deny Pi's implicit default levels, never add a level.
851
- for (const [level, flag] of Object.entries(LITELLM_LEVEL_FLAGS) as Array<
852
- [keyof typeof LITELLM_LEVEL_FLAGS, (typeof LITELLM_LEVEL_FLAGS)[keyof typeof LITELLM_LEVEL_FLAGS]]
853
- >) {
854
- const reported = deployments.map((entry) => wireBoolean(entry.model_info?.[flag]));
874
+ for (const level of EXTENDED_LEVELS) {
875
+ const reported = deployments.map((entry) => reportedLevel(entry, level));
855
876
  if (
856
877
  reported.some((value) => value === false) ||
857
878
  ((level === "xhigh" || level === "max") &&
@@ -886,9 +907,7 @@ export function reduceModelGroup(
886
907
  const semanticMap = semanticReasoningPolicy.thinkingLevelMap;
887
908
  const hasEvidenceLevels = Object.values(evidenceLevelMap ?? {}).some((level) => level !== null);
888
909
  const semanticBinary = semanticReasoningPolicy.compat?.supportsReasoningEffort === false && !hasEvidenceLevels;
889
- const explicitOffDenial = deployments.some(
890
- (entry) => wireBoolean(entry.model_info?.supports_none_reasoning_effort) === false,
891
- );
910
+ const explicitOffDenial = deployments.some((entry) => reportedLevel(entry, "off") === false);
892
911
  const reasoningPolicy = semanticMap
893
912
  ? {
894
913
  ...semanticReasoningPolicy,
@@ -933,11 +952,6 @@ export function catalogResolution(provider: string, model: Model<Api>): CatalogR
933
952
  provider,
934
953
  catalogModelId: model.id,
935
954
  reasoning: model.reasoning,
936
- effortLevels: model.thinkingLevelMap
937
- ? Object.entries(model.thinkingLevelMap)
938
- .filter(([, value]) => value !== null)
939
- .map(([level]) => level)
940
- : undefined,
941
955
  thinkingLevelMap: model.thinkingLevelMap,
942
956
  vision: model.input.includes("image"),
943
957
  contextWindow: model.contextWindow,
@@ -12,6 +12,9 @@ const KNOWN_PROVIDERS = new Set<string>(getProviders());
12
12
  export interface PublicCatalogRecord {
13
13
  source: "models.dev" | "pi-vendor" | "pi-adapter";
14
14
  provider: string;
15
+ // The Pi catalog for fields a models.dev record omits, when the record was found
16
+ // under another vendor's key (ChatGPT routes read OpenAI's but bill as Codex).
17
+ piProvider?: string;
15
18
  modelId: string;
16
19
  limits?: { context?: number; output?: number };
17
20
  cost?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
@@ -54,6 +57,8 @@ const PROVIDER_ALIASES: Readonly<Record<string, readonly string[]>> = {
54
57
  azure_ai: ["azure", "openai"],
55
58
  bedrock: ["amazon-bedrock"],
56
59
  bedrock_converse: ["amazon-bedrock"],
60
+ // models.dev has no ChatGPT subscription provider; its routes serve OpenAI models.
61
+ chatgpt: ["chatgpt", "openai"],
57
62
  deepseek: ["deepseek"],
58
63
  fireworks: ["fireworks-ai"],
59
64
  fireworks_ai: ["fireworks-ai"],
@@ -199,11 +204,6 @@ function mapModelsDev(provider: string, modelId: string, model: ModelsDevModel):
199
204
 
200
205
  function toPiRecord(source: "pi-vendor" | "pi-adapter", provider: string, model: Model<Api>): PublicCatalogRecord {
201
206
  const thinkingLevelMap = model.thinkingLevelMap as Record<string, unknown> | undefined;
202
- const effortLevels = thinkingLevelMap
203
- ? Object.entries(thinkingLevelMap)
204
- .filter(([, value]) => value !== null)
205
- .map(([level]) => level)
206
- : [];
207
207
  return {
208
208
  source,
209
209
  provider,
@@ -216,7 +216,6 @@ function toPiRecord(source: "pi-vendor" | "pi-adapter", provider: string, model:
216
216
  cacheWrite: model.cost.cacheWrite,
217
217
  },
218
218
  modalities: model.input.includes("image") ? ["text", "image"] : ["text"],
219
- ...(effortLevels.length > 0 ? { effortLevels } : {}),
220
219
  ...(thinkingLevelMap ? { thinkingLevelMap } : {}),
221
220
  };
222
221
  }
@@ -245,14 +244,17 @@ export async function loadPublicCatalog(options: LoadPublicCatalogOptions = {}):
245
244
  lookup(provider, id) {
246
245
  const providers = providerCandidates(provider);
247
246
  const ids = lookupIds(id);
247
+ const vendor = providers.find((candidate) => candidate !== "azure") ?? providers[0];
248
248
  for (const candidate of providers) {
249
249
  const models = catalog?.[candidate]?.models;
250
250
  for (const modelId of ids) {
251
251
  const model = models?.[modelId];
252
- if (model) return mapModelsDev(candidate, modelId, model);
252
+ if (!model) continue;
253
+ const record = mapModelsDev(candidate, modelId, model);
254
+ if (candidate === providers[0] || !vendor) return record;
255
+ return { ...record, piProvider: (PI_PROVIDER_ALIASES[vendor] ?? [vendor])[0] };
253
256
  }
254
257
  }
255
- const vendor = providers.find((candidate) => candidate !== "azure") ?? providers[0];
256
258
  if (vendor) {
257
259
  for (const piProvider of PI_PROVIDER_ALIASES[vendor] ?? [vendor]) {
258
260
  const model = findPiModel(piProvider, ids);
@@ -5,8 +5,8 @@ export const THINKING_LEVEL_DEFINITIONS = [
5
5
  ["off", "none", "supports_none_reasoning_effort"],
6
6
  ["minimal", "minimal", "supports_minimal_reasoning_effort"],
7
7
  ["low", "low", "supports_low_reasoning_effort"],
8
- ["medium", "medium", undefined],
9
- ["high", "high", undefined],
8
+ ["medium", "medium", "supports_medium_reasoning_effort"],
9
+ ["high", "high", "supports_high_reasoning_effort"],
10
10
  ["xhigh", "xhigh", "supports_xhigh_reasoning_effort"],
11
11
  ["max", "max", "supports_max_reasoning_effort"],
12
12
  ] as const;
@@ -19,6 +19,10 @@ type ThinkingLevelDefinitionsAreExhaustive = AssertNever<
19
19
 
20
20
  type ThinkingLevelMap = DiscoveredModel["thinkingLevelMap"];
21
21
 
22
+ // Pi sends an omitted standard level as its own name. An omitted `off` sends no
23
+ // disable value and omitted `xhigh`/`max` are unsupported, so those stay distinct.
24
+ const IMPLICIT_LEVELS = new Set<string>(["minimal", "low", "medium", "high"]);
25
+
22
26
  // Intersects independently authoritative maps. A level mentioned by any source
23
27
  // is denied unless every source supplies the same mapping for that level.
24
28
  export function intersectThinkingLevelMaps(maps: readonly ThinkingLevelMap[]): ThinkingLevelMap {
@@ -26,8 +30,9 @@ export function intersectThinkingLevelMaps(maps: readonly ThinkingLevelMap[]): T
26
30
 
27
31
  const intersection: NonNullable<ThinkingLevelMap> = {};
28
32
  for (const [level] of THINKING_LEVEL_DEFINITIONS) {
29
- const values = maps.map((map) => map?.[level]);
30
- if (values.every((value) => value === undefined)) continue;
33
+ const stated = maps.map((map) => map?.[level]);
34
+ if (stated.every((value) => value === undefined)) continue;
35
+ const values = stated.map((value) => (value === undefined && IMPLICIT_LEVELS.has(level) ? level : value));
31
36
  const first = values[0];
32
37
  intersection[level] = first !== undefined && values.every((value) => value === first) ? first : null;
33
38
  }
package/src/types.ts CHANGED
@@ -63,6 +63,8 @@ export interface DiscoveryOptions {
63
63
  signal?: AbortSignal;
64
64
  headers?: Record<string, string>;
65
65
  allowInsecureHttp?: boolean;
66
+ // `false` reads only an existing cache at `modelsDevCachePath`; with no cache
67
+ // path, models.dev is not consulted at all.
66
68
  modelsDev?: boolean;
67
69
  modelsDevCachePath?: string;
68
70
  }
@@ -89,9 +91,12 @@ export interface ModelInfoEntry {
89
91
  max_input_tokens?: number;
90
92
  max_output_tokens?: number;
91
93
  supports_reasoning?: boolean;
94
+ reasoning_effort_levels?: string[];
92
95
  supports_none_reasoning_effort?: boolean;
93
96
  supports_minimal_reasoning_effort?: boolean;
94
97
  supports_low_reasoning_effort?: boolean;
98
+ supports_medium_reasoning_effort?: boolean;
99
+ supports_high_reasoning_effort?: boolean;
95
100
  supports_xhigh_reasoning_effort?: boolean;
96
101
  supports_max_reasoning_effort?: boolean;
97
102
  supports_vision?: boolean;