pi-provider-litellm 1.2.8 → 1.3.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 +118 -13
- package/dist/cost.d.ts +4 -6
- package/dist/cost.js +0 -0
- package/dist/discover.d.ts +4 -1
- package/dist/discover.js +66 -16
- package/dist/index.js +658 -219
- package/dist/mcp-tools.d.ts +3 -3
- package/dist/mcp-tools.js +85 -21
- package/dist/skills.d.ts +7 -6
- package/dist/skills.js +112 -32
- package/dist/types.d.ts +11 -1
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
LiteLLM proxy provider extension for [Pi](https://pi.dev).
|
|
4
4
|
|
|
5
|
-
Discovers models from
|
|
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.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -68,6 +68,76 @@ export LITELLM_API_KEY="sk-..."
|
|
|
68
68
|
|
|
69
69
|
Stored pi credentials for `litellm` take precedence over `LITELLM_API_KEY`; the environment key is used when no saved credential exists. `LITELLM_BASE_URL` is used when no saved login base URL exists.
|
|
70
70
|
|
|
71
|
+
### Multiple LiteLLM provider aliases
|
|
72
|
+
|
|
73
|
+
Add alias providers in `~/.pi/agent/settings.json` under `litellm.providers`. Each alias is registered as a separate Pi provider name, so models appear as `litellm/model-id` and `litellm-anthropic/model-id`.
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"litellm": {
|
|
78
|
+
"providers": {
|
|
79
|
+
"litellm-anthropic": {
|
|
80
|
+
"baseUrl": "https://litellm.your-domain.com",
|
|
81
|
+
"apiKey": "$LITELLM_CLAUDE_KEY",
|
|
82
|
+
"headers": "$LITELLM_HEADERS"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
You can also override the default provider through the same shape:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"litellm": {
|
|
94
|
+
"providers": {
|
|
95
|
+
"litellm": {
|
|
96
|
+
"baseUrl": "https://litellm.your-domain.com",
|
|
97
|
+
"apiKey": "$LITELLM_API_KEY",
|
|
98
|
+
"headers": "$LITELLM_HEADERS"
|
|
99
|
+
},
|
|
100
|
+
"litellm-anthropic": {
|
|
101
|
+
"baseUrl": "https://litellm.your-domain.com",
|
|
102
|
+
"apiKey": "$LITELLM_CLAUDE_KEY",
|
|
103
|
+
"headers": "$LITELLM_HEADERS"
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Provider fields:
|
|
111
|
+
|
|
112
|
+
| Field | Default | Effect |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| `baseUrl` | `LITELLM_BASE_URL` for `litellm`; required for aliases | LiteLLM proxy URL, with or without `/v1` |
|
|
115
|
+
| `apiKey` | `LITELLM_API_KEY_HELPER`/`LITELLM_API_KEY` for `litellm`; required for aliases | Pi config value for this provider's key. Use `$ENV_VAR`, `${ENV_VAR}`, `!command`, or a literal key. Escape a literal `$` as `$$`. |
|
|
116
|
+
| `headers` | `$LITELLM_HEADERS` for `litellm`; unset for aliases | JSON string env reference or inline object of request headers |
|
|
117
|
+
| `displayName` | provider name | Label shown in Pi UI |
|
|
118
|
+
| `enabled` | `true` | Set `false` to skip an alias |
|
|
119
|
+
|
|
120
|
+
`/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
|
+
|
|
122
|
+
### Optional LiteLLM features
|
|
123
|
+
|
|
124
|
+
LiteLLM Skills and MCP integration are enabled by default. Disable either feature globally in `~/.pi/agent/settings.json`:
|
|
125
|
+
|
|
126
|
+
```json
|
|
127
|
+
{
|
|
128
|
+
"litellm": {
|
|
129
|
+
"skills": {
|
|
130
|
+
"enabled": false
|
|
131
|
+
},
|
|
132
|
+
"mcp": {
|
|
133
|
+
"enabled": false
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
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.
|
|
140
|
+
|
|
71
141
|
## Use
|
|
72
142
|
|
|
73
143
|
```
|
|
@@ -79,12 +149,13 @@ Stored pi credentials for `litellm` take precedence over `LITELLM_API_KEY`; the
|
|
|
79
149
|
| Variable | Default | Effect |
|
|
80
150
|
|---|---|---|
|
|
81
151
|
| `LITELLM_API_KEY_HELPER` | unset | Command that prints a fresh LiteLLM bearer token. Takes precedence over `LITELLM_API_KEY`. Registered as a `!command` provider key; Pi re-runs it on every request (the per-request auth path is uncached), so rotating/short-lived tokens stay fresh. |
|
|
82
|
-
| `
|
|
152
|
+
| `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
|
+
| `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. |
|
|
83
154
|
| `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. |
|
|
84
|
-
| `LITELLM_OFFLINE` | unset | If `1`,
|
|
85
|
-
| `LITELLM_DISCOVERY_TIMEOUT_MS` | `5000` |
|
|
155
|
+
| `LITELLM_OFFLINE` | unset | If `1`, disable all model and MCP discovery, including `/litellm-refresh` and post-login MCP discovery; use cached models only |
|
|
156
|
+
| `LITELLM_DISCOVERY_TIMEOUT_MS` | `5000` | Background and explicit discovery fetch timeout in ms; `0` disables automatic discovery |
|
|
86
157
|
|
|
87
|
-
`LITELLM_DISCOVERY_TIMEOUT_MS=0`
|
|
158
|
+
`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`.
|
|
88
159
|
|
|
89
160
|
### Google ADC token auth
|
|
90
161
|
|
|
@@ -96,7 +167,7 @@ export LITELLM_BASE_URL="https://litellm.your-domain.com"
|
|
|
96
167
|
export LITELLM_GCLOUD_TOKEN_AUTH=1
|
|
97
168
|
```
|
|
98
169
|
|
|
99
|
-
Only `authorized_user` ADC files are supported. Service account JSON files are rejected with a warning. Tokens are cached in memory for 50 minutes and
|
|
170
|
+
Only `authorized_user` ADC files are supported. Service account JSON files are rejected with a warning. Tokens are cached in memory for 50 minutes and the registered provider key is a Pi `!command`, so request-time auth resolves a fresh token when Pi sends model requests.
|
|
100
171
|
|
|
101
172
|
## LiteLLM MCP tools
|
|
102
173
|
|
|
@@ -105,25 +176,59 @@ If your LiteLLM proxy exposes MCP REST endpoints, this extension discovers tools
|
|
|
105
176
|
- `GET /mcp-rest/tools/list`
|
|
106
177
|
- `POST /mcp-rest/tools/call`
|
|
107
178
|
|
|
108
|
-
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.
|
|
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.
|
|
109
180
|
|
|
110
|
-
## LiteLLM
|
|
181
|
+
## LiteLLM Skill Hub
|
|
111
182
|
|
|
112
|
-
If your LiteLLM proxy exposes `/
|
|
183
|
+
If your LiteLLM proxy exposes `/claude-code/marketplace.json`, enabled skills are fetched before each agent turn and appended to the system prompt as a `litellm_skills` section. The extension falls back to the legacy `/v1/skills` Skills Gateway path when Skill Hub is unavailable. It also registers Pi tools for basic skill management:
|
|
113
184
|
|
|
114
185
|
- `litellm_skill_list`
|
|
115
186
|
- `litellm_skill_create`
|
|
116
187
|
- `litellm_skill_delete`
|
|
117
188
|
|
|
189
|
+
## Mocked LiteLLM smoke workflow
|
|
190
|
+
|
|
191
|
+
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
|
+
|
|
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`.
|
|
194
|
+
|
|
195
|
+
## Development
|
|
196
|
+
|
|
197
|
+
This package requires Node.js `>=22.19.0`. CI currently uses Node `26.5.0`.
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
npm ci
|
|
201
|
+
npm run check
|
|
202
|
+
npm run clean && npm run build
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`npm run check` runs Biome, type checking, and the Vitest suite. Runtime changes must be built before local Pi smoke checks because the extension entrypoint is `./dist/index.js`.
|
|
206
|
+
|
|
207
|
+
Before changing package contents or dependency policy, also run:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
npm run supply-chain:guard
|
|
211
|
+
npm pack --dry-run
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The published npm package should contain only `dist`, `README.md`, and `LICENSE`.
|
|
215
|
+
|
|
216
|
+
## Release
|
|
217
|
+
|
|
218
|
+
Releases are driven by semver tags named `v*.*.*`. The GitHub release workflow installs from the lockfile, runs the checks, builds `dist`, verifies the package tarball, publishes to npm with provenance, and creates a GitHub release.
|
|
219
|
+
|
|
220
|
+
Before tagging a release, keep `package.json` and `package-lock.json` versions in sync and verify the dry-run package contents.
|
|
221
|
+
|
|
118
222
|
## Slash commands
|
|
119
223
|
|
|
120
|
-
- `/litellm-refresh` — force re-fetch the model list, ignoring cache
|
|
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`
|
|
121
226
|
|
|
122
227
|
## Cache
|
|
123
228
|
|
|
124
|
-
The model list is cached at `~/.pi/agent/litellm-models.json` with the base URL
|
|
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.
|
|
125
230
|
|
|
126
|
-
|
|
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.
|
|
127
232
|
|
|
128
233
|
## Troubleshooting
|
|
129
234
|
|
|
@@ -138,7 +243,7 @@ If the cache is older than 24 hours, the extension refreshes it in the backgroun
|
|
|
138
243
|
| 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 |
|
|
139
244
|
| Enterprise SSO token prompt fails with "SSO token is required" | The token field was left empty — paste the token copied from the LiteLLM UI |
|
|
140
245
|
| MCP tools not showing | Verify the proxy exposes `/mcp-rest/tools/list` and run `/litellm-refresh` after fixing the proxy |
|
|
141
|
-
| Skills not affecting prompts | Verify the proxy exposes `/v1/skills` and returns enabled skills |
|
|
246
|
+
| Skills not affecting prompts | Verify the proxy exposes `/claude-code/marketplace.json` or `/v1/skills` and returns enabled skills |
|
|
142
247
|
|
|
143
248
|
## License
|
|
144
249
|
|
package/dist/cost.d.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
export interface
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
cacheReadCostPerToken: number;
|
|
6
|
-
cacheWriteCostPerToken: number;
|
|
2
|
+
export interface ProviderModels {
|
|
3
|
+
provider: string;
|
|
4
|
+
models: ProviderModelConfig[];
|
|
7
5
|
}
|
|
8
|
-
export declare function setupLiteLLMCostTracking(pi: ExtensionAPI,
|
|
6
|
+
export declare function setupLiteLLMCostTracking(pi: ExtensionAPI, providerModels: ProviderModels[]): (providerModels: ProviderModels[]) => void;
|
package/dist/cost.js
CHANGED
|
Binary file
|
package/dist/discover.d.ts
CHANGED
|
@@ -2,10 +2,13 @@ import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import type { DiscoveryOptions, DiscoveryResult } from "./types.js";
|
|
3
3
|
export declare function normalizeBaseUrl(input: string): string;
|
|
4
4
|
export declare function isMoonshotModel(modelId: string): boolean;
|
|
5
|
+
export declare function isGpt55Model(modelId: string): boolean;
|
|
5
6
|
export declare function shouldSuppressReasoningContent(modelId: string): boolean;
|
|
6
7
|
export declare function buildCompat(modelId: string): ProviderModelConfig["compat"];
|
|
7
8
|
export declare function withTimeout(timeoutMs: number, signal?: AbortSignal): {
|
|
8
9
|
signal: AbortSignal;
|
|
9
10
|
cancel: () => void;
|
|
10
11
|
};
|
|
11
|
-
export declare function discoverModels(baseUrl: string, apiKey: string, options?: DiscoveryOptions
|
|
12
|
+
export declare function discoverModels(baseUrl: string, apiKey: string, options?: DiscoveryOptions & {
|
|
13
|
+
onProgress?: (message: string) => void;
|
|
14
|
+
}): Promise<DiscoveryResult>;
|
package/dist/discover.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getModels, getProviders } from "@earendil-works/pi-ai";
|
|
1
|
+
import { getModels, getProviders } from "@earendil-works/pi-ai/compat";
|
|
2
2
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
3
3
|
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
4
4
|
const DEFAULT_MAX_TOKENS = 16_384;
|
|
@@ -8,6 +8,13 @@ let modelsDevCatalog;
|
|
|
8
8
|
export function normalizeBaseUrl(input) {
|
|
9
9
|
return input.replace(/\/+$/, "").replace(/\/v1\/?$/i, "");
|
|
10
10
|
}
|
|
11
|
+
const RESPONSES_MODE_PATTERN = /^responses?$/i;
|
|
12
|
+
function isResponsesMode(mode) {
|
|
13
|
+
return mode != null && RESPONSES_MODE_PATTERN.test(mode);
|
|
14
|
+
}
|
|
15
|
+
function isChatStyleMode(mode) {
|
|
16
|
+
return mode == null || mode === "chat" || isResponsesMode(mode);
|
|
17
|
+
}
|
|
11
18
|
// Matches both the conventional `anthropic/...` prefix and aliases that
|
|
12
19
|
// LiteLLM deployments commonly assign to Anthropic-backed routes (e.g.
|
|
13
20
|
// `google/claude-sonnet-4-6`, `opus-4.7`, `sonnet-4.6`, `haiku-4.5`). Without
|
|
@@ -16,9 +23,16 @@ export function normalizeBaseUrl(input) {
|
|
|
16
23
|
const ANTHROPIC_MODEL_PATTERN = /(?:^|[-_/.:])(?:anthropic\/|(?:claude|opus|sonnet|haiku)(?=$|[-_/.:]))/i;
|
|
17
24
|
const MOONSHOT_MODEL_PATTERN = /^(moonshotai\/|moonshot\/|kimi[-/])/i;
|
|
18
25
|
const FORCED_THINKING_MODEL_PATTERN = /(?:^|[-/])thinking(?:[-/]|$)/i;
|
|
26
|
+
// Deployments expose the gpt-5.5 route under varying names (`llm-gateway/gpt-5.5`,
|
|
27
|
+
// bare `gpt-5.5`, dated ids like `gpt-5.5-20260504143601`); match them all so the
|
|
28
|
+
// tool+reasoning workaround survives route renames.
|
|
29
|
+
const GPT55_MODEL_PATTERN = /(?:^|\/)gpt-5\.5(?:$|[-.])/i;
|
|
19
30
|
export function isMoonshotModel(modelId) {
|
|
20
31
|
return MOONSHOT_MODEL_PATTERN.test(modelId);
|
|
21
32
|
}
|
|
33
|
+
export function isGpt55Model(modelId) {
|
|
34
|
+
return GPT55_MODEL_PATTERN.test(modelId);
|
|
35
|
+
}
|
|
22
36
|
export function shouldSuppressReasoningContent(modelId) {
|
|
23
37
|
return isMoonshotModel(modelId) && !FORCED_THINKING_MODEL_PATTERN.test(modelId);
|
|
24
38
|
}
|
|
@@ -90,6 +104,7 @@ function mapModelInfoCost(info, fallback) {
|
|
|
90
104
|
cacheWrite: info.cache_creation_input_token_cost !== undefined
|
|
91
105
|
? info.cache_creation_input_token_cost * 1_000_000
|
|
92
106
|
: (fallback?.cacheWrite ?? 0),
|
|
107
|
+
...(fallback?.tiers ? { tiers: fallback.tiers } : {}),
|
|
93
108
|
};
|
|
94
109
|
}
|
|
95
110
|
function getFallbackProviderAndModel(id, ownedBy) {
|
|
@@ -129,7 +144,7 @@ async function fetchJson(url, apiKey, options) {
|
|
|
129
144
|
const { signal, cancel } = withTimeout(timeoutMs, options.signal);
|
|
130
145
|
try {
|
|
131
146
|
const response = await fetch(url, {
|
|
132
|
-
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
147
|
+
headers: { ...options.headers, Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
133
148
|
signal,
|
|
134
149
|
});
|
|
135
150
|
if (!response.ok)
|
|
@@ -199,27 +214,31 @@ function mapFromModelInfo(entry) {
|
|
|
199
214
|
if (!id)
|
|
200
215
|
return undefined;
|
|
201
216
|
const info = entry.model_info ?? {};
|
|
202
|
-
if (info.mode
|
|
217
|
+
if (!isChatStyleMode(info.mode))
|
|
203
218
|
return undefined;
|
|
219
|
+
const responsesMode = isResponsesMode(info.mode);
|
|
204
220
|
const catalogModel = findCatalogModel(id);
|
|
205
221
|
return {
|
|
206
222
|
id,
|
|
207
223
|
name: id,
|
|
208
224
|
reasoning: info.supports_reasoning ?? false,
|
|
225
|
+
...(catalogModel?.thinkingLevelMap ? { thinkingLevelMap: catalogModel.thinkingLevelMap } : {}),
|
|
209
226
|
input: info.supports_vision ? ["text", "image"] : ["text"],
|
|
210
227
|
cost: mapModelInfoCost(info, catalogModel?.cost),
|
|
211
228
|
contextWindow: info.max_input_tokens ?? DEFAULT_CONTEXT_WINDOW,
|
|
212
229
|
maxTokens: info.max_output_tokens ?? DEFAULT_MAX_TOKENS,
|
|
213
230
|
compat: buildCompat(id),
|
|
231
|
+
...(responsesMode ? { api: "openai-responses" } : {}),
|
|
214
232
|
};
|
|
215
233
|
}
|
|
216
234
|
function mapFromHealthModelInfo(entry, fallbackId) {
|
|
217
235
|
const model = mapFromModelInfo(entry);
|
|
218
236
|
if (model || !fallbackId)
|
|
219
237
|
return model;
|
|
220
|
-
if (entry.model_info?.mode
|
|
238
|
+
if (!isChatStyleMode(entry.model_info?.mode))
|
|
221
239
|
return undefined;
|
|
222
240
|
const info = entry.model_info ?? {};
|
|
241
|
+
const responsesMode = isResponsesMode(info.mode);
|
|
223
242
|
const catalogModel = findCatalogModel(fallbackId);
|
|
224
243
|
return {
|
|
225
244
|
id: fallbackId,
|
|
@@ -230,6 +249,7 @@ function mapFromHealthModelInfo(entry, fallbackId) {
|
|
|
230
249
|
contextWindow: info.max_input_tokens ?? DEFAULT_CONTEXT_WINDOW,
|
|
231
250
|
maxTokens: info.max_output_tokens ?? DEFAULT_MAX_TOKENS,
|
|
232
251
|
compat: buildCompat(fallbackId),
|
|
252
|
+
...(responsesMode ? { api: "openai-responses" } : {}),
|
|
233
253
|
};
|
|
234
254
|
}
|
|
235
255
|
function mapFromHealthEndpoint(entry) {
|
|
@@ -268,45 +288,75 @@ function mapFromModelsList(entry, modelsDev) {
|
|
|
268
288
|
};
|
|
269
289
|
}
|
|
270
290
|
async function discoverFromHealth(base, apiKey, options) {
|
|
291
|
+
options.onProgress?.("Querying /health endpoint...");
|
|
271
292
|
const healthResult = await fetchJson(`${base}/health`, apiKey, options);
|
|
272
293
|
if (!healthResult.ok)
|
|
273
294
|
return [];
|
|
274
295
|
const endpoints = (healthResult.data.healthy_endpoints ?? []).filter((entry) => entry.model || entry.model_id);
|
|
296
|
+
options.onProgress?.(`Discovered ${endpoints.length} model endpoints, fetching details...`);
|
|
297
|
+
let completed = 0;
|
|
275
298
|
const models = await Promise.all(endpoints.map(async (endpoint) => {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
299
|
+
let model = mapFromHealthEndpoint(endpoint);
|
|
300
|
+
if (endpoint.model_id) {
|
|
301
|
+
const infoResult = await fetchJson(`${base}/model/info?litellm_model_id=${encodeURIComponent(endpoint.model_id)}`, apiKey, options);
|
|
302
|
+
const entry = infoResult.ok ? infoResult.data.data?.[0] : undefined;
|
|
303
|
+
if (entry)
|
|
304
|
+
model = mapFromHealthModelInfo(entry, endpoint.model);
|
|
305
|
+
}
|
|
306
|
+
completed++;
|
|
307
|
+
if (completed % 10 === 0 || completed === endpoints.length) {
|
|
308
|
+
options.onProgress?.(`Fetched ${completed}/${endpoints.length} models...`);
|
|
309
|
+
}
|
|
310
|
+
return model;
|
|
283
311
|
}));
|
|
284
312
|
return models.filter((model) => model !== undefined);
|
|
285
313
|
}
|
|
314
|
+
function deduplicateModels(models) {
|
|
315
|
+
const seen = new Set();
|
|
316
|
+
return models.filter((m) => {
|
|
317
|
+
if (seen.has(m.id))
|
|
318
|
+
return false;
|
|
319
|
+
seen.add(m.id);
|
|
320
|
+
return true;
|
|
321
|
+
});
|
|
322
|
+
}
|
|
286
323
|
export async function discoverModels(baseUrl, apiKey, options = {}) {
|
|
287
324
|
const base = normalizeBaseUrl(baseUrl);
|
|
325
|
+
options.onProgress?.("Querying /model/info endpoint...");
|
|
288
326
|
const infoResult = await fetchJson(`${base}/model/info`, apiKey, options);
|
|
289
327
|
if (infoResult.ok) {
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
.
|
|
328
|
+
const entries = new Map();
|
|
329
|
+
for (const entry of infoResult.data.data ?? []) {
|
|
330
|
+
if (!entry.model_name)
|
|
331
|
+
continue;
|
|
332
|
+
const previous = entries.get(entry.model_name);
|
|
333
|
+
entries.set(entry.model_name, {
|
|
334
|
+
...previous,
|
|
335
|
+
...entry,
|
|
336
|
+
model_info: { ...previous?.model_info, ...entry.model_info },
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
const models = [...entries.values()].map(mapFromModelInfo).filter((m) => m !== undefined);
|
|
293
340
|
return { source: "model_info", models };
|
|
294
341
|
}
|
|
295
342
|
if (![401, 403, 404].includes(infoResult.status)) {
|
|
296
343
|
throw new Error(`/model/info returned ${infoResult.status}`);
|
|
297
344
|
}
|
|
345
|
+
options.onProgress?.("/model/info unavailable, trying /v1/models...");
|
|
298
346
|
const listResult = await fetchJson(`${base}/v1/models`, apiKey, options);
|
|
299
347
|
if (!listResult.ok) {
|
|
300
348
|
if ([401, 403, 404].includes(listResult.status)) {
|
|
349
|
+
options.onProgress?.("/v1/models unavailable, falling back to /health endpoint...");
|
|
301
350
|
const models = await discoverFromHealth(base, apiKey, options);
|
|
302
351
|
if (models.length > 0)
|
|
303
|
-
return { source: "health", models };
|
|
352
|
+
return { source: "health", models: deduplicateModels(models) };
|
|
304
353
|
}
|
|
305
354
|
throw new Error(`/v1/models returned ${listResult.status}`);
|
|
306
355
|
}
|
|
356
|
+
options.onProgress?.("Fetching models.dev catalog for metadata enrichment...");
|
|
307
357
|
const modelsDev = await getModelsDevCatalog(options);
|
|
308
358
|
const models = (listResult.data.data ?? [])
|
|
309
359
|
.map((entry) => mapFromModelsList(entry, modelsDev))
|
|
310
360
|
.filter((m) => m !== undefined);
|
|
311
|
-
return { source: "models_list", models };
|
|
361
|
+
return { source: "models_list", models: deduplicateModels(models) };
|
|
312
362
|
}
|