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 +18 -20
- package/dist/cache.d.ts +1 -5
- package/dist/cache.js +3 -42
- package/dist/cost.d.ts +2 -6
- package/dist/cost.js +0 -0
- package/dist/discover.d.ts +3 -7
- package/dist/discover.js +183 -77
- package/dist/index.js +443 -616
- package/dist/litellm.js +2 -4
- package/dist/mcp-tools.d.ts +4 -4
- package/dist/mcp-tools.js +14 -28
- package/dist/provider.d.ts +13 -0
- package/dist/provider.js +29 -0
- package/dist/skills.d.ts +3 -3
- package/dist/skills.js +11 -11
- package/dist/types.d.ts +12 -9
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# pi-provider-litellm
|
|
2
2
|
|
|
3
|
-
LiteLLM proxy
|
|
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`,
|
|
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
|
-
|
|
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.
|
|
54
|
-
4.
|
|
55
|
-
5.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
|
2
|
-
export
|
|
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
|
package/dist/discover.d.ts
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
import type {
|
|
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):
|
|
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
|
-
|
|
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
|
-
|
|
126
|
+
const models = catalog?.[provider]?.models;
|
|
127
|
+
return models && Object.hasOwn(models, modelId) ? models[modelId] : undefined;
|
|
123
128
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (signal)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
signal:
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
176
|
-
if (modelsDevCatalog)
|
|
177
|
-
return modelsDevCatalog;
|
|
239
|
+
async function readModelsDevCache(path) {
|
|
178
240
|
try {
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (
|
|
239
|
-
|
|
240
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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);
|