pi-provider-litellm 1.2.9 → 2.0.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
@@ -1,8 +1,8 @@
1
1
  # pi-provider-litellm
2
2
 
3
- LiteLLM proxy provider extension for [Pi](https://pi.dev).
3
+ LiteLLM proxy native Provider extension for [Pi](https://pi.dev). Pi 0.81.0+ is required.
4
4
 
5
- Discovers models from self-hosted LiteLLM proxies and registers them under Pi providers. The default provider is `litellm`; optional aliases can register additional LiteLLM providers with separate credentials. Supports `/login litellm`, `/litellm-refresh`, LiteLLM MCP tools, LiteLLM Skills Gateway prompt injection, and Google ADC token auth. Tries `/model/info` first (admin endpoint with rich metadata), falls back to `/v1/models` (OpenAI-compatible) on 401/403/404, then tries `/health` plus per-endpoint `/model/info` for older LiteLLM proxies.
5
+ Discovers models from self-hosted LiteLLM proxies and registers them under Pi providers. The default provider is `litellm`; optional aliases can register additional LiteLLM providers with separate credentials. Supports `/login litellm`, LiteLLM MCP tools, LiteLLM Skills Gateway prompt injection, and Google ADC token auth. Tries `/model/info` first (admin endpoint with rich metadata), falls back to `/v1/models` (OpenAI-compatible) on 401/403/404, then tries `/health` plus per-endpoint `/model/info` for older LiteLLM proxies.
6
6
 
7
7
  ## Install
8
8
 
@@ -40,7 +40,7 @@ Inside pi:
40
40
  /login litellm
41
41
  ```
42
42
 
43
- You can also run `/login`, select `Use a subscription`, then select `LiteLLM`.
43
+ To configure an API key, run `/login`, choose `Sign in with an API key`, then choose `LiteLLM API key`. With `/login litellm`, choose `Sign in with an API key` directly.
44
44
 
45
45
  You'll be prompted for the base URL and API key. Credentials are persisted to `~/.pi/agent/auth.json`.
46
46
 
@@ -48,12 +48,11 @@ You'll be prompted for the base URL and API key. Credentials are persisted to `~
48
48
 
49
49
  If your LiteLLM proxy requires SSO/OAuth authentication (enterprise deployments), you can authenticate via a browser SSO flow and optionally pair the resulting JWT with a stable virtual key:
50
50
 
51
- 1. Run `/login litellm` inside pi
51
+ 1. Run `/login litellm` inside pi and select `Sign in with LiteLLM SSO`
52
52
  2. Enter the proxy URL
53
- 3. At the login method prompt, enter `2` for SSO / Enterprise JWT
54
- 4. Your default browser opens the LiteLLM SSO login URL (e.g. `https://litellm.your-domain.com/sso/key/generate`) automatically — the URL is also displayed in case it can't be opened. Authenticate via SSO
55
- 5. Copy your token from the LiteLLM UI and paste it at the prompt (copying a full `Bearer ...` header value is fine — the prefix is stripped automatically)
56
- 6. When prompted to generate a virtual key, press Enter to accept (recommended) or enter `n` to use the JWT directly
53
+ 3. Your default browser opens the LiteLLM SSO login URL (e.g. `https://litellm.your-domain.com/sso/key/generate`) automatically — the URL is also displayed in case it can't be opened. Authenticate via SSO
54
+ 4. Copy your token from the LiteLLM UI and paste it at the prompt (copying a full `Bearer ...` header value is fine — the prefix is stripped automatically)
55
+ 5. When prompted to generate a virtual key, press Enter to accept (recommended) or enter `n` to use the JWT directly
57
56
 
58
57
  When you generate a virtual key, the resulting `sk-...` key is stored as your credential and used for all API requests. If the proxy's key policy attaches an expiry to the generated key, Pi will prompt you to re-authenticate when it nears expiry; otherwise the key is treated as permanent until revoked in LiteLLM.
59
58
 
@@ -119,6 +118,25 @@ Provider fields:
119
118
 
120
119
  `/login litellm` and Google ADC token auth remain scoped to the default `litellm` provider. Aliases use their configured `apiKey` or manually stored auth entries matching the alias name.
121
120
 
121
+ ### Optional LiteLLM features
122
+
123
+ LiteLLM Skills and MCP integration are enabled by default. Disable either feature globally in `~/.pi/agent/settings.json`:
124
+
125
+ ```json
126
+ {
127
+ "litellm": {
128
+ "skills": {
129
+ "enabled": false
130
+ },
131
+ "mcp": {
132
+ "enabled": false
133
+ }
134
+ }
135
+ }
136
+ ```
137
+
138
+ Setting `skills.enabled` to `false` disables the Skills Gateway management tools, skill fetching, and system-prompt injection. Setting `mcp.enabled` to `false` disables LiteLLM MCP discovery and tool registration. Restart Pi after changing these settings so previously registered tools are removed.
139
+
122
140
  ## Use
123
141
 
124
142
  ```
@@ -133,10 +151,14 @@ Provider fields:
133
151
  | `LITELLM_HEADERS` | unset | JSON object of extra headers sent to LiteLLM provider, discovery, MCP, and Skills Gateway requests. Provider aliases can use it with `"headers": "$LITELLM_HEADERS"`. |
134
152
  | `LITELLM_GCLOUD_TOKEN_AUTH` | unset | If set to a non-empty value other than `0`, use Google Application Default Credentials as the LiteLLM bearer token source. This takes precedence over `LITELLM_API_KEY_HELPER` and `LITELLM_API_KEY` when no stored `/login litellm` credential exists. |
135
153
  | `GOOGLE_APPLICATION_CREDENTIALS` | Google default ADC path | Optional path to an ADC JSON file used by `LITELLM_GCLOUD_TOKEN_AUTH`. If unset, the extension checks the default gcloud ADC locations. |
136
- | `LITELLM_OFFLINE` | unset | If `1`, skip discovery on this start; use cache only |
137
- | `LITELLM_DISCOVERY_TIMEOUT_MS` | `5000` | Discovery fetch timeout in ms; `0` to skip discovery |
154
+ | `LITELLM_OFFLINE` | unset | If `1`, disable all model and MCP discovery, including post-login discovery; use cached models only |
155
+ | `LITELLM_DISCOVERY_TIMEOUT_MS` | `5000` | Background and explicit discovery fetch timeout in ms; `0` disables automatic discovery |
156
+ | `LITELLM_VERBOSE_DISCOVERY` | unset | If `1`, enable progress messages during model and MCP discovery (login, refresh, startup); discovery is silent by default |
157
+ | `LITELLM_MODELS_DEV` | enabled | Set to `0` to disable models.dev metadata enrichment, including its cache and network request; `/v1/models` still uses Pi catalog metadata and defaults |
138
158
 
139
- `LITELLM_DISCOVERY_TIMEOUT_MS=0` only disables startup and refresh model discovery. It does not replace the base URL or API key settings required to send requests when you are not using `/login litellm`.
159
+ `LITELLM_DISCOVERY_TIMEOUT_MS=0` disables automatic and explicit refresh model discovery. It does not replace the base URL or API key settings required to send requests when you are not using `/login litellm`.
160
+
161
+ Models.dev metadata is cached in `litellm-models-dev.json` under the Pi agent directory for 28 days. Fresh data avoids the public request; stale data is used immediately while one background refresh updates the cache. Set `LITELLM_MODELS_DEV=0` when your LiteLLM metadata is authoritative and no external enrichment is wanted.
140
162
 
141
163
  ### Google ADC token auth
142
164
 
@@ -157,7 +179,7 @@ If your LiteLLM proxy exposes MCP REST endpoints, this extension discovers tools
157
179
  - `GET /mcp-rest/tools/list`
158
180
  - `POST /mcp-rest/tools/call`
159
181
 
160
- Each discovered tool is registered as a native Pi tool named `mcp_<server>_<tool>`, with simple JSON Schema parameters mapped to Pi/TypeBox parameters. Complex schemas fall back to a single `args` object. MCP discovery runs after successful live model discovery, `/login litellm`, or `/litellm-refresh`; it does not force network or helper-token access when a fresh model cache is used. MCP tools run in Pi's parallel tool mode and retry transient failures once.
182
+ Each discovered tool is registered as a native Pi tool named `mcp_<server>_<tool>`, with simple JSON Schema parameters mapped to Pi/TypeBox parameters. Complex schemas fall back to a single `args` object. MCP discovery runs after Pi refreshes LiteLLM models or after `/login litellm`; extension activation never waits for it. MCP tools run in Pi's parallel tool mode and retry transient failures once.
161
183
 
162
184
  ## LiteLLM Skill Hub
163
185
 
@@ -171,11 +193,11 @@ If your LiteLLM proxy exposes `/claude-code/marketplace.json`, enabled skills ar
171
193
 
172
194
  The `LiteLLM Smoke` GitHub Actions workflow starts VidaiMock and a real LiteLLM proxy on the runner. LiteLLM exposes OpenAI-compatible and Anthropic routes whose upstreams are served by VidaiMock, then this extension's smoke runner discovers those models through LiteLLM and sends `/v1/chat/completions` requests through the proxy.
173
195
 
174
- This keeps the LiteLLM integration path under test but does not call real LLM APIs. No provider API keys or GitHub Models permission are required. The smoke runner also asserts that discovery came from `/model/info` (`LITELLM_SMOKE_EXPECT_SOURCE`) so a silent fallback to `/v1/models` fails the run. The workflow also runs auth checks plus optional Postgres-backed auth checks when `LITELLM_LICENSE` is configured for virtual-key and admin-route behavior, then runs a non-interactive Pi CLI smoke with `--list-models` and `-p` against both the OpenAI-compatible and Anthropic-backed routes, so extension loading, model discovery, and real completion paths are covered without opening the TUI.
196
+ This keeps the LiteLLM integration path under test but does not call real LLM APIs. No provider API keys or GitHub Models permission are required. The smoke runner also asserts that discovery came from `/model/info` (`LITELLM_SMOKE_EXPECT_SOURCE`) so a silent fallback to `/v1/models` fails the run. The workflow also runs auth checks plus optional Postgres-backed auth checks when `LITELLM_LICENSE` is configured for virtual-key and admin-route behavior, then runs a non-interactive Pi CLI smoke with `--list-models` and `-p` against both the OpenAI-compatible and Anthropic-backed routes, so extension loading, model discovery, and real completion paths are covered without opening the TUI. It also runs an interactive Pi TUI smoke covering `/login litellm` and Pi's native `/model` refresh.
175
197
 
176
198
  ## Development
177
199
 
178
- This package requires Node.js `>=22.19.0`. CI currently uses Node `24.16.0`.
200
+ This package requires Node.js `>=22.19.0`. CI currently uses Node `26.5.0`.
179
201
 
180
202
  ```bash
181
203
  npm ci
@@ -200,16 +222,11 @@ Releases are driven by semver tags named `v*.*.*`. The GitHub release workflow i
200
222
 
201
223
  Before tagging a release, keep `package.json` and `package-lock.json` versions in sync and verify the dry-run package contents.
202
224
 
203
- ## Slash commands
204
-
205
- - `/litellm-refresh` — force re-fetch the model list for all configured LiteLLM providers, ignoring cache
206
- - `/litellm-refresh <provider>` — refresh one configured provider alias, for example `/litellm-refresh litellm-anthropic`
207
-
208
- ## Cache
225
+ ## Model catalog
209
226
 
210
- The default provider model list is cached at `~/.pi/agent/litellm-models.json` with a keyed fingerprint of the base URL + API key. Alias provider caches use `~/.pi/agent/litellm-models-<provider>.json`. Changing the base URL or key invalidates that provider's cache automatically.
227
+ Dynamic catalogs are persisted by Pi in `~/.pi/agent/models-store.json`. Credentials remain in `~/.pi/agent/auth.json`. Legacy `litellm-models*.json` files are ignored and are not deleted.
211
228
 
212
- If the cache is older than 24 hours, the extension refreshes it in the background on session start (non-blocking). Failures are silent — the cached models remain in use. Run `/litellm-refresh` to force an immediate refresh.
229
+ Opening `/model` refreshes configured provider catalogs in the background using Pi's native model lifecycle.
213
230
 
214
231
  ## Troubleshooting
215
232
 
@@ -223,7 +240,7 @@ If the cache is older than 24 hours, the extension refreshes it in the backgroun
223
240
  | No models with gcloud auth | Verify `gcloud auth application-default login` has been run or set `GOOGLE_APPLICATION_CREDENTIALS` to an `authorized_user` ADC file |
224
241
  | Enterprise SSO login shows "virtual key generation failed" | The LiteLLM instance may lack a database (`/key/generate` requires one), your user account may lack key-generation permission, or the request timed out; the JWT is used directly as a fallback |
225
242
  | Enterprise SSO token prompt fails with "SSO token is required" | The token field was left empty — paste the token copied from the LiteLLM UI |
226
- | MCP tools not showing | Verify the proxy exposes `/mcp-rest/tools/list` and run `/litellm-refresh` after fixing the proxy |
243
+ | MCP tools not showing | Verify the proxy exposes `/mcp-rest/tools/list` and open `/model` after fixing the proxy |
227
244
  | Skills not affecting prompts | Verify the proxy exposes `/claude-code/marketplace.json` or `/v1/skills` and returns enabled skills |
228
245
 
229
246
  ## License
package/dist/cache.d.ts CHANGED
@@ -1,5 +1 @@
1
- import type { CacheFile } from "./types.js";
2
- export declare function fingerprint(apiKey: string): string;
3
- export declare function isCacheValid(cache: CacheFile | null, baseUrl: string, apiKey: string): boolean;
4
- export declare function readCache(path: string): Promise<CacheFile | null>;
5
- export declare function writeCache(path: string, cache: CacheFile): Promise<void>;
1
+ export declare function writeJsonAtomic(path: string, value: unknown): Promise<void>;
package/dist/cache.js CHANGED
@@ -1,47 +1,8 @@
1
- import { scryptSync } from "node:crypto";
2
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
1
+ import { mkdir, rename, writeFile } from "node:fs/promises";
3
2
  import { dirname } from "node:path";
4
- const FINGERPRINT_SALT = "pi-provider-litellm-cache-fingerprint-v1";
5
- export function fingerprint(apiKey) {
6
- return scryptSync(apiKey, FINGERPRINT_SALT, 32).toString("hex");
7
- }
8
- export function isCacheValid(cache, baseUrl, apiKey) {
9
- if (!cache)
10
- return false;
11
- return cache.baseUrl === baseUrl && cache.apiKeyFingerprint === fingerprint(apiKey);
12
- }
13
- export async function readCache(path) {
14
- let raw;
15
- try {
16
- raw = await readFile(path, "utf8");
17
- }
18
- catch {
19
- return null;
20
- }
21
- let parsed;
22
- try {
23
- parsed = JSON.parse(raw);
24
- }
25
- catch {
26
- return null;
27
- }
28
- if (!isCacheFileShape(parsed))
29
- return null;
30
- return parsed;
31
- }
32
- function isCacheFileShape(value) {
33
- if (typeof value !== "object" || value === null)
34
- return false;
35
- const v = value;
36
- return (typeof v.baseUrl === "string" &&
37
- typeof v.apiKeyFingerprint === "string" &&
38
- typeof v.fetchedAt === "number" &&
39
- (v.source === "model_info" || v.source === "models_list" || v.source === "health") &&
40
- Array.isArray(v.models));
41
- }
42
- export async function writeCache(path, cache) {
3
+ export async function writeJsonAtomic(path, value) {
43
4
  await mkdir(dirname(path), { recursive: true });
44
5
  const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
45
- await writeFile(tmp, JSON.stringify(cache, null, 2), "utf8");
6
+ await writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
46
7
  await rename(tmp, path);
47
8
  }
package/dist/cost.d.ts CHANGED
@@ -1,12 +1,2 @@
1
- import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
2
- export interface ModelCostInfo {
3
- inputCostPerToken: number;
4
- outputCostPerToken: number;
5
- cacheReadCostPerToken: number;
6
- cacheWriteCostPerToken: number;
7
- }
8
- export interface ProviderModels {
9
- provider: string;
10
- models: ProviderModelConfig[];
11
- }
12
- export declare function setupLiteLLMCostTracking(pi: ExtensionAPI, providerModels: ProviderModels[]): (providerModels: ProviderModels[]) => void;
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ export declare function setupLiteLLMCostTracking(pi: ExtensionAPI, providerNames: string[]): void;
package/dist/cost.js CHANGED
Binary file
@@ -1,12 +1,10 @@
1
- import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
2
- import type { DiscoveryOptions, DiscoveryResult } from "./types.js";
1
+ import type { DiscoveredModel, DiscoveryOptions, DiscoveryResult } from "./types.js";
3
2
  export declare function normalizeBaseUrl(input: string): string;
4
3
  export declare function isMoonshotModel(modelId: string): boolean;
5
4
  export declare function isGpt55Model(modelId: string): boolean;
6
5
  export declare function shouldSuppressReasoningContent(modelId: string): boolean;
7
- export declare function buildCompat(modelId: string): ProviderModelConfig["compat"];
8
- export declare function withTimeout(timeoutMs: number, signal?: AbortSignal): {
9
- signal: AbortSignal;
10
- cancel: () => void;
11
- };
12
- export declare function discoverModels(baseUrl: string, apiKey: string, options?: DiscoveryOptions): Promise<DiscoveryResult>;
6
+ export declare function buildCompat(modelId: string): DiscoveredModel["compat"];
7
+ export declare function discoverModels(baseUrl: string, apiKey: string, options?: DiscoveryOptions & {
8
+ onProgress?: (message: string) => void;
9
+ silent?: boolean;
10
+ }): Promise<DiscoveryResult>;
package/dist/discover.js CHANGED
@@ -1,19 +1,23 @@
1
+ import { readFile } from "node:fs/promises";
1
2
  import { getModels, getProviders } from "@earendil-works/pi-ai/compat";
3
+ import { writeJsonAtomic } from "./cache.js";
2
4
  const DEFAULT_TIMEOUT_MS = 5000;
3
5
  const DEFAULT_CONTEXT_WINDOW = 128_000;
4
6
  const DEFAULT_MAX_TOKENS = 16_384;
5
7
  const KNOWN_PROVIDER_SET = new Set(getProviders());
6
8
  const MODELS_DEV_URL = "https://models.dev/api.json";
7
- let modelsDevCatalog;
9
+ const MODELS_DEV_CACHE_TTL_MS = 28 * 24 * 60 * 60 * 1000;
10
+ const modelsDevCaches = new Map();
11
+ const modelsDevRefreshes = new Map();
8
12
  export function normalizeBaseUrl(input) {
9
13
  return input.replace(/\/+$/, "").replace(/\/v1\/?$/i, "");
10
14
  }
11
15
  const RESPONSES_MODE_PATTERN = /^responses?$/i;
12
16
  function isResponsesMode(mode) {
13
- return mode !== undefined && RESPONSES_MODE_PATTERN.test(mode);
17
+ return mode != null && RESPONSES_MODE_PATTERN.test(mode);
14
18
  }
15
19
  function isChatStyleMode(mode) {
16
- return mode === undefined || mode === "chat" || isResponsesMode(mode);
20
+ return mode == null || mode === "chat" || isResponsesMode(mode);
17
21
  }
18
22
  // Matches both the conventional `anthropic/...` prefix and aliases that
19
23
  // LiteLLM deployments commonly assign to Anthropic-backed routes (e.g.
@@ -104,6 +108,7 @@ function mapModelInfoCost(info, fallback) {
104
108
  cacheWrite: info.cache_creation_input_token_cost !== undefined
105
109
  ? info.cache_creation_input_token_cost * 1_000_000
106
110
  : (fallback?.cacheWrite ?? 0),
111
+ ...(fallback?.tiers ? { tiers: fallback.tiers } : {}),
107
112
  };
108
113
  }
109
114
  function getFallbackProviderAndModel(id, ownedBy) {
@@ -118,70 +123,180 @@ function findModelsDevModel(catalog, id, ownedBy) {
118
123
  const { provider, modelId } = getFallbackProviderAndModel(id, ownedBy);
119
124
  if (!provider)
120
125
  return undefined;
121
- return catalog?.[provider]?.models?.[modelId];
126
+ const models = catalog?.[provider]?.models;
127
+ return models && Object.hasOwn(models, modelId) ? models[modelId] : undefined;
122
128
  }
123
- export function withTimeout(timeoutMs, signal) {
124
- const controller = new AbortController();
125
- const onAbort = () => controller.abort(signal?.reason);
126
- if (signal) {
127
- if (signal.aborted)
128
- controller.abort(signal.reason);
129
- else
130
- signal.addEventListener("abort", onAbort, { once: true });
131
- }
132
- const timer = setTimeout(() => controller.abort(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
133
- return {
134
- signal: controller.signal,
135
- cancel: () => {
136
- clearTimeout(timer);
137
- signal?.removeEventListener("abort", onAbort);
138
- },
139
- };
129
+ function awaitWithSignal(promise, signal) {
130
+ if (!signal)
131
+ return promise;
132
+ if (signal.aborted)
133
+ return Promise.reject(signal.reason);
134
+ return new Promise((resolve, reject) => {
135
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
136
+ const onAbort = () => {
137
+ cleanup();
138
+ reject(signal.reason);
139
+ };
140
+ signal.addEventListener("abort", onAbort, { once: true });
141
+ promise.then((value) => {
142
+ cleanup();
143
+ resolve(value);
144
+ }, (error) => {
145
+ cleanup();
146
+ reject(error);
147
+ });
148
+ });
140
149
  }
141
150
  async function fetchJson(url, apiKey, options) {
142
151
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
143
- const { signal, cancel } = withTimeout(timeoutMs, options.signal);
144
- try {
145
- const response = await fetch(url, {
146
- headers: { ...options.headers, Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
147
- signal,
148
- });
149
- if (!response.ok)
150
- return { ok: false, status: response.status };
151
- const data = (await response.json());
152
- return { ok: true, data };
153
- }
154
- finally {
155
- cancel();
156
- }
152
+ const response = await fetch(url, {
153
+ headers: { ...options.headers, Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
154
+ signal: options.signal
155
+ ? AbortSignal.any([options.signal, AbortSignal.timeout(timeoutMs)])
156
+ : AbortSignal.timeout(timeoutMs),
157
+ });
158
+ if (!response.ok)
159
+ return { ok: false, status: response.status };
160
+ const data = (await response.json());
161
+ return { ok: true, data };
157
162
  }
158
163
  async function fetchPublicJson(url, options) {
159
164
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
160
- const { signal, cancel } = withTimeout(timeoutMs, options.signal);
161
- try {
162
- const response = await fetch(url, {
163
- headers: { Accept: "application/json" },
164
- signal,
165
- });
166
- if (!response.ok)
167
- throw new Error(`${url} returned ${response.status}`);
168
- return (await response.json());
169
- }
170
- finally {
171
- cancel();
165
+ const response = await fetch(url, {
166
+ headers: { Accept: "application/json" },
167
+ signal: options.signal
168
+ ? AbortSignal.any([options.signal, AbortSignal.timeout(timeoutMs)])
169
+ : AbortSignal.timeout(timeoutMs),
170
+ });
171
+ if (!response.ok)
172
+ throw new Error(`${url} returned ${response.status}`);
173
+ return (await response.json());
174
+ }
175
+ function isRecord(value) {
176
+ return typeof value === "object" && value !== null && !Array.isArray(value);
177
+ }
178
+ function finiteNumber(value) {
179
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
180
+ }
181
+ function normalizeModelsDevCatalog(value) {
182
+ if (!isRecord(value))
183
+ return undefined;
184
+ const catalog = {};
185
+ for (const [providerId, providerValue] of Object.entries(value)) {
186
+ if (!isRecord(providerValue) || !isRecord(providerValue.models))
187
+ continue;
188
+ const models = {};
189
+ for (const [modelId, modelValue] of Object.entries(providerValue.models)) {
190
+ if (!isRecord(modelValue))
191
+ continue;
192
+ const model = {};
193
+ if (typeof modelValue.name === "string")
194
+ model.name = modelValue.name;
195
+ if (typeof modelValue.reasoning === "boolean")
196
+ model.reasoning = modelValue.reasoning;
197
+ if (isRecord(modelValue.modalities) && Array.isArray(modelValue.modalities.input)) {
198
+ const input = modelValue.modalities.input.filter((entry) => typeof entry === "string");
199
+ if (input.length > 0)
200
+ model.modalities = { input };
201
+ }
202
+ if (isRecord(modelValue.limit)) {
203
+ const limit = {};
204
+ const context = finiteNumber(modelValue.limit.context);
205
+ const input = finiteNumber(modelValue.limit.input);
206
+ const output = finiteNumber(modelValue.limit.output);
207
+ if (context !== undefined)
208
+ limit.context = context;
209
+ if (input !== undefined)
210
+ limit.input = input;
211
+ if (output !== undefined)
212
+ limit.output = output;
213
+ if (Object.keys(limit).length > 0)
214
+ model.limit = limit;
215
+ }
216
+ if (isRecord(modelValue.cost)) {
217
+ const cost = {};
218
+ const input = finiteNumber(modelValue.cost.input);
219
+ const output = finiteNumber(modelValue.cost.output);
220
+ const cacheRead = finiteNumber(modelValue.cost.cache_read);
221
+ const cacheWrite = finiteNumber(modelValue.cost.cache_write);
222
+ if (input !== undefined)
223
+ cost.input = input;
224
+ if (output !== undefined)
225
+ cost.output = output;
226
+ if (cacheRead !== undefined)
227
+ cost.cache_read = cacheRead;
228
+ if (cacheWrite !== undefined)
229
+ cost.cache_write = cacheWrite;
230
+ if (Object.keys(cost).length > 0)
231
+ model.cost = cost;
232
+ }
233
+ models[modelId] = model;
234
+ }
235
+ catalog[providerId] = { models };
172
236
  }
237
+ return catalog;
173
238
  }
174
- async function getModelsDevCatalog(options) {
175
- if (modelsDevCatalog)
176
- return modelsDevCatalog;
239
+ async function readModelsDevCache(path) {
177
240
  try {
178
- modelsDevCatalog = await fetchPublicJson(MODELS_DEV_URL, options);
179
- return modelsDevCatalog;
241
+ const parsed = JSON.parse(await readFile(path, "utf8"));
242
+ if (!isRecord(parsed) ||
243
+ typeof parsed.fetchedAt !== "number" ||
244
+ !Number.isFinite(parsed.fetchedAt) ||
245
+ parsed.fetchedAt < 0 ||
246
+ parsed.fetchedAt > Date.now()) {
247
+ return undefined;
248
+ }
249
+ const catalog = normalizeModelsDevCatalog(parsed.catalog);
250
+ return catalog ? { fetchedAt: parsed.fetchedAt, catalog } : undefined;
180
251
  }
181
252
  catch {
182
253
  return undefined;
183
254
  }
184
255
  }
256
+ function refreshModelsDevCatalog(key, options) {
257
+ const active = modelsDevRefreshes.get(key);
258
+ if (active)
259
+ return active;
260
+ const refresh = (async () => {
261
+ try {
262
+ const catalog = normalizeModelsDevCatalog(await fetchPublicJson(MODELS_DEV_URL, options));
263
+ if (!catalog)
264
+ return undefined;
265
+ const cache = { fetchedAt: Date.now(), catalog };
266
+ modelsDevCaches.set(key, cache);
267
+ if (options.modelsDevCachePath) {
268
+ await writeJsonAtomic(options.modelsDevCachePath, cache).catch(() => undefined);
269
+ }
270
+ return catalog;
271
+ }
272
+ catch {
273
+ return undefined;
274
+ }
275
+ finally {
276
+ modelsDevRefreshes.delete(key);
277
+ }
278
+ })();
279
+ modelsDevRefreshes.set(key, refresh);
280
+ return refresh;
281
+ }
282
+ async function getModelsDevCatalog(options) {
283
+ const key = options.modelsDevCachePath ?? MODELS_DEV_URL;
284
+ const refreshOptions = { ...options, timeoutMs: DEFAULT_TIMEOUT_MS, signal: undefined };
285
+ let cache = modelsDevCaches.get(key);
286
+ if (!cache && options.modelsDevCachePath) {
287
+ cache = await readModelsDevCache(options.modelsDevCachePath);
288
+ if (cache)
289
+ modelsDevCaches.set(key, cache);
290
+ }
291
+ if (!cache) {
292
+ const timeout = AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
293
+ return awaitWithSignal(refreshModelsDevCatalog(key, refreshOptions), options.signal ? AbortSignal.any([options.signal, timeout]) : timeout);
294
+ }
295
+ if (Date.now() - cache.fetchedAt < MODELS_DEV_CACHE_TTL_MS)
296
+ return cache.catalog;
297
+ void refreshModelsDevCatalog(key, refreshOptions);
298
+ return cache.catalog;
299
+ }
185
300
  function mapModelsDevMetadata(model) {
186
301
  if (!model)
187
302
  return {};
@@ -221,6 +336,7 @@ function mapFromModelInfo(entry) {
221
336
  id,
222
337
  name: id,
223
338
  reasoning: info.supports_reasoning ?? false,
339
+ ...(catalogModel?.thinkingLevelMap ? { thinkingLevelMap: catalogModel.thinkingLevelMap } : {}),
224
340
  input: info.supports_vision ? ["text", "image"] : ["text"],
225
341
  cost: mapModelInfoCost(info, catalogModel?.cost),
226
342
  contextWindow: info.max_input_tokens ?? DEFAULT_CONTEXT_WINDOW,
@@ -230,25 +346,12 @@ function mapFromModelInfo(entry) {
230
346
  };
231
347
  }
232
348
  function mapFromHealthModelInfo(entry, fallbackId) {
233
- const model = mapFromModelInfo(entry);
234
- if (model || !fallbackId)
235
- return model;
236
- if (!isChatStyleMode(entry.model_info?.mode))
237
- return undefined;
238
- const info = entry.model_info ?? {};
239
- const responsesMode = isResponsesMode(info.mode);
240
- const catalogModel = findCatalogModel(fallbackId);
241
- return {
242
- id: fallbackId,
243
- name: fallbackId,
244
- reasoning: info.supports_reasoning ?? false,
245
- input: info.supports_vision ? ["text", "image"] : ["text"],
246
- cost: mapModelInfoCost(info, catalogModel?.cost),
247
- contextWindow: info.max_input_tokens ?? DEFAULT_CONTEXT_WINDOW,
248
- maxTokens: info.max_output_tokens ?? DEFAULT_MAX_TOKENS,
249
- compat: buildCompat(fallbackId),
250
- ...(responsesMode ? { api: "openai-responses" } : {}),
251
- };
349
+ if (entry.model_name || !fallbackId)
350
+ return mapFromModelInfo(entry);
351
+ const model = mapFromModelInfo({ ...entry, model_name: fallbackId });
352
+ if (model)
353
+ delete model.thinkingLevelMap;
354
+ return model;
252
355
  }
253
356
  function mapFromHealthEndpoint(entry) {
254
357
  const id = entry.model;
@@ -286,45 +389,80 @@ function mapFromModelsList(entry, modelsDev) {
286
389
  };
287
390
  }
288
391
  async function discoverFromHealth(base, apiKey, options) {
392
+ const progress = options.silent ? undefined : options.onProgress;
393
+ progress?.("Querying /health endpoint...");
289
394
  const healthResult = await fetchJson(`${base}/health`, apiKey, options);
290
395
  if (!healthResult.ok)
291
396
  return [];
292
397
  const endpoints = (healthResult.data.healthy_endpoints ?? []).filter((entry) => entry.model || entry.model_id);
398
+ progress?.(`Discovered ${endpoints.length} model endpoints, fetching details...`);
399
+ let completed = 0;
293
400
  const models = await Promise.all(endpoints.map(async (endpoint) => {
294
- if (!endpoint.model_id)
295
- return mapFromHealthEndpoint(endpoint);
296
- const infoResult = await fetchJson(`${base}/model/info?litellm_model_id=${encodeURIComponent(endpoint.model_id)}`, apiKey, options);
297
- if (!infoResult.ok)
298
- return mapFromHealthEndpoint(endpoint);
299
- const entry = infoResult.data.data?.[0];
300
- return entry ? mapFromHealthModelInfo(entry, endpoint.model) : mapFromHealthEndpoint(endpoint);
401
+ let model = mapFromHealthEndpoint(endpoint);
402
+ if (endpoint.model_id) {
403
+ const infoResult = await fetchJson(`${base}/model/info?litellm_model_id=${encodeURIComponent(endpoint.model_id)}`, apiKey, options);
404
+ const entry = infoResult.ok ? infoResult.data.data?.[0] : undefined;
405
+ if (entry)
406
+ model = mapFromHealthModelInfo(entry, endpoint.model);
407
+ }
408
+ completed++;
409
+ if (completed % 10 === 0 || completed === endpoints.length) {
410
+ progress?.(`Fetched ${completed}/${endpoints.length} models...`);
411
+ }
412
+ return model;
301
413
  }));
302
414
  return models.filter((model) => model !== undefined);
303
415
  }
416
+ function deduplicateModels(models) {
417
+ const seen = new Set();
418
+ return models.filter((m) => {
419
+ if (seen.has(m.id))
420
+ return false;
421
+ seen.add(m.id);
422
+ return true;
423
+ });
424
+ }
304
425
  export async function discoverModels(baseUrl, apiKey, options = {}) {
305
426
  const base = normalizeBaseUrl(baseUrl);
427
+ const progress = options.silent ? undefined : options.onProgress;
428
+ progress?.("Querying /model/info endpoint...");
306
429
  const infoResult = await fetchJson(`${base}/model/info`, apiKey, options);
307
430
  if (infoResult.ok) {
308
- const models = (infoResult.data.data ?? [])
309
- .map(mapFromModelInfo)
310
- .filter((m) => m !== undefined);
431
+ const entries = new Map();
432
+ for (const entry of infoResult.data.data ?? []) {
433
+ if (!entry.model_name)
434
+ continue;
435
+ const previous = entries.get(entry.model_name);
436
+ entries.set(entry.model_name, {
437
+ ...previous,
438
+ ...entry,
439
+ model_info: { ...previous?.model_info, ...entry.model_info },
440
+ });
441
+ }
442
+ const models = [...entries.values()].map(mapFromModelInfo).filter((m) => m !== undefined);
311
443
  return { source: "model_info", models };
312
444
  }
313
445
  if (![401, 403, 404].includes(infoResult.status)) {
314
446
  throw new Error(`/model/info returned ${infoResult.status}`);
315
447
  }
448
+ progress?.("/model/info unavailable, trying /v1/models...");
316
449
  const listResult = await fetchJson(`${base}/v1/models`, apiKey, options);
317
450
  if (!listResult.ok) {
318
451
  if ([401, 403, 404].includes(listResult.status)) {
452
+ progress?.("/v1/models unavailable, falling back to /health endpoint...");
319
453
  const models = await discoverFromHealth(base, apiKey, options);
320
454
  if (models.length > 0)
321
- return { source: "health", models };
455
+ return { source: "health", models: deduplicateModels(models) };
322
456
  }
323
457
  throw new Error(`/v1/models returned ${listResult.status}`);
324
458
  }
325
- const modelsDev = await getModelsDevCatalog(options);
459
+ let modelsDev;
460
+ if (options.modelsDev !== false) {
461
+ progress?.("Loading models.dev catalog for metadata enrichment...");
462
+ modelsDev = await getModelsDevCatalog(options);
463
+ }
326
464
  const models = (listResult.data.data ?? [])
327
465
  .map((entry) => mapFromModelsList(entry, modelsDev))
328
466
  .filter((m) => m !== undefined);
329
- return { source: "models_list", models };
467
+ return { source: "models_list", models: deduplicateModels(models) };
330
468
  }