pi-provider-litellm 1.2.6 → 1.2.8
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 +42 -24
- package/dist/cache.js +1 -1
- package/dist/discover.d.ts +4 -0
- package/dist/discover.js +99 -15
- package/dist/gcloud-token-cli.d.ts +1 -0
- package/dist/gcloud-token-cli.js +8 -0
- package/dist/gcloud-token.d.ts +7 -0
- package/dist/gcloud-token.js +133 -0
- package/dist/index.js +205 -23
- package/dist/mcp-tools.d.ts +5 -0
- package/dist/mcp-tools.js +131 -0
- package/dist/skills.d.ts +13 -0
- package/dist/skills.js +145 -0
- package/dist/types.d.ts +25 -1
- package/package.json +5 -7
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 a self-hosted LiteLLM proxy and registers them under the `litellm` provider. Supports `/login litellm
|
|
5
|
+
Discovers models from a self-hosted LiteLLM proxy and registers them under the `litellm` provider. 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
|
|
|
@@ -44,6 +44,21 @@ You can also run `/login`, select `Use a subscription`, then select `LiteLLM`.
|
|
|
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
|
|
|
47
|
+
#### Enterprise SSO login
|
|
48
|
+
|
|
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
|
+
|
|
51
|
+
1. Run `/login litellm` inside pi
|
|
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
|
|
57
|
+
|
|
58
|
+
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
|
+
|
|
60
|
+
When using a JWT directly, the extension reads its `exp` claim and Pi will prompt you to re-authenticate when the token nears expiry. Run `/login litellm` again to refresh.
|
|
61
|
+
|
|
47
62
|
### Option B — environment variables
|
|
48
63
|
|
|
49
64
|
```bash
|
|
@@ -64,43 +79,41 @@ Stored pi credentials for `litellm` take precedence over `LITELLM_API_KEY`; the
|
|
|
64
79
|
| Variable | Default | Effect |
|
|
65
80
|
|---|---|---|
|
|
66
81
|
| `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
|
+
| `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 when no stored `/login litellm` credential exists. If ADC is unavailable, the extension falls back to `LITELLM_API_KEY_HELPER` or `LITELLM_API_KEY`. |
|
|
83
|
+
| `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. |
|
|
67
84
|
| `LITELLM_OFFLINE` | unset | If `1`, skip discovery on this start; use cache only |
|
|
68
85
|
| `LITELLM_DISCOVERY_TIMEOUT_MS` | `5000` | Discovery fetch timeout in ms; `0` to skip discovery |
|
|
69
86
|
|
|
70
87
|
`LITELLM_DISCOVERY_TIMEOUT_MS=0` only disables startup and refresh model discovery. It does not replace the base URL or API key settings required to send requests when you are not using `/login litellm`.
|
|
71
88
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
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.
|
|
75
|
-
|
|
76
|
-
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 workflow also runs a non-interactive Pi CLI smoke with `--list-models` and `-p` so extension loading, model discovery, and a real completion path are covered without opening the TUI.
|
|
89
|
+
### Google ADC token auth
|
|
77
90
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
This package requires Node.js `>=22.19.0`. CI currently uses Node `24.16.0`.
|
|
91
|
+
When your LiteLLM proxy accepts Google OAuth access tokens, you can let the extension refresh tokens from Application Default Credentials:
|
|
81
92
|
|
|
82
93
|
```bash
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
94
|
+
gcloud auth application-default login
|
|
95
|
+
export LITELLM_BASE_URL="https://litellm.your-domain.com"
|
|
96
|
+
export LITELLM_GCLOUD_TOKEN_AUTH=1
|
|
86
97
|
```
|
|
87
98
|
|
|
88
|
-
`
|
|
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 are keyed by the ADC identity, so switching `GOOGLE_APPLICATION_CREDENTIALS` or changing `gcloud auth application-default login` users refreshes both request auth and model discovery cache identity. When ADC is available, the registered provider key is a Pi `!command`, so request-time auth resolves a fresh token when Pi sends model requests. If ADC cannot be read, provider registration uses the same helper or environment API key that discovery used.
|
|
89
100
|
|
|
90
|
-
|
|
101
|
+
## LiteLLM MCP tools
|
|
91
102
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
103
|
+
If your LiteLLM proxy exposes MCP REST endpoints, this extension discovers tools from:
|
|
104
|
+
|
|
105
|
+
- `GET /mcp-rest/tools/list`
|
|
106
|
+
- `POST /mcp-rest/tools/call`
|
|
96
107
|
|
|
97
|
-
The
|
|
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. The extension accepts LiteLLM's `tools` response shape with `inputSchema` and `mcp_info`, and executes tools with LiteLLM's `server_id`, `name`, and `arguments` fields. MCP discovery runs after successful live model discovery, `/login litellm`, or `/litellm-refresh`; it does not force network or helper-token access when a fresh model cache is used.
|
|
98
109
|
|
|
99
|
-
##
|
|
110
|
+
## LiteLLM Skills Gateway
|
|
100
111
|
|
|
101
|
-
|
|
112
|
+
If your LiteLLM proxy exposes `/v1/skills`, enabled skills are fetched before each agent turn and appended to the system prompt as a `litellm_skills` section. The extension also registers Pi tools for basic Skills Gateway management:
|
|
102
113
|
|
|
103
|
-
|
|
114
|
+
- `litellm_skill_list`
|
|
115
|
+
- `litellm_skill_create`
|
|
116
|
+
- `litellm_skill_delete`
|
|
104
117
|
|
|
105
118
|
## Slash commands
|
|
106
119
|
|
|
@@ -108,7 +121,7 @@ Before tagging a release, keep `package.json` and `package-lock.json` versions i
|
|
|
108
121
|
|
|
109
122
|
## Cache
|
|
110
123
|
|
|
111
|
-
The model list is cached at `~/.pi/agent/litellm-models.json` with a keyed fingerprint of the base URL
|
|
124
|
+
The model list is cached at `~/.pi/agent/litellm-models.json` with the base URL and a keyed fingerprint of the selected auth source. Changing the base URL, API key, helper command, or Google ADC identity invalidates the cache automatically.
|
|
112
125
|
|
|
113
126
|
If the cache is older than 24 hours, the extension refreshes it in the background on session start (non-blocking). Failures are silent — the cached models remain in use. Run `/litellm-refresh` to force an immediate refresh.
|
|
114
127
|
|
|
@@ -117,10 +130,15 @@ If the cache is older than 24 hours, the extension refreshes it in the backgroun
|
|
|
117
130
|
| Symptom | Likely cause |
|
|
118
131
|
|---|---|
|
|
119
132
|
| "no credentials" warning at startup | Env vars not set and no OAuth credential — run `/login litellm` |
|
|
120
|
-
| "discovered no models" | Proxy returned an empty list — check pi's startup log
|
|
133
|
+
| "discovered no models" | Proxy returned an empty list — check pi's startup log and verify `/model/info`, `/v1/models`, or `/health` responds |
|
|
121
134
|
| `/model/info` returning 401/403/404 | Expected behavior with virtual keys — extension falls back to `/v1/models` |
|
|
122
135
|
| Discovery times out | Increase `LITELLM_DISCOVERY_TIMEOUT_MS` or set `LITELLM_OFFLINE=1` to fall back on cached models |
|
|
123
136
|
| `401 Token expired` | Set `LITELLM_API_KEY_HELPER`. |
|
|
137
|
+
| 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 |
|
|
138
|
+
| 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
|
+
| 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
|
+
| 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 |
|
|
124
142
|
|
|
125
143
|
## License
|
|
126
144
|
|
package/dist/cache.js
CHANGED
|
@@ -36,7 +36,7 @@ function isCacheFileShape(value) {
|
|
|
36
36
|
return (typeof v.baseUrl === "string" &&
|
|
37
37
|
typeof v.apiKeyFingerprint === "string" &&
|
|
38
38
|
typeof v.fetchedAt === "number" &&
|
|
39
|
-
(v.source === "model_info" || v.source === "models_list") &&
|
|
39
|
+
(v.source === "model_info" || v.source === "models_list" || v.source === "health") &&
|
|
40
40
|
Array.isArray(v.models));
|
|
41
41
|
}
|
|
42
42
|
export async function writeCache(path, cache) {
|
package/dist/discover.d.ts
CHANGED
|
@@ -4,4 +4,8 @@ export declare function normalizeBaseUrl(input: string): string;
|
|
|
4
4
|
export declare function isMoonshotModel(modelId: string): boolean;
|
|
5
5
|
export declare function shouldSuppressReasoningContent(modelId: string): boolean;
|
|
6
6
|
export declare function buildCompat(modelId: string): ProviderModelConfig["compat"];
|
|
7
|
+
export declare function withTimeout(timeoutMs: number, signal?: AbortSignal): {
|
|
8
|
+
signal: AbortSignal;
|
|
9
|
+
cancel: () => void;
|
|
10
|
+
};
|
|
7
11
|
export declare function discoverModels(baseUrl: string, apiKey: string, options?: DiscoveryOptions): Promise<DiscoveryResult>;
|
package/dist/discover.js
CHANGED
|
@@ -45,22 +45,53 @@ function toKnownProvider(provider) {
|
|
|
45
45
|
}
|
|
46
46
|
function findCatalogModel(id, ownedBy) {
|
|
47
47
|
const prefixProvider = toKnownProvider(id.split("/")[0]);
|
|
48
|
-
const
|
|
48
|
+
const lookupIds = catalogLookupIds(id);
|
|
49
|
+
const candidates = [toKnownProvider(ownedBy), prefixProvider, lookupIds.length > 1 ? "anthropic" : undefined].filter((provider) => provider !== undefined);
|
|
49
50
|
for (const provider of candidates) {
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
52
|
-
return
|
|
53
|
-
const providerQualified = getModels(provider).find((model) => model.id === `${provider}/${id}`);
|
|
54
|
-
if (providerQualified)
|
|
55
|
-
return providerQualified;
|
|
51
|
+
const match = findCatalogModelInProvider(provider, lookupIds);
|
|
52
|
+
if (match)
|
|
53
|
+
return match;
|
|
56
54
|
}
|
|
57
55
|
for (const provider of getProviders()) {
|
|
58
|
-
const
|
|
56
|
+
const match = findCatalogModelInProvider(provider, lookupIds);
|
|
57
|
+
if (match)
|
|
58
|
+
return match;
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
function catalogLookupIds(id) {
|
|
63
|
+
const lookupIds = new Set([id]);
|
|
64
|
+
const unprefixed = id.includes("/") ? id.slice(id.indexOf("/") + 1) : id;
|
|
65
|
+
lookupIds.add(unprefixed);
|
|
66
|
+
const anthropicAlias = unprefixed.toLowerCase().replaceAll(".", "-");
|
|
67
|
+
const match = /^(?:claude-)?(opus|sonnet|haiku)-(\d+)-(\d+)$/.exec(anthropicAlias);
|
|
68
|
+
if (match)
|
|
69
|
+
lookupIds.add(`claude-${match[1]}-${match[2]}-${match[3]}`);
|
|
70
|
+
return [...lookupIds];
|
|
71
|
+
}
|
|
72
|
+
function findCatalogModelInProvider(provider, lookupIds) {
|
|
73
|
+
for (const lookupId of lookupIds) {
|
|
74
|
+
const exact = getModels(provider).find((model) => model.id === lookupId);
|
|
59
75
|
if (exact)
|
|
60
76
|
return exact;
|
|
77
|
+
const providerQualified = getModels(provider).find((model) => model.id === `${provider}/${lookupId}`);
|
|
78
|
+
if (providerQualified)
|
|
79
|
+
return providerQualified;
|
|
61
80
|
}
|
|
62
81
|
return undefined;
|
|
63
82
|
}
|
|
83
|
+
function mapModelInfoCost(info, fallback) {
|
|
84
|
+
return {
|
|
85
|
+
input: info.input_cost_per_token !== undefined ? info.input_cost_per_token * 1_000_000 : (fallback?.input ?? 0),
|
|
86
|
+
output: info.output_cost_per_token !== undefined ? info.output_cost_per_token * 1_000_000 : (fallback?.output ?? 0),
|
|
87
|
+
cacheRead: info.cache_read_input_token_cost !== undefined
|
|
88
|
+
? info.cache_read_input_token_cost * 1_000_000
|
|
89
|
+
: (fallback?.cacheRead ?? 0),
|
|
90
|
+
cacheWrite: info.cache_creation_input_token_cost !== undefined
|
|
91
|
+
? info.cache_creation_input_token_cost * 1_000_000
|
|
92
|
+
: (fallback?.cacheWrite ?? 0),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
64
95
|
function getFallbackProviderAndModel(id, ownedBy) {
|
|
65
96
|
const [prefix, ...rest] = id.split("/");
|
|
66
97
|
const prefixProvider = toKnownProvider(prefix);
|
|
@@ -75,7 +106,7 @@ function findModelsDevModel(catalog, id, ownedBy) {
|
|
|
75
106
|
return undefined;
|
|
76
107
|
return catalog?.[provider]?.models?.[modelId];
|
|
77
108
|
}
|
|
78
|
-
function withTimeout(timeoutMs, signal) {
|
|
109
|
+
export function withTimeout(timeoutMs, signal) {
|
|
79
110
|
const controller = new AbortController();
|
|
80
111
|
const onAbort = () => controller.abort(signal?.reason);
|
|
81
112
|
if (signal) {
|
|
@@ -170,19 +201,51 @@ function mapFromModelInfo(entry) {
|
|
|
170
201
|
const info = entry.model_info ?? {};
|
|
171
202
|
if (info.mode && info.mode !== "chat")
|
|
172
203
|
return undefined;
|
|
204
|
+
const catalogModel = findCatalogModel(id);
|
|
173
205
|
return {
|
|
174
206
|
id,
|
|
175
207
|
name: id,
|
|
176
208
|
reasoning: info.supports_reasoning ?? false,
|
|
177
209
|
input: info.supports_vision ? ["text", "image"] : ["text"],
|
|
178
|
-
cost:
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
210
|
+
cost: mapModelInfoCost(info, catalogModel?.cost),
|
|
211
|
+
contextWindow: info.max_input_tokens ?? DEFAULT_CONTEXT_WINDOW,
|
|
212
|
+
maxTokens: info.max_output_tokens ?? DEFAULT_MAX_TOKENS,
|
|
213
|
+
compat: buildCompat(id),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function mapFromHealthModelInfo(entry, fallbackId) {
|
|
217
|
+
const model = mapFromModelInfo(entry);
|
|
218
|
+
if (model || !fallbackId)
|
|
219
|
+
return model;
|
|
220
|
+
if (entry.model_info?.mode && entry.model_info.mode !== "chat")
|
|
221
|
+
return undefined;
|
|
222
|
+
const info = entry.model_info ?? {};
|
|
223
|
+
const catalogModel = findCatalogModel(fallbackId);
|
|
224
|
+
return {
|
|
225
|
+
id: fallbackId,
|
|
226
|
+
name: fallbackId,
|
|
227
|
+
reasoning: info.supports_reasoning ?? false,
|
|
228
|
+
input: info.supports_vision ? ["text", "image"] : ["text"],
|
|
229
|
+
cost: mapModelInfoCost(info, catalogModel?.cost),
|
|
184
230
|
contextWindow: info.max_input_tokens ?? DEFAULT_CONTEXT_WINDOW,
|
|
185
231
|
maxTokens: info.max_output_tokens ?? DEFAULT_MAX_TOKENS,
|
|
232
|
+
compat: buildCompat(fallbackId),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function mapFromHealthEndpoint(entry) {
|
|
236
|
+
const id = entry.model;
|
|
237
|
+
if (!id)
|
|
238
|
+
return undefined;
|
|
239
|
+
const catalogModel = findCatalogModel(id);
|
|
240
|
+
return {
|
|
241
|
+
id,
|
|
242
|
+
name: catalogModel?.name ?? id,
|
|
243
|
+
reasoning: catalogModel?.reasoning ?? false,
|
|
244
|
+
thinkingLevelMap: catalogModel?.thinkingLevelMap,
|
|
245
|
+
input: catalogModel?.input ?? ["text"],
|
|
246
|
+
cost: catalogModel?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
247
|
+
contextWindow: catalogModel?.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
248
|
+
maxTokens: catalogModel?.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
186
249
|
compat: buildCompat(id),
|
|
187
250
|
};
|
|
188
251
|
}
|
|
@@ -204,6 +267,22 @@ function mapFromModelsList(entry, modelsDev) {
|
|
|
204
267
|
compat: buildCompat(id),
|
|
205
268
|
};
|
|
206
269
|
}
|
|
270
|
+
async function discoverFromHealth(base, apiKey, options) {
|
|
271
|
+
const healthResult = await fetchJson(`${base}/health`, apiKey, options);
|
|
272
|
+
if (!healthResult.ok)
|
|
273
|
+
return [];
|
|
274
|
+
const endpoints = (healthResult.data.healthy_endpoints ?? []).filter((entry) => entry.model || entry.model_id);
|
|
275
|
+
const models = await Promise.all(endpoints.map(async (endpoint) => {
|
|
276
|
+
if (!endpoint.model_id)
|
|
277
|
+
return mapFromHealthEndpoint(endpoint);
|
|
278
|
+
const infoResult = await fetchJson(`${base}/model/info?litellm_model_id=${encodeURIComponent(endpoint.model_id)}`, apiKey, options);
|
|
279
|
+
if (!infoResult.ok)
|
|
280
|
+
return mapFromHealthEndpoint(endpoint);
|
|
281
|
+
const entry = infoResult.data.data?.[0];
|
|
282
|
+
return entry ? mapFromHealthModelInfo(entry, endpoint.model) : mapFromHealthEndpoint(endpoint);
|
|
283
|
+
}));
|
|
284
|
+
return models.filter((model) => model !== undefined);
|
|
285
|
+
}
|
|
207
286
|
export async function discoverModels(baseUrl, apiKey, options = {}) {
|
|
208
287
|
const base = normalizeBaseUrl(baseUrl);
|
|
209
288
|
const infoResult = await fetchJson(`${base}/model/info`, apiKey, options);
|
|
@@ -218,6 +297,11 @@ export async function discoverModels(baseUrl, apiKey, options = {}) {
|
|
|
218
297
|
}
|
|
219
298
|
const listResult = await fetchJson(`${base}/v1/models`, apiKey, options);
|
|
220
299
|
if (!listResult.ok) {
|
|
300
|
+
if ([401, 403, 404].includes(listResult.status)) {
|
|
301
|
+
const models = await discoverFromHealth(base, apiKey, options);
|
|
302
|
+
if (models.length > 0)
|
|
303
|
+
return { source: "health", models };
|
|
304
|
+
}
|
|
221
305
|
throw new Error(`/v1/models returned ${listResult.status}`);
|
|
222
306
|
}
|
|
223
307
|
const modelsDev = await getModelsDevCatalog(options);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const CACHE_TTL_MS: number;
|
|
2
|
+
export declare const GCLOUD_TOKEN_CACHE_KEY = "gcloud-adc";
|
|
3
|
+
export declare function isGcloudTokenAuthEnabled(): boolean;
|
|
4
|
+
export declare function getGcloudTokenCommand(): string;
|
|
5
|
+
export declare function getGcloudTokenCacheKey(): Promise<string | null>;
|
|
6
|
+
export declare function getGcloudToken(): Promise<string | null>;
|
|
7
|
+
export declare function resetGcloudTokenCache(): void;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
export const CACHE_TTL_MS = 50 * 60 * 1000;
|
|
7
|
+
export const GCLOUD_TOKEN_CACHE_KEY = "gcloud-adc";
|
|
8
|
+
const ADC_FILENAME = "application_default_credentials.json";
|
|
9
|
+
let cachedToken = null;
|
|
10
|
+
let cachedAt = 0;
|
|
11
|
+
let cachedTokenKey = null;
|
|
12
|
+
function isAuthorizedUserCredentials(credentials) {
|
|
13
|
+
return (credentials.type === "authorized_user" &&
|
|
14
|
+
typeof credentials.client_id === "string" &&
|
|
15
|
+
typeof credentials.client_secret === "string" &&
|
|
16
|
+
typeof credentials.refresh_token === "string");
|
|
17
|
+
}
|
|
18
|
+
export function isGcloudTokenAuthEnabled() {
|
|
19
|
+
const raw = process.env.LITELLM_GCLOUD_TOKEN_AUTH;
|
|
20
|
+
return raw !== undefined && raw !== "" && raw !== "0";
|
|
21
|
+
}
|
|
22
|
+
function shellQuote(value) {
|
|
23
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
24
|
+
}
|
|
25
|
+
export function getGcloudTokenCommand() {
|
|
26
|
+
const cliPath = fileURLToPath(new URL("./gcloud-token-cli.js", import.meta.url));
|
|
27
|
+
return `!${shellQuote(process.execPath)} ${shellQuote(cliPath)}`;
|
|
28
|
+
}
|
|
29
|
+
function getAdcPath() {
|
|
30
|
+
const envPath = process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
|
31
|
+
if (envPath)
|
|
32
|
+
return envPath;
|
|
33
|
+
const candidates = [join(homedir(), ".config", "gcloud", ADC_FILENAME)];
|
|
34
|
+
if (process.env.APPDATA)
|
|
35
|
+
candidates.push(join(process.env.APPDATA, "gcloud", ADC_FILENAME));
|
|
36
|
+
return candidates.find((path) => existsSync(path)) ?? null;
|
|
37
|
+
}
|
|
38
|
+
function getCredentialsCacheKey(credentials) {
|
|
39
|
+
if (isAuthorizedUserCredentials(credentials)) {
|
|
40
|
+
return `${GCLOUD_TOKEN_CACHE_KEY}:authorized_user:${credentials.client_id}:${credentials.refresh_token}`;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
async function readCredentials(path) {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export async function getGcloudTokenCacheKey() {
|
|
53
|
+
const adcPath = getAdcPath();
|
|
54
|
+
if (!adcPath) {
|
|
55
|
+
console.warn("LiteLLM gcloud auth: No Google ADC file found. Set GOOGLE_APPLICATION_CREDENTIALS or run `gcloud auth application-default login`.");
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const credentials = await readCredentials(adcPath);
|
|
59
|
+
if (!credentials) {
|
|
60
|
+
console.warn(`LiteLLM gcloud auth: Failed to read ADC file: ${adcPath}`);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const cacheKey = getCredentialsCacheKey(credentials);
|
|
64
|
+
if (cacheKey)
|
|
65
|
+
return cacheKey;
|
|
66
|
+
if (credentials.type === "service_account") {
|
|
67
|
+
console.warn("LiteLLM gcloud auth: Service account credentials are not supported; use authorized_user ADC.");
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
console.warn(`LiteLLM gcloud auth: Unknown credential type: ${credentials.type ?? "missing"}`);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
async function exchangeRefreshToken(credentials) {
|
|
74
|
+
const body = new URLSearchParams({
|
|
75
|
+
grant_type: "refresh_token",
|
|
76
|
+
client_id: credentials.client_id,
|
|
77
|
+
client_secret: credentials.client_secret,
|
|
78
|
+
refresh_token: credentials.refresh_token,
|
|
79
|
+
}).toString();
|
|
80
|
+
try {
|
|
81
|
+
const response = await fetch("https://oauth2.googleapis.com/token", {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
84
|
+
body,
|
|
85
|
+
signal: AbortSignal.timeout(10_000),
|
|
86
|
+
});
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
console.warn(`LiteLLM gcloud auth: Token exchange failed (${response.status}): ${await response.text()}`);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const data = (await response.json());
|
|
92
|
+
return typeof data.access_token === "string" && data.access_token ? data.access_token : null;
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
console.warn(`LiteLLM gcloud auth: Token exchange failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export async function getGcloudToken() {
|
|
100
|
+
const adcPath = getAdcPath();
|
|
101
|
+
if (!adcPath) {
|
|
102
|
+
console.warn("LiteLLM gcloud auth: No Google ADC file found. Set GOOGLE_APPLICATION_CREDENTIALS or run `gcloud auth application-default login`.");
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const credentials = await readCredentials(adcPath);
|
|
106
|
+
if (!credentials) {
|
|
107
|
+
console.warn(`LiteLLM gcloud auth: Failed to read ADC file: ${adcPath}`);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const cacheKey = getCredentialsCacheKey(credentials);
|
|
111
|
+
if (cacheKey && cachedToken && cachedTokenKey === cacheKey && Date.now() - cachedAt < CACHE_TTL_MS)
|
|
112
|
+
return cachedToken;
|
|
113
|
+
if (isAuthorizedUserCredentials(credentials)) {
|
|
114
|
+
const token = await exchangeRefreshToken(credentials);
|
|
115
|
+
if (token) {
|
|
116
|
+
cachedToken = token;
|
|
117
|
+
cachedTokenKey = cacheKey;
|
|
118
|
+
cachedAt = Date.now();
|
|
119
|
+
}
|
|
120
|
+
return token;
|
|
121
|
+
}
|
|
122
|
+
if (credentials.type === "service_account") {
|
|
123
|
+
console.warn("LiteLLM gcloud auth: Service account credentials are not supported; use authorized_user ADC.");
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
console.warn(`LiteLLM gcloud auth: Unknown credential type: ${credentials.type ?? "missing"}`);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
export function resetGcloudTokenCache() {
|
|
130
|
+
cachedToken = null;
|
|
131
|
+
cachedTokenKey = null;
|
|
132
|
+
cachedAt = 0;
|
|
133
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import { execSync } from "node:child_process";
|
|
1
|
+
import { execSync, spawn } from "node:child_process";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { AuthStorage, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { fingerprint, readCache, writeCache } from "./cache.js";
|
|
6
6
|
import { setupLiteLLMCostTracking } from "./cost.js";
|
|
7
|
-
import { discoverModels, normalizeBaseUrl, shouldSuppressReasoningContent } from "./discover.js";
|
|
7
|
+
import { discoverModels, normalizeBaseUrl, shouldSuppressReasoningContent, withTimeout } from "./discover.js";
|
|
8
|
+
import { getGcloudToken, getGcloudTokenCacheKey, getGcloudTokenCommand, isGcloudTokenAuthEnabled, } from "./gcloud-token.js";
|
|
8
9
|
import { getSessionIdFromFile } from "./litellm.js";
|
|
10
|
+
import { createMcpToolDefinitions } from "./mcp-tools.js";
|
|
11
|
+
import { createSkillsPromptSection, createSkillToolDefinitions, listSkills } from "./skills.js";
|
|
9
12
|
const PROVIDER_NAME = "litellm";
|
|
10
13
|
const ENV_BASE_URL = "LITELLM_BASE_URL";
|
|
11
14
|
const ENV_API_KEY = "LITELLM_API_KEY";
|
|
@@ -69,6 +72,45 @@ function tokenExpiresAt(apiKey, opaqueFallback = PERMANENT_TOKEN_EXPIRES_AT) {
|
|
|
69
72
|
return opaqueFallback;
|
|
70
73
|
}
|
|
71
74
|
}
|
|
75
|
+
function openInBrowser(url) {
|
|
76
|
+
// Never invoke a shell here: cmd.exe re-parses metacharacters before `start`
|
|
77
|
+
// runs, which would make URL contents injectable. Launch is best-effort; the
|
|
78
|
+
// URL is also shown to the user, so launcher failures must not crash the process.
|
|
79
|
+
const [cmd, args] = process.platform === "darwin"
|
|
80
|
+
? ["open", [url]]
|
|
81
|
+
: process.platform === "win32"
|
|
82
|
+
? ["rundll32", ["url.dll,FileProtocolHandler", url]]
|
|
83
|
+
: ["xdg-open", [url]];
|
|
84
|
+
spawn(cmd, args, { stdio: "ignore", detached: true })
|
|
85
|
+
.on("error", () => undefined)
|
|
86
|
+
.unref();
|
|
87
|
+
}
|
|
88
|
+
async function generateVirtualKey(baseUrl, userToken, signal) {
|
|
89
|
+
const { signal: boundedSignal, cancel } = withTimeout(LOGIN_TIMEOUT_MS, signal);
|
|
90
|
+
try {
|
|
91
|
+
const response = await fetch(`${baseUrl}/key/generate`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: {
|
|
94
|
+
Authorization: `Bearer ${userToken}`,
|
|
95
|
+
"Content-Type": "application/json",
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify({}),
|
|
98
|
+
signal: boundedSignal,
|
|
99
|
+
});
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
const text = await response.text().catch(() => "");
|
|
102
|
+
throw new Error(`Virtual key generation failed (${response.status}): ${text}`);
|
|
103
|
+
}
|
|
104
|
+
const data = (await response.json());
|
|
105
|
+
if (typeof data.key !== "string" || !data.key)
|
|
106
|
+
throw new Error("No key in response from /key/generate");
|
|
107
|
+
const expiresMs = typeof data.expires === "string" ? Date.parse(data.expires) : Number.NaN;
|
|
108
|
+
return { key: data.key, expiresAt: Number.isNaN(expiresMs) ? undefined : expiresMs };
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
cancel();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
72
114
|
function getLiteLLMApiKey(credentials) {
|
|
73
115
|
return credentials.access;
|
|
74
116
|
}
|
|
@@ -82,27 +124,51 @@ async function resolveCredentials({ executeHelpers = true } = {}) {
|
|
|
82
124
|
const envBase = cleanConfig(process.env[ENV_BASE_URL]);
|
|
83
125
|
const envKey = cleanConfig(process.env[ENV_API_KEY]);
|
|
84
126
|
const envHelperCommand = getApiKeyHelperCommand();
|
|
127
|
+
const useGcloudToken = isGcloudTokenAuthEnabled();
|
|
85
128
|
const authBase = entry?.type === "oauth" ? entry.baseUrl?.trim() : undefined;
|
|
129
|
+
const gcloudCacheKey = useGcloudToken && !entry ? ((await getGcloudTokenCacheKey()) ?? undefined) : undefined;
|
|
86
130
|
const authKey = entry?.type === "oauth"
|
|
87
131
|
? (executeHelpers ? resolveOAuthApiKey(entry) : entry.access).trim()
|
|
88
132
|
: entry?.type === "api_key"
|
|
89
133
|
? (await AuthStorage.create(getAuthPath()).getApiKey(PROVIDER_NAME, { includeFallback: false }))?.trim()
|
|
90
134
|
: undefined;
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
135
|
+
const gcloudKey = executeHelpers && gcloudCacheKey ? (await getGcloudToken())?.trim() : undefined;
|
|
136
|
+
const helperKey = !authKey && !gcloudKey && executeHelpers && envHelperCommand ? executeApiKeyCommand(envHelperCommand) : undefined;
|
|
137
|
+
const apiKey = authKey || gcloudKey || helperKey || envKey;
|
|
138
|
+
let apiKeyFingerprint;
|
|
139
|
+
let apiKeyConfig;
|
|
140
|
+
if (entry?.type === "oauth" && entry.refresh.startsWith("!")) {
|
|
141
|
+
apiKeyFingerprint = fingerprint(entry.refresh);
|
|
142
|
+
}
|
|
143
|
+
else if (authKey) {
|
|
144
|
+
apiKeyFingerprint = fingerprint(authKey);
|
|
145
|
+
}
|
|
146
|
+
else if (gcloudKey) {
|
|
147
|
+
apiKeyFingerprint = fingerprint(gcloudCacheKey ?? gcloudKey);
|
|
148
|
+
apiKeyConfig = getGcloudTokenCommand();
|
|
149
|
+
}
|
|
150
|
+
else if (!executeHelpers && gcloudCacheKey) {
|
|
151
|
+
apiKeyFingerprint = fingerprint(gcloudCacheKey);
|
|
152
|
+
apiKeyConfig = getGcloudTokenCommand();
|
|
153
|
+
}
|
|
154
|
+
else if (helperKey && envHelperCommand) {
|
|
155
|
+
apiKeyFingerprint = fingerprint(envHelperCommand);
|
|
156
|
+
apiKeyConfig = envHelperCommand;
|
|
157
|
+
}
|
|
158
|
+
else if (!executeHelpers && envHelperCommand) {
|
|
159
|
+
apiKeyFingerprint = fingerprint(envHelperCommand);
|
|
160
|
+
apiKeyConfig = envHelperCommand;
|
|
161
|
+
}
|
|
162
|
+
else if (envKey) {
|
|
163
|
+
apiKeyFingerprint = fingerprint(envKey);
|
|
164
|
+
apiKeyConfig = `$${ENV_API_KEY}`;
|
|
165
|
+
}
|
|
101
166
|
const rawBase = authBase || envBase;
|
|
102
167
|
return {
|
|
103
168
|
baseUrl: rawBase ? normalizeBaseUrl(rawBase) : undefined,
|
|
104
169
|
apiKey: apiKey || undefined,
|
|
105
170
|
apiKeyFingerprint,
|
|
171
|
+
apiKeyConfig,
|
|
106
172
|
};
|
|
107
173
|
}
|
|
108
174
|
function getDiscoveryTimeoutMs() {
|
|
@@ -137,12 +203,63 @@ async function loginLiteLLM(callbacks, onCacheWrite) {
|
|
|
137
203
|
message: "Enter LiteLLM proxy URL (no trailing /v1):",
|
|
138
204
|
placeholder: "https://litellm.example.com",
|
|
139
205
|
})).trim();
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
throw new Error("Both base URL and API key are required");
|
|
206
|
+
if (!rawBaseUrl)
|
|
207
|
+
throw new Error("Base URL is required");
|
|
143
208
|
const baseUrl = normalizeBaseUrl(rawBaseUrl);
|
|
144
|
-
const
|
|
145
|
-
|
|
209
|
+
const method = (await callbacks.onPrompt({
|
|
210
|
+
message: "Select login method (1 = API key / !command, 2 = SSO / Enterprise JWT):",
|
|
211
|
+
})).trim();
|
|
212
|
+
let apiKey;
|
|
213
|
+
let refresh;
|
|
214
|
+
let expires;
|
|
215
|
+
if (method === "2") {
|
|
216
|
+
callbacks.onAuth?.({
|
|
217
|
+
url: `${baseUrl}/sso/key/generate`,
|
|
218
|
+
instructions: "Authenticate via SSO, then copy your token from the LiteLLM UI.",
|
|
219
|
+
});
|
|
220
|
+
const rawToken = (await callbacks.onPrompt({ message: "Paste your SSO token from the LiteLLM UI:" }))
|
|
221
|
+
.trim()
|
|
222
|
+
.replace(/^Bearer\s+/i, "")
|
|
223
|
+
.trim();
|
|
224
|
+
if (!rawToken)
|
|
225
|
+
throw new Error("SSO token is required");
|
|
226
|
+
const wantVirtualKey = (await callbacks.onPrompt({ message: "Generate a LiteLLM virtual key from this token? (y/n):" }))
|
|
227
|
+
.trim()
|
|
228
|
+
.toLowerCase();
|
|
229
|
+
if (wantVirtualKey !== "n" && wantVirtualKey !== "no") {
|
|
230
|
+
try {
|
|
231
|
+
callbacks.onProgress?.("Generating virtual key...");
|
|
232
|
+
const generated = await generateVirtualKey(baseUrl, rawToken, callbacks.signal);
|
|
233
|
+
apiKey = generated.key;
|
|
234
|
+
refresh = "";
|
|
235
|
+
expires =
|
|
236
|
+
generated.expiresAt === undefined
|
|
237
|
+
? PERMANENT_TOKEN_EXPIRES_AT
|
|
238
|
+
: Math.max(Date.now(), generated.expiresAt - TOKEN_REFRESH_LEAD_MS);
|
|
239
|
+
callbacks.onProgress?.("Virtual key generated and will be used for API calls.");
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
243
|
+
callbacks.onProgress?.(`LiteLLM: virtual key generation failed (${message}); using SSO token directly.`);
|
|
244
|
+
apiKey = rawToken;
|
|
245
|
+
refresh = "";
|
|
246
|
+
expires = tokenExpiresAt(rawToken, PERMANENT_TOKEN_EXPIRES_AT);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
apiKey = rawToken;
|
|
251
|
+
refresh = "";
|
|
252
|
+
expires = tokenExpiresAt(rawToken, PERMANENT_TOKEN_EXPIRES_AT);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
const apiKeyInput = (await callbacks.onPrompt({ message: "Enter API key:" })).trim();
|
|
257
|
+
if (!apiKeyInput)
|
|
258
|
+
throw new Error("Both base URL and API key are required");
|
|
259
|
+
refresh = apiKeyInput.startsWith("!") ? apiKeyInput : "";
|
|
260
|
+
apiKey = refresh ? executeApiKeyCommand(refresh) : apiKeyInput;
|
|
261
|
+
expires = tokenExpiresAt(apiKey, refresh ? EXPIRE_TOKEN_IMMEDIATELY : PERMANENT_TOKEN_EXPIRES_AT);
|
|
262
|
+
}
|
|
146
263
|
const { models, source } = await discoverModels(baseUrl, apiKey, {
|
|
147
264
|
timeoutMs: LOGIN_TIMEOUT_MS,
|
|
148
265
|
signal: callbacks.signal,
|
|
@@ -160,13 +277,17 @@ async function loginLiteLLM(callbacks, onCacheWrite) {
|
|
|
160
277
|
return {
|
|
161
278
|
access: apiKey,
|
|
162
279
|
refresh,
|
|
163
|
-
expires
|
|
280
|
+
expires,
|
|
164
281
|
baseUrl,
|
|
165
282
|
};
|
|
166
283
|
}
|
|
167
284
|
async function refreshLiteLLM(credentials) {
|
|
168
|
-
if (!credentials.refresh.startsWith("!"))
|
|
285
|
+
if (!credentials.refresh.startsWith("!")) {
|
|
286
|
+
if (credentials.expires < PERMANENT_TOKEN_EXPIRES_AT) {
|
|
287
|
+
throw new Error("LiteLLM credential cannot be refreshed; run /login litellm again");
|
|
288
|
+
}
|
|
169
289
|
return credentials;
|
|
290
|
+
}
|
|
170
291
|
const access = executeApiKeyCommand(credentials.refresh);
|
|
171
292
|
return { ...credentials, access, expires: tokenExpiresAt(access, EXPIRE_TOKEN_IMMEDIATELY) };
|
|
172
293
|
}
|
|
@@ -274,6 +395,7 @@ export default async function (pi) {
|
|
|
274
395
|
getDiscoveryTimeoutMs() > 0 &&
|
|
275
396
|
(!cacheValid || isListModelsMode());
|
|
276
397
|
let credentialWarning;
|
|
398
|
+
let liveDiscoveryApiKey;
|
|
277
399
|
if (shouldFetch) {
|
|
278
400
|
try {
|
|
279
401
|
creds = await resolveCredentials();
|
|
@@ -304,6 +426,7 @@ export default async function (pi) {
|
|
|
304
426
|
}
|
|
305
427
|
else {
|
|
306
428
|
models = result.models;
|
|
429
|
+
liveDiscoveryApiKey = creds.apiKey;
|
|
307
430
|
const next = {
|
|
308
431
|
baseUrl: creds.baseUrl,
|
|
309
432
|
apiKeyFingerprint: fp,
|
|
@@ -330,7 +453,7 @@ export default async function (pi) {
|
|
|
330
453
|
getApiKey: getLiteLLMApiKey,
|
|
331
454
|
modifyModels: modifyLiteLLMModels,
|
|
332
455
|
};
|
|
333
|
-
function registerProvider(baseUrl, models) {
|
|
456
|
+
function registerProvider(baseUrl, models, apiKeyConfig = creds.apiKeyConfig ?? getApiKeyHelperCommand() ?? `$${ENV_API_KEY}`) {
|
|
334
457
|
pi.registerProvider(PROVIDER_NAME, {
|
|
335
458
|
baseUrl: baseUrl ? `${baseUrl}/v1` : "https://litellm.example.com/v1",
|
|
336
459
|
// When LITELLM_API_KEY_HELPER is set we register the helper as a `!command` provider key.
|
|
@@ -340,7 +463,7 @@ export default async function (pi) {
|
|
|
340
463
|
// resolveConfigValue). So a short-lived/rotating helper token stays fresh. The OAuth hooks
|
|
341
464
|
// remain registered for `/login litellm` users. See the regression test
|
|
342
465
|
// "re-runs the helper command on every request" in tests/index.test.ts.
|
|
343
|
-
apiKey:
|
|
466
|
+
apiKey: apiKeyConfig,
|
|
344
467
|
api: "openai-completions",
|
|
345
468
|
models,
|
|
346
469
|
oauth,
|
|
@@ -356,6 +479,45 @@ export default async function (pi) {
|
|
|
356
479
|
return `${ENV_TIMEOUT}=0`;
|
|
357
480
|
return null;
|
|
358
481
|
}
|
|
482
|
+
async function resolveRuntimeApiKey() {
|
|
483
|
+
const fresh = await resolveCredentials();
|
|
484
|
+
if (!fresh.apiKey)
|
|
485
|
+
throw new Error("no credentials. Run /login litellm or set env vars.");
|
|
486
|
+
return fresh.apiKey;
|
|
487
|
+
}
|
|
488
|
+
function registerSkillTools(baseUrl) {
|
|
489
|
+
if (!baseUrl)
|
|
490
|
+
return;
|
|
491
|
+
for (const tool of createSkillToolDefinitions(baseUrl, resolveRuntimeApiKey)) {
|
|
492
|
+
pi.registerTool(tool);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
function seededRuntimeApiKey(seed) {
|
|
496
|
+
let first = seed;
|
|
497
|
+
return async () => {
|
|
498
|
+
if (first) {
|
|
499
|
+
const value = first;
|
|
500
|
+
first = undefined;
|
|
501
|
+
return value;
|
|
502
|
+
}
|
|
503
|
+
return resolveRuntimeApiKey();
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
async function registerMcpTools(baseUrl, discoveryApiKey) {
|
|
507
|
+
if (!baseUrl || !discoveryApiKey || discoveryDisabledReason())
|
|
508
|
+
return;
|
|
509
|
+
try {
|
|
510
|
+
const tools = await createMcpToolDefinitions(baseUrl, seededRuntimeApiKey(discoveryApiKey));
|
|
511
|
+
for (const tool of tools) {
|
|
512
|
+
pi.registerTool(tool);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
process.stderr.write(`LiteLLM: MCP tool discovery failed (${error instanceof Error ? error.message : String(error)}).\n`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
registerSkillTools(creds.baseUrl);
|
|
520
|
+
await registerMcpTools(creds.baseUrl, liveDiscoveryApiKey);
|
|
359
521
|
async function refreshModelsAndCosts() {
|
|
360
522
|
const fresh = await resolveCredentials();
|
|
361
523
|
const freshFp = fresh.apiKeyFingerprint;
|
|
@@ -371,9 +533,10 @@ export default async function (pi) {
|
|
|
371
533
|
source: result.source,
|
|
372
534
|
models: result.models,
|
|
373
535
|
});
|
|
374
|
-
registerProvider(fresh.baseUrl, result.models);
|
|
536
|
+
registerProvider(fresh.baseUrl, result.models, fresh.apiKeyConfig);
|
|
375
537
|
updateCosts(result.models);
|
|
376
538
|
cacheFetchedAt = now;
|
|
539
|
+
await registerMcpTools(fresh.baseUrl, fresh.apiKey);
|
|
377
540
|
return { models: result.models, source: result.source };
|
|
378
541
|
}
|
|
379
542
|
function runRefresh() {
|
|
@@ -384,7 +547,10 @@ export default async function (pi) {
|
|
|
384
547
|
}
|
|
385
548
|
async function runLogin(ctx) {
|
|
386
549
|
const credential = await loginLiteLLM({
|
|
387
|
-
onAuth: () =>
|
|
550
|
+
onAuth: ({ url, instructions }) => {
|
|
551
|
+
openInBrowser(url);
|
|
552
|
+
ctx.ui.notify(instructions ? `${url} — ${instructions}` : url, "info");
|
|
553
|
+
},
|
|
388
554
|
onDeviceCode: () => undefined,
|
|
389
555
|
onPrompt: async ({ message, placeholder }) => {
|
|
390
556
|
const value = await ctx.ui.input(message, placeholder);
|
|
@@ -402,6 +568,10 @@ export default async function (pi) {
|
|
|
402
568
|
});
|
|
403
569
|
ctx.modelRegistry.authStorage.set(PROVIDER_NAME, { type: "oauth", ...credential });
|
|
404
570
|
ctx.modelRegistry.refresh();
|
|
571
|
+
const credentialBaseUrl = credential.baseUrl;
|
|
572
|
+
const credentialAccess = typeof credential.access === "string" ? credential.access : undefined;
|
|
573
|
+
registerSkillTools(credentialBaseUrl);
|
|
574
|
+
await registerMcpTools(credentialBaseUrl, credentialAccess);
|
|
405
575
|
ctx.ui.notify(`Logged in to LiteLLM. Credentials saved to ${getAuthPath()}`, "info");
|
|
406
576
|
}
|
|
407
577
|
pi.on("input", async (event, ctx) => {
|
|
@@ -451,6 +621,18 @@ export default async function (pi) {
|
|
|
451
621
|
return;
|
|
452
622
|
return prepareLiteLLMRequestPayload(event.payload, ctx.model?.id, sessionId);
|
|
453
623
|
});
|
|
624
|
+
pi.on("before_agent_start", async (event) => {
|
|
625
|
+
if (discoveryDisabledReason())
|
|
626
|
+
return;
|
|
627
|
+
const fresh = await resolveCredentials();
|
|
628
|
+
if (!fresh.baseUrl || !fresh.apiKey)
|
|
629
|
+
return;
|
|
630
|
+
const skills = await listSkills(fresh.baseUrl, fresh.apiKey);
|
|
631
|
+
const section = createSkillsPromptSection(skills);
|
|
632
|
+
if (!section)
|
|
633
|
+
return;
|
|
634
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${section}` };
|
|
635
|
+
});
|
|
454
636
|
pi.on("message_end", (event) => {
|
|
455
637
|
if (event.message.role !== "assistant")
|
|
456
638
|
return;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { LiteLLMMcpTool } from "./types.js";
|
|
3
|
+
export declare function discoverMcpTools(baseUrl: string, apiKey: string): Promise<LiteLLMMcpTool[]>;
|
|
4
|
+
export declare function executeMcpTool(baseUrl: string, apiKey: string, serverId: string, toolName: string, args: Record<string, unknown>): Promise<string>;
|
|
5
|
+
export declare function createMcpToolDefinitions(baseUrl: string, getApiKey: () => Promise<string>): Promise<ToolDefinition[]>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
2
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { normalizeBaseUrl } from "./discover.js";
|
|
4
|
+
const LIST_TIMEOUT_MS = 10_000;
|
|
5
|
+
const CALL_TIMEOUT_MS = 30_000;
|
|
6
|
+
function withTimeout(timeoutMs) {
|
|
7
|
+
const controller = new AbortController();
|
|
8
|
+
const timer = setTimeout(() => controller.abort(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
9
|
+
return {
|
|
10
|
+
signal: controller.signal,
|
|
11
|
+
cancel: () => clearTimeout(timer),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function asRecord(value) {
|
|
15
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
16
|
+
}
|
|
17
|
+
function stringValue(value) {
|
|
18
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
19
|
+
}
|
|
20
|
+
function normalizeMcpTool(value) {
|
|
21
|
+
const raw = asRecord(value);
|
|
22
|
+
if (!raw)
|
|
23
|
+
return undefined;
|
|
24
|
+
const name = stringValue(raw.name);
|
|
25
|
+
const serverName = stringValue(raw.mcp_info?.server_name) ??
|
|
26
|
+
stringValue(raw.server_name) ??
|
|
27
|
+
stringValue(raw.mcp_info?.server_id) ??
|
|
28
|
+
stringValue(raw.server_id);
|
|
29
|
+
if (!name || !serverName)
|
|
30
|
+
return undefined;
|
|
31
|
+
const inputSchema = asRecord(raw.inputSchema) ?? asRecord(raw.input_schema) ?? { type: "object", properties: {} };
|
|
32
|
+
return {
|
|
33
|
+
name,
|
|
34
|
+
server_name: serverName,
|
|
35
|
+
server_id: stringValue(raw.mcp_info?.server_id) ?? stringValue(raw.server_id) ?? serverName,
|
|
36
|
+
description: stringValue(raw.description) ?? name,
|
|
37
|
+
input_schema: inputSchema,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export async function discoverMcpTools(baseUrl, apiKey) {
|
|
41
|
+
const { signal, cancel } = withTimeout(LIST_TIMEOUT_MS);
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(`${normalizeBaseUrl(baseUrl)}/mcp-rest/tools/list`, {
|
|
44
|
+
headers: {
|
|
45
|
+
Authorization: `Bearer ${apiKey}`,
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
},
|
|
48
|
+
signal,
|
|
49
|
+
});
|
|
50
|
+
if (!response.ok)
|
|
51
|
+
return [];
|
|
52
|
+
const body = (await response.json());
|
|
53
|
+
const bodyRecord = asRecord(body);
|
|
54
|
+
const rawTools = Array.isArray(body) ? body : Array.isArray(bodyRecord?.tools) ? bodyRecord.tools : [];
|
|
55
|
+
return rawTools.map(normalizeMcpTool).filter((tool) => tool !== undefined);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
cancel();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function executeMcpTool(baseUrl, apiKey, serverId, toolName, args) {
|
|
65
|
+
const { signal, cancel } = withTimeout(CALL_TIMEOUT_MS);
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(`${normalizeBaseUrl(baseUrl)}/mcp-rest/tools/call`, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: {
|
|
70
|
+
Authorization: `Bearer ${apiKey}`,
|
|
71
|
+
"Content-Type": "application/json",
|
|
72
|
+
},
|
|
73
|
+
body: JSON.stringify({ server_id: serverId, name: toolName, arguments: args }),
|
|
74
|
+
signal,
|
|
75
|
+
});
|
|
76
|
+
if (!response.ok)
|
|
77
|
+
return `Error calling ${toolName} on ${serverId}: HTTP ${response.status}`;
|
|
78
|
+
const body = (await response.json());
|
|
79
|
+
return JSON.stringify("result" in body ? body.result : body, null, 2);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
return `Error calling ${toolName} on ${serverId}: ${error instanceof Error ? error.message : String(error)}`;
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
cancel();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function sanitizeName(name) {
|
|
89
|
+
const sanitized = name
|
|
90
|
+
.toLowerCase()
|
|
91
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
92
|
+
.replace(/^_+|_+$/g, "");
|
|
93
|
+
return sanitized || "tool";
|
|
94
|
+
}
|
|
95
|
+
function buildParameters(inputSchema) {
|
|
96
|
+
const properties = inputSchema.properties;
|
|
97
|
+
if (!properties || typeof properties !== "object") {
|
|
98
|
+
return Type.Object({
|
|
99
|
+
args: Type.Record(Type.String(), Type.Unknown(), { description: "Tool arguments as key-value pairs" }),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return Type.Unsafe(inputSchema);
|
|
103
|
+
}
|
|
104
|
+
export async function createMcpToolDefinitions(baseUrl, getApiKey) {
|
|
105
|
+
const discoveryApiKey = await getApiKey();
|
|
106
|
+
const tools = await discoverMcpTools(baseUrl, discoveryApiKey);
|
|
107
|
+
return tools.map((mcpTool) => {
|
|
108
|
+
const safeServer = sanitizeName(mcpTool.server_name);
|
|
109
|
+
const safeTool = sanitizeName(mcpTool.name);
|
|
110
|
+
const parameters = buildParameters(mcpTool.input_schema);
|
|
111
|
+
return defineTool({
|
|
112
|
+
name: `mcp_${safeServer}_${safeTool}`,
|
|
113
|
+
label: `${mcpTool.server_name}: ${mcpTool.name}`,
|
|
114
|
+
description: `${mcpTool.description} (via ${mcpTool.server_name} MCP server)`,
|
|
115
|
+
promptSnippet: `${mcpTool.description} via ${mcpTool.server_name} MCP server`,
|
|
116
|
+
parameters,
|
|
117
|
+
async execute(_toolCallId, params) {
|
|
118
|
+
const apiKey = await getApiKey();
|
|
119
|
+
const rawParams = params;
|
|
120
|
+
const args = Object.keys(rawParams).length === 1 && rawParams.args && typeof rawParams.args === "object"
|
|
121
|
+
? rawParams.args
|
|
122
|
+
: rawParams;
|
|
123
|
+
const text = await executeMcpTool(baseUrl, apiKey, mcpTool.server_id ?? mcpTool.server_name, mcpTool.name, args);
|
|
124
|
+
return {
|
|
125
|
+
content: [{ type: "text", text }],
|
|
126
|
+
details: { server: mcpTool.server_name, serverId: mcpTool.server_id, tool: mcpTool.name },
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
package/dist/skills.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { LiteLLMSkill } from "./types.js";
|
|
3
|
+
export declare function listSkills(baseUrl: string, apiKey: string): Promise<LiteLLMSkill[]>;
|
|
4
|
+
export declare function createSkill(baseUrl: string, apiKey: string, input: {
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
code: string;
|
|
8
|
+
inputSchema?: Record<string, unknown>;
|
|
9
|
+
}): Promise<unknown>;
|
|
10
|
+
export declare function deleteSkill(baseUrl: string, apiKey: string, skillId: string): Promise<void>;
|
|
11
|
+
export declare function createSkillsPromptSection(skills: LiteLLMSkill[]): string | undefined;
|
|
12
|
+
export declare function createSkillToolDefinitions(baseUrl: string, getApiKey: () => Promise<string>): ToolDefinition[];
|
|
13
|
+
export declare function resetSkillsCache(): void;
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
2
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { normalizeBaseUrl } from "./discover.js";
|
|
4
|
+
const SKILLS_CACHE_TTL_MS = 60_000;
|
|
5
|
+
let skillsCache;
|
|
6
|
+
function getSkillsFromBody(body) {
|
|
7
|
+
if (Array.isArray(body))
|
|
8
|
+
return body;
|
|
9
|
+
if (body && typeof body === "object") {
|
|
10
|
+
const record = body;
|
|
11
|
+
if (Array.isArray(record.data))
|
|
12
|
+
return record.data;
|
|
13
|
+
if (Array.isArray(record.skills))
|
|
14
|
+
return record.skills;
|
|
15
|
+
}
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
export async function listSkills(baseUrl, apiKey) {
|
|
19
|
+
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
|
20
|
+
if (skillsCache &&
|
|
21
|
+
skillsCache.baseUrl === normalizedBaseUrl &&
|
|
22
|
+
skillsCache.apiKey === apiKey &&
|
|
23
|
+
Date.now() - skillsCache.fetchedAt < SKILLS_CACHE_TTL_MS) {
|
|
24
|
+
return skillsCache.skills;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetch(`${normalizedBaseUrl}/v1/skills`, {
|
|
28
|
+
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
29
|
+
signal: AbortSignal.timeout(10_000),
|
|
30
|
+
});
|
|
31
|
+
if (!response.ok)
|
|
32
|
+
return [];
|
|
33
|
+
const skills = getSkillsFromBody(await response.json());
|
|
34
|
+
skillsCache = { baseUrl: normalizedBaseUrl, apiKey, fetchedAt: Date.now(), skills };
|
|
35
|
+
return skills;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export async function createSkill(baseUrl, apiKey, input) {
|
|
42
|
+
const response = await fetch(`${normalizeBaseUrl(baseUrl)}/v1/skills`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: {
|
|
45
|
+
Authorization: `Bearer ${apiKey}`,
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
},
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
name: input.name,
|
|
50
|
+
description: input.description,
|
|
51
|
+
code: input.code,
|
|
52
|
+
input_schema: input.inputSchema ?? { type: "object", properties: {} },
|
|
53
|
+
}),
|
|
54
|
+
signal: AbortSignal.timeout(10_000),
|
|
55
|
+
});
|
|
56
|
+
if (!response.ok)
|
|
57
|
+
throw new Error(`LiteLLM skill create failed: HTTP ${response.status}`);
|
|
58
|
+
skillsCache = undefined;
|
|
59
|
+
return response.json().catch(() => ({}));
|
|
60
|
+
}
|
|
61
|
+
export async function deleteSkill(baseUrl, apiKey, skillId) {
|
|
62
|
+
const response = await fetch(`${normalizeBaseUrl(baseUrl)}/v1/skills/${encodeURIComponent(skillId)}`, {
|
|
63
|
+
method: "DELETE",
|
|
64
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
65
|
+
signal: AbortSignal.timeout(10_000),
|
|
66
|
+
});
|
|
67
|
+
if (!response.ok && response.status !== 404)
|
|
68
|
+
throw new Error(`LiteLLM skill delete failed: HTTP ${response.status}`);
|
|
69
|
+
skillsCache = undefined;
|
|
70
|
+
}
|
|
71
|
+
function escapeXml(value) {
|
|
72
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
73
|
+
}
|
|
74
|
+
export function createSkillsPromptSection(skills) {
|
|
75
|
+
const enabled = skills.filter((skill) => skill.enabled !== false && skill.name);
|
|
76
|
+
if (enabled.length === 0)
|
|
77
|
+
return undefined;
|
|
78
|
+
const body = enabled
|
|
79
|
+
.map((skill) => {
|
|
80
|
+
const description = skill.description?.trim() || "No description provided.";
|
|
81
|
+
return `<skill name="${escapeXml(skill.name)}">\n${escapeXml(description)}\n</skill>`;
|
|
82
|
+
})
|
|
83
|
+
.join("\n");
|
|
84
|
+
return `LiteLLM Skills Gateway provided these active skills. Use them as additional guidance when relevant.\n<litellm_skills>\n${body}\n</litellm_skills>`;
|
|
85
|
+
}
|
|
86
|
+
function formatSkills(skills) {
|
|
87
|
+
if (skills.length === 0)
|
|
88
|
+
return "No LiteLLM skills are registered.";
|
|
89
|
+
return skills
|
|
90
|
+
.map((skill) => `- ${skill.name}${skill.enabled === false ? " (disabled)" : ""}: ${skill.description ?? ""}`)
|
|
91
|
+
.join("\n");
|
|
92
|
+
}
|
|
93
|
+
const CreateSkillParams = Type.Object({
|
|
94
|
+
name: Type.String({ description: "Skill name" }),
|
|
95
|
+
description: Type.String({ description: "Skill description" }),
|
|
96
|
+
code: Type.String({ description: "Skill implementation or prompt code" }),
|
|
97
|
+
inputSchemaJson: Type.Optional(Type.String({ description: "Optional JSON Schema string for skill inputs" })),
|
|
98
|
+
});
|
|
99
|
+
const DeleteSkillParams = Type.Object({
|
|
100
|
+
skillId: Type.String({ description: "LiteLLM skill id" }),
|
|
101
|
+
});
|
|
102
|
+
export function createSkillToolDefinitions(baseUrl, getApiKey) {
|
|
103
|
+
return [
|
|
104
|
+
defineTool({
|
|
105
|
+
name: "litellm_skill_list",
|
|
106
|
+
label: "LiteLLM Skills",
|
|
107
|
+
description: "List skills registered on the LiteLLM proxy Skills Gateway.",
|
|
108
|
+
promptSnippet: "List LiteLLM Skills Gateway skills",
|
|
109
|
+
parameters: Type.Object({}),
|
|
110
|
+
async execute() {
|
|
111
|
+
const apiKey = await getApiKey();
|
|
112
|
+
const skills = await listSkills(baseUrl, apiKey);
|
|
113
|
+
return { content: [{ type: "text", text: formatSkills(skills) }], details: { count: skills.length } };
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
116
|
+
defineTool({
|
|
117
|
+
name: "litellm_skill_create",
|
|
118
|
+
label: "Create LiteLLM Skill",
|
|
119
|
+
description: "Create a skill on the LiteLLM proxy Skills Gateway.",
|
|
120
|
+
parameters: CreateSkillParams,
|
|
121
|
+
async execute(_toolCallId, params) {
|
|
122
|
+
const apiKey = await getApiKey();
|
|
123
|
+
const inputSchema = params.inputSchemaJson
|
|
124
|
+
? JSON.parse(params.inputSchemaJson)
|
|
125
|
+
: undefined;
|
|
126
|
+
const result = await createSkill(baseUrl, apiKey, { ...params, inputSchema });
|
|
127
|
+
return { content: [{ type: "text", text: "LiteLLM skill created." }], details: { result } };
|
|
128
|
+
},
|
|
129
|
+
}),
|
|
130
|
+
defineTool({
|
|
131
|
+
name: "litellm_skill_delete",
|
|
132
|
+
label: "Delete LiteLLM Skill",
|
|
133
|
+
description: "Delete a skill from the LiteLLM proxy Skills Gateway.",
|
|
134
|
+
parameters: DeleteSkillParams,
|
|
135
|
+
async execute(_toolCallId, params) {
|
|
136
|
+
const apiKey = await getApiKey();
|
|
137
|
+
await deleteSkill(baseUrl, apiKey, params.skillId);
|
|
138
|
+
return { content: [{ type: "text", text: `LiteLLM skill deleted: ${params.skillId}` }], details: {} };
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
export function resetSkillsCache() {
|
|
144
|
+
skillsCache = undefined;
|
|
145
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
export type DiscoverySource = "model_info" | "models_list";
|
|
2
|
+
export type DiscoverySource = "model_info" | "models_list" | "health";
|
|
3
3
|
export interface CacheFile {
|
|
4
4
|
baseUrl: string;
|
|
5
5
|
apiKeyFingerprint: string;
|
|
@@ -32,6 +32,14 @@ export interface ModelInfoEntry {
|
|
|
32
32
|
export interface ModelInfoResponse {
|
|
33
33
|
data?: ModelInfoEntry[];
|
|
34
34
|
}
|
|
35
|
+
export interface HealthModelEntry {
|
|
36
|
+
model?: string;
|
|
37
|
+
model_id?: string;
|
|
38
|
+
api_base?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface HealthResponse {
|
|
41
|
+
healthy_endpoints?: HealthModelEntry[];
|
|
42
|
+
}
|
|
35
43
|
export interface ModelsListEntry {
|
|
36
44
|
id?: string;
|
|
37
45
|
owned_by?: string;
|
|
@@ -53,4 +61,20 @@ export interface ResolvedCredentials {
|
|
|
53
61
|
baseUrl?: string;
|
|
54
62
|
apiKey?: string;
|
|
55
63
|
apiKeyFingerprint?: string;
|
|
64
|
+
apiKeyConfig?: string;
|
|
65
|
+
}
|
|
66
|
+
export interface LiteLLMMcpTool {
|
|
67
|
+
name: string;
|
|
68
|
+
server_name: string;
|
|
69
|
+
server_id?: string;
|
|
70
|
+
description: string;
|
|
71
|
+
input_schema: Record<string, unknown>;
|
|
72
|
+
}
|
|
73
|
+
export interface LiteLLMSkill {
|
|
74
|
+
id?: string;
|
|
75
|
+
name: string;
|
|
76
|
+
description?: string;
|
|
77
|
+
enabled?: boolean;
|
|
78
|
+
input_schema?: Record<string, unknown>;
|
|
79
|
+
code?: string;
|
|
56
80
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-provider-litellm",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.8",
|
|
4
4
|
"description": "LiteLLM proxy provider extension for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"image": "https://raw.githubusercontent.com/balcsida/pi-provider-litellm/refs/heads/main/assets/pi_litellm_gallery.png"
|
|
41
41
|
},
|
|
42
42
|
"scripts": {
|
|
43
|
-
"clean": "
|
|
43
|
+
"clean": "rm -rf dist",
|
|
44
44
|
"build": "tsgo -p tsconfig.build.json",
|
|
45
45
|
"dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
|
|
46
46
|
"typecheck": "tsgo --noEmit",
|
|
@@ -64,14 +64,12 @@
|
|
|
64
64
|
"protobufjs": "8.2.1"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
-
"@earendil-works/pi-ai": "^0.
|
|
68
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
67
|
+
"@earendil-works/pi-ai": "^0.79.1",
|
|
68
|
+
"@earendil-works/pi-coding-agent": "^0.79.1",
|
|
69
69
|
"@biomejs/biome": "^2.4.15",
|
|
70
70
|
"@types/node": "^25.9.1",
|
|
71
|
-
"@typescript/native-preview": "7.0.0-dev.
|
|
72
|
-
"shx": "^0.4.0",
|
|
71
|
+
"@typescript/native-preview": "7.0.0-dev.20260617.2",
|
|
73
72
|
"tsx": "^4.21.0",
|
|
74
|
-
"typescript": "^6.0.3",
|
|
75
73
|
"vitest": "^4.1.7"
|
|
76
74
|
}
|
|
77
75
|
}
|