pi-provider-litellm 1.2.6 → 1.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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` and `/litellm-refresh`. Tries `/model/info` first (admin endpoint with rich metadata), falls back to `/v1/models` (OpenAI-compatible) on 401/403/404.
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
 
@@ -64,11 +64,42 @@ Stored pi credentials for `litellm` take precedence over `LITELLM_API_KEY`; the
64
64
  | Variable | Default | Effect |
65
65
  |---|---|---|
66
66
  | `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. |
67
+ | `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`. |
68
+ | `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
69
  | `LITELLM_OFFLINE` | unset | If `1`, skip discovery on this start; use cache only |
68
70
  | `LITELLM_DISCOVERY_TIMEOUT_MS` | `5000` | Discovery fetch timeout in ms; `0` to skip discovery |
69
71
 
70
72
  `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
73
 
74
+ ### Google ADC token auth
75
+
76
+ When your LiteLLM proxy accepts Google OAuth access tokens, you can let the extension refresh tokens from Application Default Credentials:
77
+
78
+ ```bash
79
+ gcloud auth application-default login
80
+ export LITELLM_BASE_URL="https://litellm.your-domain.com"
81
+ export LITELLM_GCLOUD_TOKEN_AUTH=1
82
+ ```
83
+
84
+ 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.
85
+
86
+ ## LiteLLM MCP tools
87
+
88
+ If your LiteLLM proxy exposes MCP REST endpoints, this extension discovers tools from:
89
+
90
+ - `GET /mcp-rest/tools/list`
91
+ - `POST /mcp-rest/tools/call`
92
+
93
+ 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.
94
+
95
+ ## LiteLLM Skills Gateway
96
+
97
+ 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:
98
+
99
+ - `litellm_skill_list`
100
+ - `litellm_skill_create`
101
+ - `litellm_skill_delete`
102
+
72
103
  ## Mocked LiteLLM smoke workflow
73
104
 
74
105
  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.
@@ -108,7 +139,7 @@ Before tagging a release, keep `package.json` and `package-lock.json` versions i
108
139
 
109
140
  ## Cache
110
141
 
111
- The model list is cached at `~/.pi/agent/litellm-models.json` with a keyed fingerprint of the base URL + API key. Changing either invalidates the cache automatically.
142
+ 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
143
 
113
144
  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
145
 
@@ -117,10 +148,13 @@ If the cache is older than 24 hours, the extension refreshes it in the backgroun
117
148
  | Symptom | Likely cause |
118
149
  |---|---|
119
150
  | "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 for the actual response |
151
+ | "discovered no models" | Proxy returned an empty list — check pi's startup log and verify `/model/info`, `/v1/models`, or `/health` responds |
121
152
  | `/model/info` returning 401/403/404 | Expected behavior with virtual keys — extension falls back to `/v1/models` |
122
153
  | Discovery times out | Increase `LITELLM_DISCOVERY_TIMEOUT_MS` or set `LITELLM_OFFLINE=1` to fall back on cached models |
123
154
  | `401 Token expired` | Set `LITELLM_API_KEY_HELPER`. |
155
+ | 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 |
156
+ | MCP tools not showing | Verify the proxy exposes `/mcp-rest/tools/list` and run `/litellm-refresh` after fixing the proxy |
157
+ | Skills not affecting prompts | Verify the proxy exposes `/v1/skills` and returns enabled skills |
124
158
 
125
159
  ## License
126
160
 
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.js CHANGED
@@ -186,6 +186,45 @@ function mapFromModelInfo(entry) {
186
186
  compat: buildCompat(id),
187
187
  };
188
188
  }
