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 +40 -23
- package/dist/cache.d.ts +1 -5
- package/dist/cache.js +3 -42
- package/dist/cost.d.ts +2 -12
- package/dist/cost.js +0 -0
- package/dist/discover.d.ts +6 -8
- package/dist/discover.js +222 -84
- package/dist/index.js +457 -643
- package/dist/litellm.js +2 -4
- package/dist/mcp-tools.d.ts +4 -4
- package/dist/mcp-tools.js +24 -30
- package/dist/provider.d.ts +13 -0
- package/dist/provider.js +30 -0
- package/dist/skills.d.ts +3 -3
- package/dist/skills.js +11 -11
- package/dist/types.d.ts +13 -10
- package/package.json +7 -6
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
|
|
|
@@ -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`,
|
|
137
|
-
| `LITELLM_DISCOVERY_TIMEOUT_MS` | `5000` |
|
|
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`
|
|
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
|
|
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 `
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,12 +1,2 @@
|
|
|
1
|
-
import type { ExtensionAPI
|
|
2
|
-
export
|
|
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
|
package/dist/discover.d.ts
CHANGED
|
@@ -1,12 +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
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
|
17
|
+
return mode != null && RESPONSES_MODE_PATTERN.test(mode);
|
|
14
18
|
}
|
|
15
19
|
function isChatStyleMode(mode) {
|
|
16
|
-
return 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
|
-
|
|
126
|
+
const models = catalog?.[provider]?.models;
|
|
127
|
+
return models && Object.hasOwn(models, modelId) ? models[modelId] : undefined;
|
|
122
128
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (signal)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
signal:
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
175
|
-
if (modelsDevCatalog)
|
|
176
|
-
return modelsDevCatalog;
|
|
239
|
+
async function readModelsDevCache(path) {
|
|
177
240
|
try {
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
309
|
-
|
|
310
|
-
.
|
|
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
|
-
|
|
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
|
}
|