pi-provider-litellm 1.2.2 → 1.2.4

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
@@ -24,8 +24,8 @@ pi -e npm:pi-provider-litellm
24
24
  ```bash
25
25
  git clone https://github.com/balcsida/pi-provider-litellm.git ~/.pi/agent/extensions/pi-provider-litellm
26
26
  cd ~/.pi/agent/extensions/pi-provider-litellm
27
- npm install
28
- npm run build
27
+ npm ci
28
+ npm run clean && npm run build
29
29
  ```
30
30
 
31
31
  </details>
@@ -61,11 +61,45 @@ Stored pi credentials for `litellm` take precedence over `LITELLM_API_KEY`; the
61
61
 
62
62
  | Variable | Default | Effect |
63
63
  |---|---|---|
64
+ | `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. |
64
65
  | `LITELLM_OFFLINE` | unset | If `1`, skip discovery on this start; use cache only |
65
66
  | `LITELLM_DISCOVERY_TIMEOUT_MS` | `5000` | Discovery fetch timeout in ms; `0` to skip discovery |
66
67
 
67
68
  `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`.
68
69
 
70
+ ## Mocked LiteLLM smoke workflow
71
+
72
+ 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.
73
+
74
+ 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.
75
+
76
+ ## Development
77
+
78
+ This package requires Node.js `>=22.19.0`. CI currently uses Node `24.16.0`.
79
+
80
+ ```bash
81
+ npm ci
82
+ npm run check
83
+ npm run clean && npm run build
84
+ ```
85
+
86
+ `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`.
87
+
88
+ Before changing package contents or dependency policy, also run:
89
+
90
+ ```bash
91
+ npm run supply-chain:guard
92
+ npm pack --dry-run
93
+ ```
94
+
95
+ The published npm package should contain only `dist`, `README.md`, and `LICENSE`.
96
+
97
+ ## Release
98
+
99
+ 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.
100
+
101
+ Before tagging a release, keep `package.json` and `package-lock.json` versions in sync and verify the dry-run package contents.
102
+
69
103
  ## Slash commands
70
104
 
71
105
  - `/litellm-refresh` — force re-fetch the model list, ignoring cache
@@ -74,6 +108,8 @@ Stored pi credentials for `litellm` take precedence over `LITELLM_API_KEY`; the
74
108
 
75
109
  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.
76
110
 
111
+ 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.
112
+
77
113
  ## Troubleshooting
78
114
 
79
115
  | Symptom | Likely cause |
@@ -82,6 +118,7 @@ The model list is cached at `~/.pi/agent/litellm-models.json` with a keyed finge
82
118
  | "discovered no models" | Proxy returned an empty list — check pi's startup log for the actual response |
83
119
  | `/model/info` returning 401/403/404 | Expected behavior with virtual keys — extension falls back to `/v1/models` |
84
120
  | Discovery times out | Increase `LITELLM_DISCOVERY_TIMEOUT_MS` or set `LITELLM_OFFLINE=1` to fall back on cached models |
121
+ | `401 Token expired` | Set `LITELLM_API_KEY_HELPER`. |
85
122
 
86
123
  ## License
87
124
 
package/dist/cost.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
2
2
  export interface ModelCostInfo {
3
3
  inputCostPerToken: number;
4
4
  outputCostPerToken: number;
5
5
  cacheReadCostPerToken: number;
6
6
  cacheWriteCostPerToken: number;
7
7
  }
8
- export declare function setupLiteLLMCostTracking(pi: ExtensionAPI): void;
8
+ export declare function setupLiteLLMCostTracking(pi: ExtensionAPI, models: ProviderModelConfig[]): (models: ProviderModelConfig[]) => void;
package/dist/cost.js CHANGED
@@ -1,48 +1,21 @@
1
- import { resolveCredentials } from "./litellm.js";
2
- export function setupLiteLLMCostTracking(pi) {
1
+ export function setupLiteLLMCostTracking(pi, models) {
3
2
  const modelCosts = new Map();
4
3
  let lastResponseCost = null;
5
- pi.on("session_start", async (_event, ctx) => {
6
- try {
7
- const config = await resolveCredentials();
8
- if (!config.baseUrl || !config.apiKey) {
9
- ctx.ui?.notify?.("LiteLLM cost extension: no credentials configured", "warning");
10
- return;
11
- }
12
- const response = await fetch(`${config.baseUrl}/model/info`, {
13
- headers: {
14
- Authorization: `Bearer ${config.apiKey}`,
15
- Accept: "application/json",
16
- },
4
+ // pi reports model costs per million tokens; cost lookups multiply by raw token counts.
5
+ const updateCosts = (newModels) => {
6
+ modelCosts.clear();
7
+ for (const model of newModels) {
8
+ if (!model.cost)
9
+ continue;
10
+ modelCosts.set(model.id, {
11
+ inputCostPerToken: (model.cost.input ?? 0) / 1_000_000,
12
+ outputCostPerToken: (model.cost.output ?? 0) / 1_000_000,
13
+ cacheReadCostPerToken: (model.cost.cacheRead ?? 0) / 1_000_000,
14
+ cacheWriteCostPerToken: (model.cost.cacheWrite ?? 0) / 1_000_000,
17
15
  });
18
- if (!response.ok) {
19
- ctx.ui?.notify?.(`LiteLLM cost extension: /model/info returned ${response.status}`, "warning");
20
- return;
21
- }
22
- const payload = (await response.json());
23
- for (const entry of payload.data) {
24
- const info = entry.model_info;
25
- if (!info)
26
- continue;
27
- const costInfo = {
28
- inputCostPerToken: info.input_cost_per_token ?? 0,
29
- outputCostPerToken: info.output_cost_per_token ?? 0,
30
- cacheReadCostPerToken: info.cache_read_input_token_cost ?? 0,
31
- cacheWriteCostPerToken: info.cache_creation_input_token_cost ?? 0,
32
- };
33
- modelCosts.set(entry.model_name, costInfo);
34
- if (entry.litellm_params?.model) {
35
- modelCosts.set(entry.litellm_params.model, costInfo);
36
- }
37
- }
38
- if (modelCosts.size > 0) {
39
- ctx.ui?.notify?.(`LiteLLM cost: loaded pricing for ${modelCosts.size} model(s)`, "info");
40
- }
41
16
  }
42
- catch (error) {
43
- ctx.ui?.notify?.(`LiteLLM cost extension: could not fetch /model/info (${error instanceof Error ? error.message : String(error)})`, "warning");
44
- }
45
- });
17
+ };
18
+ updateCosts(models);
46
19
  pi.on("after_provider_response", (event) => {
47
20
  const costHeader = event.headers?.["x-litellm-response-cost"] ?? event.headers?.["X-Litellm-Response-Cost"];
48
21
  if (costHeader) {
@@ -132,4 +105,5 @@ export function setupLiteLLMCostTracking(pi) {
132
105
  }
133
106
  return;
134
107
  });
108
+ return updateCosts;
135
109
  }
package/dist/discover.js CHANGED
@@ -1,15 +1,19 @@
1
+ import { getModels, getProviders } from "@earendil-works/pi-ai";
1
2
  const DEFAULT_TIMEOUT_MS = 5000;
2
3
  const DEFAULT_CONTEXT_WINDOW = 128_000;
3
4
  const DEFAULT_MAX_TOKENS = 16_384;
5
+ const KNOWN_PROVIDER_SET = new Set(getProviders());
6
+ const MODELS_DEV_URL = "https://models.dev/api.json";
7
+ let modelsDevCatalog;
4
8
  export function normalizeBaseUrl(input) {
5
9
  return input.replace(/\/+$/, "").replace(/\/v1\/?$/i, "");
6
10
  }
7
- // Matches both the conventional `anthropic/...` prefix and bare aliases that
11
+ // Matches both the conventional `anthropic/...` prefix and aliases that
8
12
  // LiteLLM deployments commonly assign to Anthropic-backed routes (e.g.
9
- // `opus-4.7`, `sonnet-4.6`, `haiku-4.5`, `claude-3-5-sonnet`). Without the
10
- // `cacheControlFormat: "anthropic"` flag, pi never relays cache_control markers
11
- // through the proxy, so prompt caching silently no-ops on Claude models.
12
- const ANTHROPIC_MODEL_PATTERN = /^(anthropic\/|claude|opus|sonnet|haiku)/i;
13
+ // `google/claude-sonnet-4-6`, `opus-4.7`, `sonnet-4.6`, `haiku-4.5`). Without
14
+ // the `cacheControlFormat: "anthropic"` flag, pi never relays cache_control
15
+ // markers through the proxy, so prompt caching silently no-ops on Claude models.
16
+ const ANTHROPIC_MODEL_PATTERN = /(?:^|[-_/.:])(?:anthropic\/|(?:claude|opus|sonnet|haiku)(?=$|[-_/.:]))/i;
13
17
  const MOONSHOT_MODEL_PATTERN = /^(moonshotai\/|moonshot\/|kimi[-/])/i;
14
18
  const FORCED_THINKING_MODEL_PATTERN = /(?:^|[-/])thinking(?:[-/]|$)/i;
15
19
  export function isMoonshotModel(modelId) {
@@ -33,6 +37,44 @@ export function buildCompat(modelId) {
33
37
  }
34
38
  return { supportsStore: false };
35
39
  }
40
+ function toKnownProvider(provider) {
41
+ if (!provider)
42
+ return undefined;
43
+ const normalized = provider.toLowerCase();
44
+ return KNOWN_PROVIDER_SET.has(normalized) ? normalized : undefined;
45
+ }
46
+ function findCatalogModel(id, ownedBy) {
47
+ const prefixProvider = toKnownProvider(id.split("/")[0]);
48
+ const candidates = [toKnownProvider(ownedBy), prefixProvider].filter((provider) => provider !== undefined);
49
+ 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;
56
+ }
57
+ for (const provider of getProviders()) {
58
+ const exact = getModels(provider).find((model) => model.id === id);
59
+ if (exact)
60
+ return exact;
61
+ }
62
+ return undefined;
63
+ }
64
+ function getFallbackProviderAndModel(id, ownedBy) {
65
+ const [prefix, ...rest] = id.split("/");
66
+ const prefixProvider = toKnownProvider(prefix);
67
+ if (prefixProvider && rest.length > 0) {
68
+ return { provider: prefixProvider, modelId: rest.join("/") };
69
+ }
70
+ return { provider: toKnownProvider(ownedBy), modelId: id };
71
+ }
72
+ function findModelsDevModel(catalog, id, ownedBy) {
73
+ const { provider, modelId } = getFallbackProviderAndModel(id, ownedBy);
74
+ if (!provider)
75
+ return undefined;
76
+ return catalog?.[provider]?.models?.[modelId];
77
+ }
36
78
  function withTimeout(timeoutMs, signal) {
37
79
  const controller = new AbortController();
38
80
  const onAbort = () => controller.abort(signal?.reason);
@@ -68,6 +110,59 @@ async function fetchJson(url, apiKey, options) {
68
110
  cancel();
69
111
  }
70
112
  }
113
+ async function fetchPublicJson(url, options) {
114
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
115
+ const { signal, cancel } = withTimeout(timeoutMs, options.signal);
116
+ try {
117
+ const response = await fetch(url, {
118
+ headers: { Accept: "application/json" },
119
+ signal,
120
+ });
121
+ if (!response.ok)
122
+ throw new Error(`${url} returned ${response.status}`);
123
+ return (await response.json());
124
+ }
125
+ finally {
126
+ cancel();
127
+ }
128
+ }
129
+ async function getModelsDevCatalog(options) {
130
+ if (modelsDevCatalog)
131
+ return modelsDevCatalog;
132
+ try {
133
+ modelsDevCatalog = await fetchPublicJson(MODELS_DEV_URL, options);
134
+ return modelsDevCatalog;
135
+ }
136
+ catch {
137
+ return undefined;
138
+ }
139
+ }
140
+ function mapModelsDevMetadata(model) {
141
+ if (!model)
142
+ return {};
143
+ const metadata = {};
144
+ if (model.name)
145
+ metadata.name = model.name;
146
+ if (model.reasoning !== undefined)
147
+ metadata.reasoning = model.reasoning;
148
+ if (model.modalities?.input) {
149
+ metadata.input = model.modalities.input.includes("image") ? ["text", "image"] : ["text"];
150
+ }
151
+ const contextWindow = model.limit?.context ?? model.limit?.input;
152
+ if (contextWindow !== undefined)
153
+ metadata.contextWindow = contextWindow;
154
+ if (model.limit?.output !== undefined)
155
+ metadata.maxTokens = model.limit.output;
156
+ if (model.cost) {
157
+ metadata.cost = {
158
+ input: model.cost.input ?? 0,
159
+ output: model.cost.output ?? 0,
160
+ cacheRead: model.cost.cache_read ?? 0,
161
+ cacheWrite: model.cost.cache_write ?? 0,
162
+ };
163
+ }
164
+ return metadata;
165
+ }
71
166
  function mapFromModelInfo(entry) {
72
167
  const id = entry.model_name;
73
168
  if (!id)
@@ -91,18 +186,21 @@ function mapFromModelInfo(entry) {
91
186
  compat: buildCompat(id),
92
187
  };
93
188
  }
94
- function mapFromModelsList(entry) {
189
+ function mapFromModelsList(entry, modelsDev) {
95
190
  const id = entry.id;
96
191
  if (!id)
97
192
  return undefined;
193
+ const catalogModel = findCatalogModel(id, entry.owned_by);
194
+ const modelsDevMetadata = mapModelsDevMetadata(findModelsDevModel(modelsDev, id, entry.owned_by));
98
195
  return {
99
196
  id,
100
- name: `${id} (no metadata)`,
101
- reasoning: false,
102
- input: ["text"],
103
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
104
- contextWindow: DEFAULT_CONTEXT_WINDOW,
105
- maxTokens: DEFAULT_MAX_TOKENS,
197
+ name: modelsDevMetadata.name ?? catalogModel?.name ?? `${id} (no metadata)`,
198
+ reasoning: modelsDevMetadata.reasoning ?? catalogModel?.reasoning ?? false,
199
+ thinkingLevelMap: catalogModel?.thinkingLevelMap,
200
+ input: modelsDevMetadata.input ?? catalogModel?.input ?? ["text"],
201
+ cost: modelsDevMetadata.cost ?? catalogModel?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
202
+ contextWindow: modelsDevMetadata.contextWindow ?? catalogModel?.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
203
+ maxTokens: modelsDevMetadata.maxTokens ?? catalogModel?.maxTokens ?? DEFAULT_MAX_TOKENS,
106
204
  compat: buildCompat(id),
107
205
  };
108
206
  }
@@ -122,8 +220,9 @@ export async function discoverModels(baseUrl, apiKey, options = {}) {
122
220
  if (!listResult.ok) {
123
221
  throw new Error(`/v1/models returned ${listResult.status}`);
124
222
  }
223
+ const modelsDev = await getModelsDevCatalog(options);
125
224
  const models = (listResult.data.data ?? [])
126
- .map(mapFromModelsList)
225
+ .map((entry) => mapFromModelsList(entry, modelsDev))
127
226
  .filter((m) => m !== undefined);
128
227
  return { source: "models_list", models };
129
228
  }
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { execSync } from "node:child_process";
1
2
  import { readFile } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
  import { AuthStorage, getAgentDir } from "@earendil-works/pi-coding-agent";
@@ -8,11 +9,16 @@ import { getSessionIdFromFile } from "./litellm.js";
8
9
  const PROVIDER_NAME = "litellm";
9
10
  const ENV_BASE_URL = "LITELLM_BASE_URL";
10
11
  const ENV_API_KEY = "LITELLM_API_KEY";
12
+ const ENV_API_KEY_HELPER = "LITELLM_API_KEY_HELPER";
11
13
  const ENV_TIMEOUT = "LITELLM_DISCOVERY_TIMEOUT_MS";
12
14
  const ENV_OFFLINE = "LITELLM_OFFLINE";
13
15
  const DEFAULT_TIMEOUT_MS = 5000;
14
16
  const LOGIN_TIMEOUT_MS = 10_000;
15
17
  const CACHE_FILENAME = "litellm-models.json";
18
+ const CACHE_STALE_MS = 24 * 60 * 60 * 1000;
19
+ const TOKEN_REFRESH_LEAD_MS = 5 * 60 * 1000;
20
+ const PERMANENT_TOKEN_EXPIRES_AT = Number.MAX_SAFE_INTEGER;
21
+ const EXPIRE_TOKEN_IMMEDIATELY = 0;
16
22
  function getAuthPath() {
17
23
  return join(getAgentDir(), "auth.json");
18
24
  }
@@ -29,20 +35,74 @@ async function readAuthEntry() {
29
35
  return undefined;
30
36
  }
31
37
  }
32
- async function resolveCredentials() {
38
+ function cleanConfig(raw) {
39
+ const trimmed = raw?.trim();
40
+ return trimmed && trimmed !== "undefined" ? trimmed : undefined;
41
+ }
42
+ function normalizeCommand(raw) {
43
+ const trimmed = cleanConfig(raw);
44
+ if (!trimmed)
45
+ return undefined;
46
+ return trimmed.startsWith("!") ? trimmed : `!${trimmed}`;
47
+ }
48
+ function getApiKeyHelperCommand() {
49
+ return normalizeCommand(process.env[ENV_API_KEY_HELPER]);
50
+ }
51
+ function executeApiKeyCommand(commandConfig) {
52
+ const command = commandConfig.startsWith("!") ? commandConfig.slice(1) : commandConfig;
53
+ const output = execSync(command, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 }).trim();
54
+ if (!output)
55
+ throw new Error(`LiteLLM API key helper produced no output: ${command}`);
56
+ return output;
57
+ }
58
+ function tokenExpiresAt(apiKey, opaqueFallback = PERMANENT_TOKEN_EXPIRES_AT) {
59
+ const [, payload] = apiKey.split(".");
60
+ if (!payload)
61
+ return opaqueFallback;
62
+ try {
63
+ const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
64
+ return typeof claims.exp === "number"
65
+ ? Math.max(Date.now(), claims.exp * 1000 - TOKEN_REFRESH_LEAD_MS)
66
+ : opaqueFallback;
67
+ }
68
+ catch {
69
+ return opaqueFallback;
70
+ }
71
+ }
72
+ function getLiteLLMApiKey(credentials) {
73
+ return credentials.access;
74
+ }
75
+ function resolveOAuthApiKey(credentials) {
76
+ return credentials.refresh.startsWith("!")
77
+ ? executeApiKeyCommand(credentials.refresh)
78
+ : getLiteLLMApiKey(credentials);
79
+ }
80
+ async function resolveCredentials({ executeHelpers = true } = {}) {
33
81
  const entry = await readAuthEntry();
34
- const envBase = process.env[ENV_BASE_URL]?.trim();
35
- const envKey = process.env[ENV_API_KEY]?.trim();
82
+ const envBase = cleanConfig(process.env[ENV_BASE_URL]);
83
+ const envKey = cleanConfig(process.env[ENV_API_KEY]);
84
+ const envHelperCommand = getApiKeyHelperCommand();
36
85
  const authBase = entry?.type === "oauth" ? entry.baseUrl?.trim() : undefined;
37
86
  const authKey = entry?.type === "oauth"
38
- ? entry.access?.trim()
87
+ ? (executeHelpers ? resolveOAuthApiKey(entry) : entry.access).trim()
39
88
  : entry?.type === "api_key"
40
89
  ? (await AuthStorage.create(getAuthPath()).getApiKey(PROVIDER_NAME, { includeFallback: false }))?.trim()
41
90
  : 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;
42
101
  const rawBase = authBase || envBase;
43
102
  return {
44
103
  baseUrl: rawBase ? normalizeBaseUrl(rawBase) : undefined,
45
- apiKey: authKey || envKey || undefined,
104
+ apiKey: apiKey || undefined,
105
+ apiKeyFingerprint,
46
106
  };
47
107
  }
48
108
  function getDiscoveryTimeoutMs() {
@@ -72,36 +132,43 @@ async function discoverWithFallback(baseUrl, apiKey, options) {
72
132
  };
73
133
  }
74
134
  }
75
- async function loginLiteLLM(callbacks) {
135
+ async function loginLiteLLM(callbacks, onCacheWrite) {
76
136
  const rawBaseUrl = (await callbacks.onPrompt({
77
137
  message: "Enter LiteLLM proxy URL (no trailing /v1):",
78
138
  placeholder: "https://litellm.example.com",
79
139
  })).trim();
80
- const apiKey = (await callbacks.onPrompt({ message: "Enter API key:" })).trim();
81
- if (!rawBaseUrl || !apiKey)
140
+ const apiKeyInput = (await callbacks.onPrompt({ message: "Enter API key:" })).trim();
141
+ if (!rawBaseUrl || !apiKeyInput)
82
142
  throw new Error("Both base URL and API key are required");
83
143
  const baseUrl = normalizeBaseUrl(rawBaseUrl);
144
+ const refresh = apiKeyInput.startsWith("!") ? apiKeyInput : "";
145
+ const apiKey = refresh ? executeApiKeyCommand(refresh) : apiKeyInput;
84
146
  const { models, source } = await discoverModels(baseUrl, apiKey, {
85
147
  timeoutMs: LOGIN_TIMEOUT_MS,
86
148
  signal: callbacks.signal,
87
149
  });
88
- await writeCache(getCachePath(), {
150
+ const cache = {
89
151
  baseUrl,
90
- apiKeyFingerprint: fingerprint(apiKey),
152
+ apiKeyFingerprint: fingerprint(refresh || apiKey),
91
153
  fetchedAt: Date.now(),
92
154
  source,
93
155
  models,
94
- });
156
+ };
157
+ await writeCache(getCachePath(), cache);
158
+ onCacheWrite?.(cache);
95
159
  callbacks.onProgress?.(`LiteLLM: ${models.length} models discovered (source: ${source})`);
96
160
  return {
97
161
  access: apiKey,
98
- refresh: "",
99
- expires: Number.MAX_SAFE_INTEGER,
162
+ refresh,
163
+ expires: tokenExpiresAt(apiKey, refresh ? EXPIRE_TOKEN_IMMEDIATELY : PERMANENT_TOKEN_EXPIRES_AT),
100
164
  baseUrl,
101
165
  };
102
166
  }
103
167
  async function refreshLiteLLM(credentials) {
104
- return credentials;
168
+ if (!credentials.refresh.startsWith("!"))
169
+ return credentials;
170
+ const access = executeApiKeyCommand(credentials.refresh);
171
+ return { ...credentials, access, expires: tokenExpiresAt(access, EXPIRE_TOKEN_IMMEDIATELY) };
105
172
  }
106
173
  function modifyLiteLLMModels(models, cred) {
107
174
  const baseUrl = cred.baseUrl;
@@ -191,97 +258,137 @@ function normalizeThinkTags(message) {
191
258
  return { ...message, content };
192
259
  }
193
260
  export default async function (pi) {
194
- const creds = await resolveCredentials();
261
+ let creds = await resolveCredentials({ executeHelpers: false });
195
262
  const cache = await readCache(getCachePath());
196
- const fp = creds.apiKey ? fingerprint(creds.apiKey) : undefined;
263
+ let fp = creds.apiKeyFingerprint;
264
+ let cacheFetchedAt = cache?.fetchedAt ?? 0;
197
265
  const cacheValid = cache !== null &&
198
266
  creds.baseUrl !== undefined &&
199
267
  fp !== undefined &&
200
268
  cache.baseUrl === creds.baseUrl &&
201
269
  cache.apiKeyFingerprint === fp;
202
270
  let models = cacheValid && cache ? cache.models : [];
203
- const haveCreds = creds.baseUrl !== undefined && creds.apiKey !== undefined && fp !== undefined;
204
- const shouldFetch = haveCreds && !isOffline() && (!cacheValid || isListModelsMode());
205
- if (shouldFetch && creds.baseUrl && creds.apiKey && fp) {
271
+ const shouldFetch = creds.baseUrl !== undefined &&
272
+ fp !== undefined &&
273
+ !isOffline() &&
274
+ getDiscoveryTimeoutMs() > 0 &&
275
+ (!cacheValid || isListModelsMode());
276
+ let credentialWarning;
277
+ if (shouldFetch) {
278
+ try {
279
+ creds = await resolveCredentials();
280
+ fp = creds.apiKeyFingerprint;
281
+ }
282
+ catch (error) {
283
+ if (!cacheValid || !cache)
284
+ throw error;
285
+ credentialWarning = error instanceof Error ? error.message : String(error);
286
+ process.stderr.write(`LiteLLM: discovery failed (${credentialWarning}); using cached models.\n`);
287
+ models = cache.models;
288
+ }
289
+ }
290
+ if (shouldFetch && !credentialWarning && creds.baseUrl && creds.apiKey && fp) {
206
291
  const timeoutMs = getDiscoveryTimeoutMs();
207
- if (timeoutMs > 0) {
208
- const { result, warning } = await discoverWithFallback(creds.baseUrl, creds.apiKey, {
209
- timeoutMs,
210
- });
211
- if (warning) {
212
- if (cacheValid && cache) {
213
- process.stderr.write(`LiteLLM: discovery failed (${warning}); using cached models.\n`);
214
- models = cache.models;
215
- }
216
- else {
217
- process.stderr.write(`LiteLLM: discovery failed (${warning}); registering provider with no models.\n`);
218
- models = [];
219
- }
292
+ const { result, warning } = await discoverWithFallback(creds.baseUrl, creds.apiKey, {
293
+ timeoutMs,
294
+ });
295
+ if (warning) {
296
+ if (cacheValid && cache) {
297
+ process.stderr.write(`LiteLLM: discovery failed (${warning}); using cached models.\n`);
298
+ models = cache.models;
220
299
  }
221
300
  else {
222
- models = result.models;
223
- const next = {
224
- baseUrl: creds.baseUrl,
225
- apiKeyFingerprint: fp,
226
- fetchedAt: Date.now(),
227
- source: result.source,
228
- models: result.models,
229
- };
230
- await writeCache(getCachePath(), next);
231
- if (isListModelsMode()) {
232
- process.stderr.write(`LiteLLM: ${result.models.length} models discovered (source: ${result.source}).\n`);
233
- }
301
+ process.stderr.write(`LiteLLM: discovery failed (${warning}); registering provider with no models.\n`);
302
+ models = [];
303
+ }
304
+ }
305
+ else {
306
+ models = result.models;
307
+ const next = {
308
+ baseUrl: creds.baseUrl,
309
+ apiKeyFingerprint: fp,
310
+ fetchedAt: Date.now(),
311
+ source: result.source,
312
+ models: result.models,
313
+ };
314
+ await writeCache(getCachePath(), next);
315
+ cacheFetchedAt = next.fetchedAt;
316
+ if (isListModelsMode()) {
317
+ process.stderr.write(`LiteLLM: ${result.models.length} models discovered (source: ${result.source}).\n`);
234
318
  }
235
319
  }
236
320
  }
237
321
  const oauth = {
238
322
  name: "LiteLLM",
239
- login: loginLiteLLM,
323
+ login: (callbacks) => loginLiteLLM(callbacks, (next) => {
324
+ cacheFetchedAt = next.fetchedAt;
325
+ }),
240
326
  refreshToken: refreshLiteLLM,
241
- getApiKey: (cred) => cred.access,
327
+ getApiKey: getLiteLLMApiKey,
242
328
  modifyModels: modifyLiteLLMModels,
243
329
  };
244
- pi.registerProvider(PROVIDER_NAME, {
245
- baseUrl: creds.baseUrl ? `${creds.baseUrl}/v1` : "https://litellm.example.com/v1",
246
- apiKey: ENV_API_KEY,
247
- api: "openai-completions",
248
- models,
249
- oauth,
250
- });
330
+ function registerProvider(baseUrl, models) {
331
+ pi.registerProvider(PROVIDER_NAME, {
332
+ baseUrl: baseUrl ? `${baseUrl}/v1` : "https://litellm.example.com/v1",
333
+ // When LITELLM_API_KEY_HELPER is set we register the helper as a `!command` provider key.
334
+ // Pi's per-request auth path (ModelRegistry.getApiKeyAndHeaders) resolves provider keys via
335
+ // resolveConfigValueOrThrow -> resolveConfigValueUncached, i.e. the command is re-executed on
336
+ // every request (it does NOT use the process-lifetime command cache, which only applies to
337
+ // resolveConfigValue). So a short-lived/rotating helper token stays fresh. The OAuth hooks
338
+ // remain registered for `/login litellm` users. See the regression test
339
+ // "re-runs the helper command on every request" in tests/index.test.ts.
340
+ apiKey: getApiKeyHelperCommand() ?? `$${ENV_API_KEY}`,
341
+ api: "openai-completions",
342
+ models,
343
+ oauth,
344
+ });
345
+ }
346
+ registerProvider(creds.baseUrl, models);
347
+ const updateCosts = setupLiteLLMCostTracking(pi, models);
348
+ let refreshInProgress = null;
349
+ function discoveryDisabledReason() {
350
+ if (isOffline())
351
+ return `${ENV_OFFLINE}=1`;
352
+ if (getDiscoveryTimeoutMs() === 0)
353
+ return `${ENV_TIMEOUT}=0`;
354
+ return null;
355
+ }
356
+ async function refreshModelsAndCosts() {
357
+ const fresh = await resolveCredentials();
358
+ const freshFp = fresh.apiKeyFingerprint;
359
+ if (!fresh.baseUrl || !fresh.apiKey || !freshFp) {
360
+ throw new Error("no credentials. Run /login litellm or set env vars.");
361
+ }
362
+ const result = await discoverModels(fresh.baseUrl, fresh.apiKey, { timeoutMs: getDiscoveryTimeoutMs() });
363
+ const now = Date.now();
364
+ await writeCache(getCachePath(), {
365
+ baseUrl: fresh.baseUrl,
366
+ apiKeyFingerprint: freshFp,
367
+ fetchedAt: now,
368
+ source: result.source,
369
+ models: result.models,
370
+ });
371
+ registerProvider(fresh.baseUrl, result.models);
372
+ updateCosts(result.models);
373
+ cacheFetchedAt = now;
374
+ return { models: result.models, source: result.source };
375
+ }
376
+ function runRefresh() {
377
+ refreshInProgress ??= refreshModelsAndCosts().finally(() => {
378
+ refreshInProgress = null;
379
+ });
380
+ return refreshInProgress;
381
+ }
251
382
  pi.registerCommand("litellm-refresh", {
252
383
  description: "Re-discover models from the LiteLLM proxy.",
253
384
  handler: async (_args, ctx) => {
254
- if (isOffline()) {
255
- ctx.ui.notify(`LiteLLM refresh disabled (${ENV_OFFLINE}=1)`, "warning");
256
- return;
257
- }
258
- const timeoutMs = getDiscoveryTimeoutMs();
259
- if (timeoutMs === 0) {
260
- ctx.ui.notify(`LiteLLM refresh disabled (${ENV_TIMEOUT}=0)`, "warning");
261
- return;
262
- }
263
- const fresh = await resolveCredentials();
264
- const freshFp = fresh.apiKey ? fingerprint(fresh.apiKey) : undefined;
265
- if (!fresh.baseUrl || !fresh.apiKey || !freshFp) {
266
- ctx.ui.notify("LiteLLM refresh failed: no credentials. Run /login litellm or set env vars.", "error");
385
+ const disabledReason = discoveryDisabledReason();
386
+ if (disabledReason) {
387
+ ctx.ui.notify(`LiteLLM refresh disabled (${disabledReason})`, "warning");
267
388
  return;
268
389
  }
269
390
  try {
270
- const result = await discoverModels(fresh.baseUrl, fresh.apiKey, { timeoutMs });
271
- await writeCache(getCachePath(), {
272
- baseUrl: fresh.baseUrl,
273
- apiKeyFingerprint: freshFp,
274
- fetchedAt: Date.now(),
275
- source: result.source,
276
- models: result.models,
277
- });
278
- pi.registerProvider(PROVIDER_NAME, {
279
- baseUrl: `${fresh.baseUrl}/v1`,
280
- apiKey: ENV_API_KEY,
281
- api: "openai-completions",
282
- models: result.models,
283
- oauth,
284
- });
391
+ const result = await runRefresh();
285
392
  ctx.ui.notify(`LiteLLM: ${result.models.length} models refreshed (source: ${result.source})`, "info");
286
393
  }
287
394
  catch (error) {
@@ -290,10 +397,12 @@ export default async function (pi) {
290
397
  }
291
398
  },
292
399
  });
293
- setupLiteLLMCostTracking(pi);
294
400
  let sessionId;
295
- pi.on("session_start", async (_event, ctx) => {
401
+ pi.on("session_start", (_event, ctx) => {
296
402
  sessionId = getSessionIdFromFile(ctx.sessionManager.getSessionFile());
403
+ if (discoveryDisabledReason() || !cacheFetchedAt || Date.now() - cacheFetchedAt <= CACHE_STALE_MS)
404
+ return;
405
+ void runRefresh().catch(() => undefined);
297
406
  });
298
407
  pi.on("before_provider_request", (event, ctx) => {
299
408
  if (ctx.model?.provider !== PROVIDER_NAME)
package/dist/types.d.ts CHANGED
@@ -34,6 +34,7 @@ export interface ModelInfoResponse {
34
34
  }
35
35
  export interface ModelsListEntry {
36
36
  id?: string;
37
+ owned_by?: string;
37
38
  }
38
39
  export interface ModelsListResponse {
39
40
  data?: ModelsListEntry[];
@@ -51,4 +52,5 @@ export type AuthFileEntry = {
51
52
  export interface ResolvedCredentials {
52
53
  baseUrl?: string;
53
54
  apiKey?: string;
55
+ apiKeyFingerprint?: string;
54
56
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-provider-litellm",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "description": "LiteLLM proxy provider extension for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,15 +47,16 @@
47
47
  "lint": "biome check --error-on-warnings .",
48
48
  "format": "biome check --write .",
49
49
  "test": "vitest --run",
50
+ "supply-chain:guard": "tsx scripts/supply-chain-guard.ts",
50
51
  "check": "npm run lint && npm run typecheck && npm test",
51
- "prepublishOnly": "npm run check && npm run clean && npm run build"
52
+ "prepublishOnly": "npm run check && npm run clean && npm run build && npm run supply-chain:guard"
52
53
  },
53
54
  "engines": {
54
- "node": ">=20.0.0"
55
+ "node": ">=22.19.0"
55
56
  },
56
57
  "peerDependencies": {
57
- "@earendil-works/pi-ai": "^0.74.0",
58
- "@earendil-works/pi-coding-agent": "^0.74.0"
58
+ "@earendil-works/pi-ai": "*",
59
+ "@earendil-works/pi-coding-agent": "*"
59
60
  },
60
61
  "overrides": {
61
62
  "basic-ftp": "6.0.1",
@@ -63,14 +64,14 @@
63
64
  "protobufjs": "8.2.1"
64
65
  },
65
66
  "devDependencies": {
66
- "@earendil-works/pi-ai": "^0.74.0",
67
- "@earendil-works/pi-coding-agent": "^0.74.0",
68
- "@biomejs/biome": "^1.9.4",
69
- "@types/node": "^24.12.3",
70
- "@typescript/native-preview": "7.0.0-dev.20260120.1",
67
+ "@earendil-works/pi-ai": "^0.78.1",
68
+ "@earendil-works/pi-coding-agent": "^0.78.1",
69
+ "@biomejs/biome": "^2.4.15",
70
+ "@types/node": "^25.9.1",
71
+ "@typescript/native-preview": "7.0.0-dev.20260607.1",
71
72
  "shx": "^0.4.0",
72
73
  "tsx": "^4.21.0",
73
- "typescript": "^5.9.3",
74
- "vitest": "^3.2.4"
74
+ "typescript": "^6.0.3",
75
+ "vitest": "^4.1.7"
75
76
  }
76
77
  }