189
+ function mapFromHealthModelInfo(entry, fallbackId) {
190
+ const model = mapFromModelInfo(entry);
191
+ if (model || !fallbackId)
192
+ return model;
193
+ if (entry.model_info?.mode && entry.model_info.mode !== "chat")
194
+ return undefined;
195
+ return {
196
+ id: fallbackId,
197
+ name: fallbackId,
198
+ reasoning: entry.model_info?.supports_reasoning ?? false,
199
+ input: entry.model_info?.supports_vision ? ["text", "image"] : ["text"],
200
+ cost: {
201
+ input: (entry.model_info?.input_cost_per_token ?? 0) * 1_000_000,
202
+ output: (entry.model_info?.output_cost_per_token ?? 0) * 1_000_000,
203
+ cacheRead: (entry.model_info?.cache_read_input_token_cost ?? 0) * 1_000_000,
204
+ cacheWrite: (entry.model_info?.cache_creation_input_token_cost ?? 0) * 1_000_000,
205
+ },
206
+ contextWindow: entry.model_info?.max_input_tokens ?? DEFAULT_CONTEXT_WINDOW,
207
+ maxTokens: entry.model_info?.max_output_tokens ?? DEFAULT_MAX_TOKENS,
208
+ compat: buildCompat(fallbackId),
209
+ };
210
+ }
211
+ function mapFromHealthEndpoint(entry) {
212
+ const id = entry.model;
213
+ if (!id)
214
+ return undefined;
215
+ const catalogModel = findCatalogModel(id);
216
+ return {
217
+ id,
218
+ name: catalogModel?.name ?? id,
219
+ reasoning: catalogModel?.reasoning ?? false,
220
+ thinkingLevelMap: catalogModel?.thinkingLevelMap,
221
+ input: catalogModel?.input ?? ["text"],
222
+ cost: catalogModel?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
223
+ contextWindow: catalogModel?.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
224
+ maxTokens: catalogModel?.maxTokens ?? DEFAULT_MAX_TOKENS,
225
+ compat: buildCompat(id),
226
+ };
227
+ }
189
228
  function mapFromModelsList(entry, modelsDev) {
190
229
  const id = entry.id;
191
230
  if (!id)
@@ -204,6 +243,22 @@ function mapFromModelsList(entry, modelsDev) {
204
243
  compat: buildCompat(id),
205
244
  };
206
245
  }
