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 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
 
@@ -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
- ## Mocked LiteLLM smoke workflow
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
- ## Development
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
- npm ci
84
- npm run check
85
- npm run clean && npm run build
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
- `npm run check` runs Biome, type checking, and the Vitest suite. Runtime changes must be built before local Pi smoke checks because the extension entrypoint is `./dist/index.js`.
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
- Before changing package contents or dependency policy, also run:
101
+ ## LiteLLM MCP tools
91
102
 
92
- ```bash
93
- npm run supply-chain:guard
94
- npm pack --dry-run
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 published npm package should contain only `dist`, `README.md`, and `LICENSE`.
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
- ## Release
110
+ ## LiteLLM Skills Gateway
100
111
 
101
- Releases are driven by semver tags named `v*.*.*`. The GitHub release workflow installs from the lockfile, runs the checks, builds `dist`, verifies the package tarball, publishes to npm with provenance, and creates a GitHub release.
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
- Before tagging a release, keep `package.json` and `package-lock.json` versions in sync and verify the dry-run package contents.
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 + API key. Changing either invalidates the cache automatically.
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 for the actual response |
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) {
@@ -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 candidates = [toKnownProvider(ownedBy), prefixProvider].filter((provider) => provider !== undefined);
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 exact = getModels(provider).find((model) => model.id === id);
51
- if (exact)
52
- return exact;
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 exact = getModels(provider).find((model) => model.id === id);
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
- input: (info.input_cost_per_token ?? 0) * 1_000_000,
180
- output: (info.output_cost_per_token ?? 0) * 1_000_000,
181
- cacheRead: (info.cache_read_input_token_cost ?? 0) * 1_000_000,
182
- cacheWrite: (info.cache_creation_input_token_cost ?? 0) * 1_000_000,
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,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
@@ -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 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;
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
- const apiKeyInput = (await callbacks.onPrompt({ message: "Enter API key:" })).trim();
141
- if (!rawBaseUrl || !apiKeyInput)
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 refresh = apiKeyInput.startsWith("!") ? apiKeyInput : "";
145
- const apiKey = refresh ? executeApiKeyCommand(refresh) : apiKeyInput;
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: tokenExpiresAt(apiKey, refresh ? EXPIRE_TOKEN_IMMEDIATELY : PERMANENT_TOKEN_EXPIRES_AT),
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: getApiKeyHelperCommand() ?? `$${ENV_API_KEY}`,
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: () => undefined,
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
+ }
@@ -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.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": "shx rm -rf dist",
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.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",
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
  }