pi-provider-litellm 1.3.0 → 2.0.1

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
 
@@ -152,11 +151,15 @@ Setting `skills.enabled` to `false` disables the Skills Gateway management tools
152
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"`. |
153
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. |
154
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. |
155
- | `LITELLM_OFFLINE` | unset | If `1`, disable all model and MCP discovery, including `/litellm-refresh` and post-login MCP discovery; use cached models only |
154
+ | `LITELLM_OFFLINE` | unset | If `1`, disable all model and MCP discovery, including post-login discovery; use cached models only |
156
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 |
157
158
 
158
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`.
159
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.
162
+
160
163
  ### Google ADC token auth
161
164
 
162
165
  When your LiteLLM proxy accepts Google OAuth access tokens, you can let the extension refresh tokens from Application Default Credentials:
@@ -176,7 +179,7 @@ If your LiteLLM proxy exposes MCP REST endpoints, this extension discovers tools
176
179
  - `GET /mcp-rest/tools/list`
177
180
  - `POST /mcp-rest/tools/call`
178
181
 
179
- 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 background model discovery, `/login litellm`, or `/litellm-refresh`; extension activation never waits for it. 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.
180
183
 
181
184
  ## LiteLLM Skill Hub
182
185
 
@@ -190,7 +193,7 @@ If your LiteLLM proxy exposes `/claude-code/marketplace.json`, enabled skills ar
190
193
 
191
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.
192
195
 
193
- 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`, `/litellm-refresh`, and `/model`.
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.
194
197
 
195
198
  ## Development
196
199
 
@@ -219,16 +222,11 @@ Releases are driven by semver tags named `v*.*.*`. The GitHub release workflow i
219
222
 
220
223
  Before tagging a release, keep `package.json` and `package-lock.json` versions in sync and verify the dry-run package contents.
221
224
 
222
- ## Slash commands
223
-
224
- - `/litellm-refresh` — force re-fetch the model list for all configured LiteLLM providers, ignoring cache
225
- - `/litellm-refresh <provider>` — refresh one configured provider alias, for example `/litellm-refresh litellm-anthropic`
226
-
227
- ## Cache
225
+ ## Model catalog
228
226
 
229
- 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.
230
228
 
231
- When the cache is missing, invalid, or older than 24 hours, the extension starts without waiting for discovery and refreshes models in the background on session start. Existing cached models remain available while an invalid or stale cache refreshes. Failures are silent; run `/litellm-refresh` to force an immediate refresh and see its result.
229
+ Opening `/model` refreshes configured provider catalogs in the background using Pi's native model lifecycle.
232
230
 
233
231
  ## Troubleshooting
234
232
 
@@ -242,7 +240,7 @@ When the cache is missing, invalid, or older than 24 hours, the extension starts
242
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 |
243
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 |
244
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 |
245
- | 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 |
246
244
  | Skills not affecting prompts | Verify the proxy exposes `/claude-code/marketplace.json` or `/v1/skills` and returns enabled skills |
247
245
 
248
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,6 +1,2 @@
1
- import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
2
- export interface ProviderModels {
3
- provider: string;
4
- models: ProviderModelConfig[];
5
- }
6
- 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,14 +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
- };
6
+ export declare function buildCompat(modelId: string): DiscoveredModel["compat"];
12
7
  export declare function discoverModels(baseUrl: string, apiKey: string, options?: DiscoveryOptions & {
13
8
  onProgress?: (message: string) => void;
9
+ silent?: boolean;
14
10
  }): Promise<DiscoveryResult>;
package/dist/discover.js CHANGED
@@ -1,10 +1,14 @@
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
  }
@@ -119,70 +123,180 @@ function findModelsDevModel(catalog, id, ownedBy) {
119
123
  const { provider, modelId } = getFallbackProviderAndModel(id, ownedBy);
120
124
  if (!provider)
121
125
  return undefined;
122
- return catalog?.[provider]?.models?.[modelId];
126
+ const models = catalog?.[provider]?.models;
127
+ return models && Object.hasOwn(models, modelId) ? models[modelId] : undefined;
123
128
  }
124
- export function withTimeout(timeoutMs, signal) {
125
- const controller = new AbortController();
126
- const onAbort = () => controller.abort(signal?.reason);
127
- if (signal) {
128
- if (signal.aborted)
129
- controller.abort(signal.reason);
130
- else
131
- signal.addEventListener("abort", onAbort, { once: true });
132
- }
133
- const timer = setTimeout(() => controller.abort(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
134
- return {
135
- signal: controller.signal,
136
- cancel: () => {
137
- clearTimeout(timer);
138
- signal?.removeEventListener("abort", onAbort);
139
- },
140
- };
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
+ });
141
149
  }
142
150
  async function fetchJson(url, apiKey, options) {
143
151
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
144
- const { signal, cancel } = withTimeout(timeoutMs, options.signal);
145
- try {
146
- const response = await fetch(url, {
147
- headers: { ...options.headers, Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
148
- signal,
149
- });
150
- if (!response.ok)
151
- return { ok: false, status: response.status };
152
- const data = (await response.json());
153
- return { ok: true, data };
154
- }
155
- finally {
156
- cancel();
157
- }
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 };
158
162
  }
159
163
  async function fetchPublicJson(url, options) {
160
164
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
161
- const { signal, cancel } = withTimeout(timeoutMs, options.signal);
162
- try {
163
- const response = await fetch(url, {
164
- headers: { Accept: "application/json" },
165
- signal,
166
- });
167
- if (!response.ok)
168
- throw new Error(`${url} returned ${response.status}`);
169
- return (await response.json());
170
- }
171
- finally {
172
- 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 };
173
236
  }