246
+ async function discoverFromHealth(base, apiKey, options) {
247
+ const healthResult = await fetchJson(`${base}/health`, apiKey, options);
248
+ if (!healthResult.ok)
249
+ return [];
250
+ const endpoints = (healthResult.data.healthy_endpoints ?? []).filter((entry) => entry.model || entry.model_id);
251
+ const models = await Promise.all(endpoints.map(async (endpoint) => {
252
+ if (!endpoint.model_id)
253
+ return mapFromHealthEndpoint(endpoint);
254
+ const infoResult = await fetchJson(`${base}/model/info?litellm_model_id=${encodeURIComponent(endpoint.model_id)}`, apiKey, options);
255
+ if (!infoResult.ok)
256
+ return mapFromHealthEndpoint(endpoint);
257
+ const entry = infoResult.data.data?.[0];
258
+ return entry ? mapFromHealthModelInfo(entry, endpoint.model) : mapFromHealthEndpoint(endpoint);
259
+ }));
260
+ return models.filter((model) => model !== undefined);
261
+ }
207
262
  export async function discoverModels(baseUrl, apiKey, options = {}) {
208
263
  const base = normalizeBaseUrl(baseUrl);
209
264
  const infoResult = await fetchJson(`${base}/model/info`, apiKey, options);
@@ -218,6 +273,11 @@ export async function discoverModels(baseUrl, apiKey, options = {}) {
218
273
  }
219
274
  const listResult = await fetchJson(`${base}/v1/models`, apiKey, options);
220
275
  if (!listResult.ok) {
276
+ if ([401, 403, 404].includes(listResult.status)) {
277
+ const models = await discoverFromHealth(base, apiKey, options);
278
+ if (models.length > 0)
279
+ return { source: "health", models };
280
+ }
221
281
  throw new Error(`/v1/models returned ${listResult.status}`);
222
282
  }
223
283
  const modelsDev = await getModelsDevCatalog(options);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ import { getGcloudToken } from "./gcloud-token.js";
2
+ const token = await getGcloudToken();
3
+ if (token) {
4
+ process.stdout.write(token);
5
+ }
6
+ else {
7
+ process.exitCode = 1;
8
+ }
@@ -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
@@ -5,7 +5,10 @@ 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
7
  import { discoverModels, normalizeBaseUrl, shouldSuppressReasoningContent } 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";
@@ -82,27 +85,51 @@ async function resolveCredentials({ executeHelpers = true } = {}) {
82
85
  const envBase = cleanConfig(process.env[ENV_BASE_URL]);
83
86
  const envKey = cleanConfig(process.env[ENV_API_KEY]);
84
87
  const envHelperCommand = getApiKeyHelperCommand();
88
+ const useGcloudToken = isGcloudTokenAuthEnabled();
85
89
  const authBase = entry?.type === "oauth" ? entry.baseUrl?.trim() : undefined;
90
+ const gcloudCacheKey = useGcloudToken && !entry ? ((await getGcloudTokenCacheKey()) ?? undefined) : undefined;
86
91
  const authKey = entry?.type === "oauth"
87
92
  ? (executeHelpers ? resolveOAuthApiKey(entry) : entry.access).trim()
88
93
  : entry?.type === "api_key"
89
94
  ? (await AuthStorage.create(getAuthPath()).getApiKey(PROVIDER_NAME, { includeFallback: false }))?.trim()
90
95
  : undefined;
91
- const apiKey = authKey || (executeHelpers && envHelperCommand ? executeApiKeyCommand(envHelperCommand) : undefined) || envKey;
92
- const apiKeyFingerprint = entry?.type === "oauth" && entry.refresh.startsWith("!")
93
- ? fingerprint(entry.refresh)
94
- : authKey
95
- ? fingerprint(authKey)
96
- : envHelperCommand
97
- ? fingerprint(envHelperCommand)
98
- : envKey
99
- ? fingerprint(envKey)
100
- : undefined;
96
+ const gcloudKey = executeHelpers && gcloudCacheKey ? (await getGcloudToken())?.trim() : undefined;
97
+ const helperKey = !authKey && !gcloudKey && executeHelpers && envHelperCommand ? executeApiKeyCommand(envHelperCommand) : undefined;
98
+ const apiKey = authKey || gcloudKey || helperKey || envKey;
99
+ let apiKeyFingerprint;
100
+ let apiKeyConfig;
101
+ if (entry?.type === "oauth" && entry.refresh.startsWith("!")) {
102
+ apiKeyFingerprint = fingerprint(entry.refresh);
103
+ }
104
+ else if (authKey) {
105
+ apiKeyFingerprint = fingerprint(authKey);
106
+ }
107
+ else if (gcloudKey) {
108
+ apiKeyFingerprint = fingerprint(gcloudCacheKey ?? gcloudKey);
109
+ apiKeyConfig = getGcloudTokenCommand();
110
+ }
111
+ else if (!executeHelpers && gcloudCacheKey) {
112
+ apiKeyFingerprint = fingerprint(gcloudCacheKey);
113
+ apiKeyConfig = getGcloudTokenCommand();
114
+ }
115
+ else if (helperKey && envHelperCommand) {
116
+ apiKeyFingerprint = fingerprint(envHelperCommand);
117
+ apiKeyConfig = envHelperCommand;
118
+ }
119
+ else if (!executeHelpers && envHelperCommand) {
120
+ apiKeyFingerprint = fingerprint(envHelperCommand);
121
+ apiKeyConfig = envHelperCommand;
122
+ }
123
+ else if (envKey) {
124
+ apiKeyFingerprint = fingerprint(envKey);
125
+ apiKeyConfig = `$${ENV_API_KEY}`;
126
+ }
101
127
  const rawBase = authBase || envBase;
102
128
  return {
103
129
  baseUrl: rawBase ? normalizeBaseUrl(rawBase) : undefined,
104
130
  apiKey: apiKey || undefined,
105
131
  apiKeyFingerprint,
132
+ apiKeyConfig,
106
133
  };
107
134
  }
108
135
  function getDiscoveryTimeoutMs() {
@@ -274,6 +301,7 @@ export default async function (pi) {
274
301
  getDiscoveryTimeoutMs() > 0 &&
275
302
  (!cacheValid || isListModelsMode());
276
303
  let credentialWarning;
304
+ let liveDiscoveryApiKey;
277
305
  if (shouldFetch) {
278
306
  try {
279
307
  creds = await resolveCredentials();
@@ -304,6 +332,7 @@ export default async function (pi) {
304
332
  }
305
333
  else {
306
334
  models = result.models;
335
+ liveDiscoveryApiKey = creds.apiKey;
307
336
  const next = {
308
337
  baseUrl: creds.baseUrl,
309
338
  apiKeyFingerprint: fp,
@@ -330,7 +359,7 @@ export default async function (pi) {
330
359
  getApiKey: getLiteLLMApiKey,
331
360
  modifyModels: modifyLiteLLMModels,
332
361
  };
333
- function registerProvider(baseUrl, models) {
362
+ function registerProvider(baseUrl, models, apiKeyConfig = creds.apiKeyConfig ?? getApiKeyHelperCommand() ?? `$${ENV_API_KEY}`) {
334
363
  pi.registerProvider(PROVIDER_NAME, {
335
364
  baseUrl: baseUrl ? `${baseUrl}/v1` : "https://litellm.example.com/v1",
336
365
  // When LITELLM_API_KEY_HELPER is set we register the helper as a `!command` provider key.
@@ -340,7 +369,7 @@ export default async function (pi) {
340
369
  // resolveConfigValue). So a short-lived/rotating helper token stays fresh. The OAuth hooks
341
370
  // remain registered for `/login litellm` users. See the regression test
342
371
  // "re-runs the helper command on every request" in tests/index.test.ts.
343
- apiKey: getApiKeyHelperCommand() ?? `$${ENV_API_KEY}`,
372
+ apiKey: apiKeyConfig,
344
373
  api: "openai-completions",
345
374
  models,
346
375
  oauth,
@@ -356,6 +385,45 @@ export default async function (pi) {
356
385
  return `${ENV_TIMEOUT}=0`;
357
386
  return null;
358
387
  }
388
+ async function resolveRuntimeApiKey() {
389
+ const fresh = await resolveCredentials();
390
+ if (!fresh.apiKey)
391
+ throw new Error("no credentials. Run /login litellm or set env vars.");
392
+ return fresh.apiKey;
393
+ }
394
+ function registerSkillTools(baseUrl) {
395
+ if (!baseUrl)
396
+ return;
397
+ for (const tool of createSkillToolDefinitions(baseUrl, resolveRuntimeApiKey)) {
398
+ pi.registerTool(tool);
399
+ }
400
+ }
401
+ function seededRuntimeApiKey(seed) {
402
+ let first = seed;
403
+ return async () => {
404
+ if (first) {
405
+ const value = first;
406
+ first = undefined;
407
+ return value;
408
+ }
409
+ return resolveRuntimeApiKey();
410
+ };
411
+ }
412
+ async function registerMcpTools(baseUrl, discoveryApiKey) {
413
+ if (!baseUrl || !discoveryApiKey || discoveryDisabledReason())
414
+ return;
415
+ try {
416
+ const tools = await createMcpToolDefinitions(baseUrl, seededRuntimeApiKey(discoveryApiKey));
417
+ for (const tool of tools) {
418
+ pi.registerTool(tool);
419
+ }
420
+ }
421
+ catch (error) {
422
+ process.stderr.write(`LiteLLM: MCP tool discovery failed (${error instanceof Error ? error.message : String(error)}).\n`);
423
+ }
424
+ }
425
+ registerSkillTools(creds.baseUrl);
426
+ await registerMcpTools(creds.baseUrl, liveDiscoveryApiKey);
359
427
  async function refreshModelsAndCosts() {
360
428
  const fresh = await resolveCredentials();
361
429
  const freshFp = fresh.apiKeyFingerprint;
@@ -371,9 +439,10 @@ export default async function (pi) {
371
439
  source: result.source,
372
440
  models: result.models,
373
441
  });
374
- registerProvider(fresh.baseUrl, result.models);
442
+ registerProvider(fresh.baseUrl, result.models, fresh.apiKeyConfig);
375
443
  updateCosts(result.models);
376
444
  cacheFetchedAt = now;
445
+ await registerMcpTools(fresh.baseUrl, fresh.apiKey);
377
446
  return { models: result.models, source: result.source };
378
447
  }
379
448
  function runRefresh() {
@@ -402,6 +471,10 @@ export default async function (pi) {
402
471
  });
403
472
  ctx.modelRegistry.authStorage.set(PROVIDER_NAME, { type: "oauth", ...credential });
404
473
  ctx.modelRegistry.refresh();
474
+ const credentialBaseUrl = credential.baseUrl;
475
+ const credentialAccess = typeof credential.access === "string" ? credential.access : undefined;
476
+ registerSkillTools(credentialBaseUrl);
477
+ await registerMcpTools(credentialBaseUrl, credentialAccess);
405
478
  ctx.ui.notify(`Logged in to LiteLLM. Credentials saved to ${getAuthPath()}`, "info");
406
479
  }
407
480
  pi.on("input", async (event, ctx) => {
@@ -451,6 +524,18 @@ export default async function (pi) {
451
524
  return;
452
525
  return prepareLiteLLMRequestPayload(event.payload, ctx.model?.id, sessionId);
453
526
  });
527
+ pi.on("before_agent_start", async (event) => {
528
+ if (discoveryDisabledReason())
529
+ return;
530
+ const fresh = await resolveCredentials();
531
+ if (!fresh.baseUrl || !fresh.apiKey)
532
+ return;
533
+ const skills = await listSkills(fresh.baseUrl, fresh.apiKey);
534
+ const section = createSkillsPromptSection(skills);
535
+ if (!section)
536
+ return;
537
+ return { systemPrompt: `${event.systemPrompt}\n\n${section}` };
538
+ });
454
539
  pi.on("message_end", (event) => {
455
540
  if (event.message.role !== "assistant")
456
541
  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,175 @@
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 isMappableSchema(value) {
96
+ if (!value || typeof value !== "object")
97
+ return false;
98
+ const schema = value;
99
+ if ("$ref" in schema || "anyOf" in schema || "oneOf" in schema || "allOf" in schema)
100
+ return false;
101
+ if (["string", "number", "integer", "boolean"].includes(String(schema.type)))
102
+ return true;
103
+ if (schema.type === "array") {
104
+ const items = schema.items;
105
+ return items?.type === "string";
106
+ }
107
+ return false;
108
+ }
109
+ function mapPropertySchema(name, schema) {
110
+ const description = typeof schema.description === "string" ? schema.description : name;
111
+ switch (schema.type) {
112
+ case "string":
113
+ return Type.String({ description });
114
+ case "number":
115
+ return Type.Number({ description });
116
+ case "integer":
117
+ return Type.Integer({ description });
118
+ case "boolean":
119
+ return Type.Boolean({ description });
120
+ case "array":
121
+ return Type.Array(Type.String(), { description });
122
+ default:
123
+ return Type.Unknown({ description });
124
+ }
125
+ }
126
+ function buildParameters(inputSchema) {
127
+ const properties = inputSchema.properties;
128
+ if (!properties || typeof properties !== "object") {
129
+ return Type.Object({
130
+ args: Type.Record(Type.String(), Type.Unknown(), { description: "Tool arguments as key-value pairs" }),
131
+ });
132
+ }
133
+ const required = Array.isArray(inputSchema.required)
134
+ ? inputSchema.required.filter((key) => typeof key === "string")
135
+ : [];
136
+ const mapped = {};
137
+ for (const [key, property] of Object.entries(properties)) {
138
+ if (!isMappableSchema(property)) {
139
+ return Type.Object({
140
+ args: Type.Record(Type.String(), Type.Unknown(), { description: "Tool arguments as key-value pairs" }),
141
+ });
142
+ }
143
+ const field = mapPropertySchema(key, property);
144
+ mapped[key] = required.includes(key) ? field : Type.Optional(field);
145
+ }
146
+ return Type.Object(mapped);
147
+ }
148
+ export async function createMcpToolDefinitions(baseUrl, getApiKey) {
149
+ const discoveryApiKey = await getApiKey();
150
+ const tools = await discoverMcpTools(baseUrl, discoveryApiKey);
151
+ return tools.map((mcpTool) => {
152
+ const safeServer = sanitizeName(mcpTool.server_name);
153
+ const safeTool = sanitizeName(mcpTool.name);
154
+ const parameters = buildParameters(mcpTool.input_schema);
155
+ return defineTool({
156
+ name: `mcp_${safeServer}_${safeTool}`,
157
+ label: `${mcpTool.server_name}: ${mcpTool.name}`,
158
+ description: `${mcpTool.description} (via ${mcpTool.server_name} MCP server)`,
159
+ promptSnippet: `${mcpTool.description} via ${mcpTool.server_name} MCP server`,
160
+ parameters,
161
+ async execute(_toolCallId, params) {
162
+ const apiKey = await getApiKey();
163
+ const rawParams = params;
164
+ const args = Object.keys(rawParams).length === 1 && rawParams.args && typeof rawParams.args === "object"
165
+ ? rawParams.args
166
+ : rawParams;
167
+ const text = await executeMcpTool(baseUrl, apiKey, mcpTool.server_id ?? mcpTool.server_name, mcpTool.name, args);
168
+ return {
169
+ content: [{ type: "text", text }],
170
+ details: { server: mcpTool.server_name, serverId: mcpTool.server_id, tool: mcpTool.name },
171
+ };
172
+ },
173
+ });
174
+ });
175
+ }
@@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
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.6",
3
+ "version": "1.2.7",
4
4
  "description": "LiteLLM proxy provider extension for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -64,11 +64,11 @@
64
64
  "protobufjs": "8.2.1"
65
65
  },
66
66
  "devDependencies": {
67
- "@earendil-works/pi-ai": "^0.78.1",
68
- "@earendil-works/pi-coding-agent": "^0.78.1",
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.20260607.1",
71
+ "@typescript/native-preview": "7.0.0-dev.20260610.1",
72
72
  "shx": "^0.4.0",
73
73
  "tsx": "^4.21.0",
74
74
  "typescript": "^6.0.3",