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 +37 -3
- package/dist/cache.js +1 -1
- package/dist/discover.js +60 -0
- package/dist/gcloud-token-cli.d.ts +1 -0
- package/dist/gcloud-token-cli.js +8 -0
- package/dist/gcloud-token.d.ts +7 -0
- package/dist/gcloud-token.js +133 -0
- package/dist/index.js +98 -13
- package/dist/mcp-tools.d.ts +5 -0
- package/dist/mcp-tools.js +175 -0
- package/dist/skills.d.ts +13 -0
- package/dist/skills.js +145 -0
- package/dist/types.d.ts +25 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
LiteLLM proxy provider extension for [Pi](https://pi.dev).
|
|
4
4
|
|
|
5
|
-
Discovers models from a self-hosted LiteLLM proxy and registers them under the `litellm` provider. Supports `/login litellm
|
|
5
|
+
Discovers models from a self-hosted LiteLLM proxy and registers them under the `litellm` provider. Supports `/login litellm`, `/litellm-refresh`, LiteLLM MCP tools, LiteLLM Skills Gateway prompt injection, and Google ADC token auth. Tries `/model/info` first (admin endpoint with rich metadata), falls back to `/v1/models` (OpenAI-compatible) on 401/403/404, then tries `/health` plus per-endpoint `/model/info` for older LiteLLM proxies.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -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
|
|
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
|
|
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,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
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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:
|
|
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
|
+
}
|
package/dist/skills.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { LiteLLMSkill } from "./types.js";
|
|
3
|
+
export declare function listSkills(baseUrl: string, apiKey: string): Promise<LiteLLMSkill[]>;
|
|
4
|
+
export declare function createSkill(baseUrl: string, apiKey: string, input: {
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
code: string;
|
|
8
|
+
inputSchema?: Record<string, unknown>;
|
|
9
|
+
}): Promise<unknown>;
|
|
10
|
+
export declare function deleteSkill(baseUrl: string, apiKey: string, skillId: string): Promise<void>;
|
|
11
|
+
export declare function createSkillsPromptSection(skills: LiteLLMSkill[]): string | undefined;
|
|
12
|
+
export declare function createSkillToolDefinitions(baseUrl: string, getApiKey: () => Promise<string>): ToolDefinition[];
|
|
13
|
+
export declare function resetSkillsCache(): void;
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
2
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { normalizeBaseUrl } from "./discover.js";
|
|
4
|
+
const SKILLS_CACHE_TTL_MS = 60_000;
|
|
5
|
+
let skillsCache;
|
|
6
|
+
function getSkillsFromBody(body) {
|
|
7
|
+
if (Array.isArray(body))
|
|
8
|
+
return body;
|
|
9
|
+
if (body && typeof body === "object") {
|
|
10
|
+
const record = body;
|
|
11
|
+
if (Array.isArray(record.data))
|
|
12
|
+
return record.data;
|
|
13
|
+
if (Array.isArray(record.skills))
|
|
14
|
+
return record.skills;
|
|
15
|
+
}
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
export async function listSkills(baseUrl, apiKey) {
|
|
19
|
+
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
|
20
|
+
if (skillsCache &&
|
|
21
|
+
skillsCache.baseUrl === normalizedBaseUrl &&
|
|
22
|
+
skillsCache.apiKey === apiKey &&
|
|
23
|
+
Date.now() - skillsCache.fetchedAt < SKILLS_CACHE_TTL_MS) {
|
|
24
|
+
return skillsCache.skills;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetch(`${normalizedBaseUrl}/v1/skills`, {
|
|
28
|
+
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
29
|
+
signal: AbortSignal.timeout(10_000),
|
|
30
|
+
});
|
|
31
|
+
if (!response.ok)
|
|
32
|
+
return [];
|
|
33
|
+
const skills = getSkillsFromBody(await response.json());
|
|
34
|
+
skillsCache = { baseUrl: normalizedBaseUrl, apiKey, fetchedAt: Date.now(), skills };
|
|
35
|
+
return skills;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export async function createSkill(baseUrl, apiKey, input) {
|
|
42
|
+
const response = await fetch(`${normalizeBaseUrl(baseUrl)}/v1/skills`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: {
|
|
45
|
+
Authorization: `Bearer ${apiKey}`,
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
},
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
name: input.name,
|
|
50
|
+
description: input.description,
|
|
51
|
+
code: input.code,
|
|
52
|
+
input_schema: input.inputSchema ?? { type: "object", properties: {} },
|
|
53
|
+
}),
|
|
54
|
+
signal: AbortSignal.timeout(10_000),
|
|
55
|
+
});
|
|
56
|
+
if (!response.ok)
|
|
57
|
+
throw new Error(`LiteLLM skill create failed: HTTP ${response.status}`);
|
|
58
|
+
skillsCache = undefined;
|
|
59
|
+
return response.json().catch(() => ({}));
|
|
60
|
+
}
|
|
61
|
+
export async function deleteSkill(baseUrl, apiKey, skillId) {
|
|
62
|
+
const response = await fetch(`${normalizeBaseUrl(baseUrl)}/v1/skills/${encodeURIComponent(skillId)}`, {
|
|
63
|
+
method: "DELETE",
|
|
64
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
65
|
+
signal: AbortSignal.timeout(10_000),
|
|
66
|
+
});
|
|
67
|
+
if (!response.ok && response.status !== 404)
|
|
68
|
+
throw new Error(`LiteLLM skill delete failed: HTTP ${response.status}`);
|
|
69
|
+
skillsCache = undefined;
|
|
70
|
+
}
|
|
71
|
+
function escapeXml(value) {
|
|
72
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
73
|
+
}
|
|
74
|
+
export function createSkillsPromptSection(skills) {
|
|
75
|
+
const enabled = skills.filter((skill) => skill.enabled !== false && skill.name);
|
|
76
|
+
if (enabled.length === 0)
|
|
77
|
+
return undefined;
|
|
78
|
+
const body = enabled
|
|
79
|
+
.map((skill) => {
|
|
80
|
+
const description = skill.description?.trim() || "No description provided.";
|
|
81
|
+
return `<skill name="${escapeXml(skill.name)}">\n${escapeXml(description)}\n</skill>`;
|
|
82
|
+
})
|
|
83
|
+
.join("\n");
|
|
84
|
+
return `LiteLLM Skills Gateway provided these active skills. Use them as additional guidance when relevant.\n<litellm_skills>\n${body}\n</litellm_skills>`;
|
|
85
|
+
}
|
|
86
|
+
function formatSkills(skills) {
|
|
87
|
+
if (skills.length === 0)
|
|
88
|
+
return "No LiteLLM skills are registered.";
|
|
89
|
+
return skills
|
|
90
|
+
.map((skill) => `- ${skill.name}${skill.enabled === false ? " (disabled)" : ""}: ${skill.description ?? ""}`)
|
|
91
|
+
.join("\n");
|
|
92
|
+
}
|
|
93
|
+
const CreateSkillParams = Type.Object({
|
|
94
|
+
name: Type.String({ description: "Skill name" }),
|
|
95
|
+
description: Type.String({ description: "Skill description" }),
|
|
96
|
+
code: Type.String({ description: "Skill implementation or prompt code" }),
|
|
97
|
+
inputSchemaJson: Type.Optional(Type.String({ description: "Optional JSON Schema string for skill inputs" })),
|
|
98
|
+
});
|
|
99
|
+
const DeleteSkillParams = Type.Object({
|
|
100
|
+
skillId: Type.String({ description: "LiteLLM skill id" }),
|
|
101
|
+
});
|
|
102
|
+
export function createSkillToolDefinitions(baseUrl, getApiKey) {
|
|
103
|
+
return [
|
|
104
|
+
defineTool({
|
|
105
|
+
name: "litellm_skill_list",
|
|
106
|
+
label: "LiteLLM Skills",
|
|
107
|
+
description: "List skills registered on the LiteLLM proxy Skills Gateway.",
|
|
108
|
+
promptSnippet: "List LiteLLM Skills Gateway skills",
|
|
109
|
+
parameters: Type.Object({}),
|
|
110
|
+
async execute() {
|
|
111
|
+
const apiKey = await getApiKey();
|
|
112
|
+
const skills = await listSkills(baseUrl, apiKey);
|
|
113
|
+
return { content: [{ type: "text", text: formatSkills(skills) }], details: { count: skills.length } };
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
116
|
+
defineTool({
|
|
117
|
+
name: "litellm_skill_create",
|
|
118
|
+
label: "Create LiteLLM Skill",
|
|
119
|
+
description: "Create a skill on the LiteLLM proxy Skills Gateway.",
|
|
120
|
+
parameters: CreateSkillParams,
|
|
121
|
+
async execute(_toolCallId, params) {
|
|
122
|
+
const apiKey = await getApiKey();
|
|
123
|
+
const inputSchema = params.inputSchemaJson
|
|
124
|
+
? JSON.parse(params.inputSchemaJson)
|
|
125
|
+
: undefined;
|
|
126
|
+
const result = await createSkill(baseUrl, apiKey, { ...params, inputSchema });
|
|
127
|
+
return { content: [{ type: "text", text: "LiteLLM skill created." }], details: { result } };
|
|
128
|
+
},
|
|
129
|
+
}),
|
|
130
|
+
defineTool({
|
|
131
|
+
name: "litellm_skill_delete",
|
|
132
|
+
label: "Delete LiteLLM Skill",
|
|
133
|
+
description: "Delete a skill from the LiteLLM proxy Skills Gateway.",
|
|
134
|
+
parameters: DeleteSkillParams,
|
|
135
|
+
async execute(_toolCallId, params) {
|
|
136
|
+
const apiKey = await getApiKey();
|
|
137
|
+
await deleteSkill(baseUrl, apiKey, params.skillId);
|
|
138
|
+
return { content: [{ type: "text", text: `LiteLLM skill deleted: ${params.skillId}` }], details: {} };
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
export function resetSkillsCache() {
|
|
144
|
+
skillsCache = undefined;
|
|
145
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
export type DiscoverySource = "model_info" | "models_list";
|
|
2
|
+
export type DiscoverySource = "model_info" | "models_list" | "health";
|
|
3
3
|
export interface CacheFile {
|
|
4
4
|
baseUrl: string;
|
|
5
5
|
apiKeyFingerprint: string;
|
|
@@ -32,6 +32,14 @@ export interface ModelInfoEntry {
|
|
|
32
32
|
export interface ModelInfoResponse {
|
|
33
33
|
data?: ModelInfoEntry[];
|
|
34
34
|
}
|
|
35
|
+
export interface HealthModelEntry {
|
|
36
|
+
model?: string;
|
|
37
|
+
model_id?: string;
|
|
38
|
+
api_base?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface HealthResponse {
|
|
41
|
+
healthy_endpoints?: HealthModelEntry[];
|
|
42
|
+
}
|
|
35
43
|
export interface ModelsListEntry {
|
|
36
44
|
id?: string;
|
|
37
45
|
owned_by?: string;
|
|
@@ -53,4 +61,20 @@ export interface ResolvedCredentials {
|
|
|
53
61
|
baseUrl?: string;
|
|
54
62
|
apiKey?: string;
|
|
55
63
|
apiKeyFingerprint?: string;
|
|
64
|
+
apiKeyConfig?: string;
|
|
65
|
+
}
|
|
66
|
+
export interface LiteLLMMcpTool {
|
|
67
|
+
name: string;
|
|
68
|
+
server_name: string;
|
|
69
|
+
server_id?: string;
|
|
70
|
+
description: string;
|
|
71
|
+
input_schema: Record<string, unknown>;
|
|
72
|
+
}
|
|
73
|
+
export interface LiteLLMSkill {
|
|
74
|
+
id?: string;
|
|
75
|
+
name: string;
|
|
76
|
+
description?: string;
|
|
77
|
+
enabled?: boolean;
|
|
78
|
+
input_schema?: Record<string, unknown>;
|
|
79
|
+
code?: string;
|
|
56
80
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-provider-litellm",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.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.
|
|
68
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
67
|
+
"@earendil-works/pi-ai": "^0.79.1",
|
|
68
|
+
"@earendil-works/pi-coding-agent": "^0.79.1",
|
|
69
69
|
"@biomejs/biome": "^2.4.15",
|
|
70
70
|
"@types/node": "^25.9.1",
|
|
71
|
-
"@typescript/native-preview": "7.0.0-dev.
|
|
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",
|