237
+ return catalog;
174
238
  }
175
- async function getModelsDevCatalog(options) {
176
- if (modelsDevCatalog)
177
- return modelsDevCatalog;
239
+ async function readModelsDevCache(path) {
178
240
  try {
179
- modelsDevCatalog = await fetchPublicJson(MODELS_DEV_URL, options);
180
- 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;
181
251
  }
182
252
  catch {
183
253
  return undefined;
184
254
  }
185
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
+ }
186
300
  function mapModelsDevMetadata(model) {
187
301
  if (!model)
188
302
  return {};
@@ -232,25 +346,12 @@ function mapFromModelInfo(entry) {
232
346
  };
233
347
  }
234
348
  function mapFromHealthModelInfo(entry, fallbackId) {
235
- const model = mapFromModelInfo(entry);
236
- if (model || !fallbackId)
237
- return model;
238
- if (!isChatStyleMode(entry.model_info?.mode))
239
- return undefined;
240
- const info = entry.model_info ?? {};
241
- const responsesMode = isResponsesMode(info.mode);
242
- const catalogModel = findCatalogModel(fallbackId);
243
- return {
244
- id: fallbackId,
245
- name: fallbackId,
246
- reasoning: info.supports_reasoning ?? false,
247
- input: info.supports_vision ? ["text", "image"] : ["text"],
248
- cost: mapModelInfoCost(info, catalogModel?.cost),
249
- contextWindow: info.max_input_tokens ?? DEFAULT_CONTEXT_WINDOW,
250
- maxTokens: info.max_output_tokens ?? DEFAULT_MAX_TOKENS,
251
- compat: buildCompat(fallbackId),
252
- ...(responsesMode ? { api: "openai-responses" } : {}),
253
- };
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;
254
355
  }
255
356
  function mapFromHealthEndpoint(entry) {
256
357
  const id = entry.model;
@@ -288,12 +389,13 @@ function mapFromModelsList(entry, modelsDev) {
288
389
  };
289
390
  }
290
391
  async function discoverFromHealth(base, apiKey, options) {
291
- options.onProgress?.("Querying /health endpoint...");
392
+ const progress = options.silent ? undefined : options.onProgress;
393
+ progress?.("Querying /health endpoint...");
292
394
  const healthResult = await fetchJson(`${base}/health`, apiKey, options);
293
395
  if (!healthResult.ok)
294
396
  return [];
295
397
  const endpoints = (healthResult.data.healthy_endpoints ?? []).filter((entry) => entry.model || entry.model_id);
296
- options.onProgress?.(`Discovered ${endpoints.length} model endpoints, fetching details...`);
398
+ progress?.(`Discovered ${endpoints.length} model endpoints, fetching details...`);
297
399
  let completed = 0;
298
400
  const models = await Promise.all(endpoints.map(async (endpoint) => {
299
401
  let model = mapFromHealthEndpoint(endpoint);
@@ -305,7 +407,7 @@ async function discoverFromHealth(base, apiKey, options) {
305
407
  }
306
408
  completed++;
307
409
  if (completed % 10 === 0 || completed === endpoints.length) {
308
- options.onProgress?.(`Fetched ${completed}/${endpoints.length} models...`);
410
+ progress?.(`Fetched ${completed}/${endpoints.length} models...`);
309
411
  }
310
412
  return model;
311
413
  }));
@@ -322,7 +424,8 @@ function deduplicateModels(models) {
322
424
  }
323
425
  export async function discoverModels(baseUrl, apiKey, options = {}) {
324
426
  const base = normalizeBaseUrl(baseUrl);
325
- options.onProgress?.("Querying /model/info endpoint...");
427
+ const progress = options.silent ? undefined : options.onProgress;
428
+ progress?.("Querying /model/info endpoint...");
326
429
  const infoResult = await fetchJson(`${base}/model/info`, apiKey, options);
327
430
  if (infoResult.ok) {
328
431
  const entries = new Map();
@@ -342,19 +445,22 @@ export async function discoverModels(baseUrl, apiKey, options = {}) {
342
445
  if (![401, 403, 404].includes(infoResult.status)) {
343
446
  throw new Error(`/model/info returned ${infoResult.status}`);
344
447
  }
345
- options.onProgress?.("/model/info unavailable, trying /v1/models...");
448
+ progress?.("/model/info unavailable, trying /v1/models...");
346
449
  const listResult = await fetchJson(`${base}/v1/models`, apiKey, options);
347
450
  if (!listResult.ok) {
348
451
  if ([401, 403, 404].includes(listResult.status)) {
349
- options.onProgress?.("/v1/models unavailable, falling back to /health endpoint...");
452
+ progress?.("/v1/models unavailable, falling back to /health endpoint...");
350
453
  const models = await discoverFromHealth(base, apiKey, options);
351
454
  if (models.length > 0)
352
455
  return { source: "health", models: deduplicateModels(models) };
353
456
  }
354
457
  throw new Error(`/v1/models returned ${listResult.status}`);
355
458
  }
356
- options.onProgress?.("Fetching models.dev catalog for metadata enrichment...");
357
- 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
+ }
358
464
  const models = (listResult.data.data ?? [])
359
465
  .map((entry) => mapFromModelsList(entry, modelsDev))
360
466
  .filter((m) => m !== undefined);