pi-provider-litellm 1.2.1 → 1.2.3

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>
@@ -66,6 +66,39 @@ Stored pi credentials for `litellm` take precedence over `LITELLM_API_KEY`; the
66
66
 
67
67
  `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
68
 
69
+ ## Mocked LiteLLM smoke workflow
70
+
71
+ 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.
72
+
73
+ 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.
74
+
75
+ ## Development
76
+
77
+ This package requires Node.js `>=22.19.0`. CI currently uses Node `24.16.0`.
78
+
79
+ ```bash
80
+ npm ci
81
+ npm run check
82
+ npm run clean && npm run build
83
+ ```
84
+
85
+ `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`.
86
+
87
+ Before changing package contents or dependency policy, also run:
88
+
89
+ ```bash
90
+ npm run supply-chain:guard
91
+ npm pack --dry-run
92
+ ```
93
+
94
+ The published npm package should contain only `dist`, `README.md`, and `LICENSE`.
95
+
96
+ ## Release
97
+
98
+ 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.
99
+
100
+ Before tagging a release, keep `package.json` and `package-lock.json` versions in sync and verify the dry-run package contents.
101
+
69
102
  ## Slash commands
70
103
 
71
104
  - `/litellm-refresh` — force re-fetch the model list, ignoring cache
@@ -74,6 +107,8 @@ Stored pi credentials for `litellm` take precedence over `LITELLM_API_KEY`; the
74
107
 
75
108
  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
109
 
110
+ 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.
111
+
77
112
  ## Troubleshooting
78
113
 
79
114
  | Symptom | Likely cause |
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
  }
@@ -1,5 +1,7 @@
1
1
  import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
2
2
  import type { DiscoveryOptions, DiscoveryResult } from "./types.js";
3
3
  export declare function normalizeBaseUrl(input: string): string;
4
+ export declare function isMoonshotModel(modelId: string): boolean;
5
+ export declare function shouldSuppressReasoningContent(modelId: string): boolean;
4
6
  export declare function buildCompat(modelId: string): ProviderModelConfig["compat"];
5
7
  export declare function discoverModels(baseUrl: string, apiKey: string, options?: DiscoveryOptions): Promise<DiscoveryResult>;
package/dist/discover.js CHANGED
@@ -1,18 +1,29 @@
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;
18
+ const FORCED_THINKING_MODEL_PATTERN = /(?:^|[-/])thinking(?:[-/]|$)/i;
19
+ export function isMoonshotModel(modelId) {
20
+ return MOONSHOT_MODEL_PATTERN.test(modelId);
21
+ }
22
+ export function shouldSuppressReasoningContent(modelId) {
23
+ return isMoonshotModel(modelId) && !FORCED_THINKING_MODEL_PATTERN.test(modelId);
24
+ }
14
25
  export function buildCompat(modelId) {
15
- if (MOONSHOT_MODEL_PATTERN.test(modelId)) {
26
+ if (isMoonshotModel(modelId)) {
16
27
  return {
17
28
  supportsStore: false,
18
29
  supportsDeveloperRole: false,
@@ -26,6 +37,44 @@ export function buildCompat(modelId) {
26
37
  }
27
38
  return { supportsStore: false };
28
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
+ }
29
78
  function withTimeout(timeoutMs, signal) {
30
79
  const controller = new AbortController();
31
80
  const onAbort = () => controller.abort(signal?.reason);
@@ -61,6 +110,59 @@ async function fetchJson(url, apiKey, options) {
61
110
  cancel();
62
111
  }
63
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
+ }
64
166
  function mapFromModelInfo(entry) {
65
167
  const id = entry.model_name;
66
168
  if (!id)
@@ -84,18 +186,21 @@ function mapFromModelInfo(entry) {
84
186
  compat: buildCompat(id),
85
187
  };
86
188
  }
87
- function mapFromModelsList(entry) {
189
+ function mapFromModelsList(entry, modelsDev) {
88
190
  const id = entry.id;
89
191
  if (!id)
90
192
  return undefined;
193
+ const catalogModel = findCatalogModel(id, entry.owned_by);
194
+ const modelsDevMetadata = mapModelsDevMetadata(findModelsDevModel(modelsDev, id, entry.owned_by));
91
195
  return {
92
196
  id,
93
- name: `${id} (no metadata)`,
94
- reasoning: false,
95
- input: ["text"],
96
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
97
- contextWindow: DEFAULT_CONTEXT_WINDOW,
98
- 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,
99
204
  compat: buildCompat(id),
100
205
  };
101
206
  }
@@ -115,8 +220,9 @@ export async function discoverModels(baseUrl, apiKey, options = {}) {
115
220
  if (!listResult.ok) {
116
221
  throw new Error(`/v1/models returned ${listResult.status}`);
117
222
  }
223
+ const modelsDev = await getModelsDevCatalog(options);
118
224
  const models = (listResult.data.data ?? [])
119
- .map(mapFromModelsList)
225
+ .map((entry) => mapFromModelsList(entry, modelsDev))
120
226
  .filter((m) => m !== undefined);
121
227
  return { source: "models_list", models };
122
228
  }
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { AuthStorage, getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
  import { fingerprint, readCache, writeCache } from "./cache.js";
5
5
  import { setupLiteLLMCostTracking } from "./cost.js";
6
- import { discoverModels, normalizeBaseUrl } from "./discover.js";
6
+ import { discoverModels, normalizeBaseUrl, shouldSuppressReasoningContent } from "./discover.js";
7
7
  import { getSessionIdFromFile } from "./litellm.js";
8
8
  const PROVIDER_NAME = "litellm";
9
9
  const ENV_BASE_URL = "LITELLM_BASE_URL";
@@ -13,6 +13,7 @@ const ENV_OFFLINE = "LITELLM_OFFLINE";
13
13
  const DEFAULT_TIMEOUT_MS = 5000;
14
14
  const LOGIN_TIMEOUT_MS = 10_000;
15
15
  const CACHE_FILENAME = "litellm-models.json";
16
+ const CACHE_STALE_MS = 24 * 60 * 60 * 1000;
16
17
  function getAuthPath() {
17
18
  return join(getAgentDir(), "auth.json");
18
19
  }
@@ -72,7 +73,7 @@ async function discoverWithFallback(baseUrl, apiKey, options) {
72
73
  };
73
74
  }
74
75
  }
75
- async function loginLiteLLM(callbacks) {
76
+ async function loginLiteLLM(callbacks, onCacheWrite) {
76
77
  const rawBaseUrl = (await callbacks.onPrompt({
77
78
  message: "Enter LiteLLM proxy URL (no trailing /v1):",
78
79
  placeholder: "https://litellm.example.com",
@@ -85,13 +86,15 @@ async function loginLiteLLM(callbacks) {
85
86
  timeoutMs: LOGIN_TIMEOUT_MS,
86
87
  signal: callbacks.signal,
87
88
  });
88
- await writeCache(getCachePath(), {
89
+ const cache = {
89
90
  baseUrl,
90
91
  apiKeyFingerprint: fingerprint(apiKey),
91
92
  fetchedAt: Date.now(),
92
93
  source,
93
94
  models,
94
- });
95
+ };
96
+ await writeCache(getCachePath(), cache);
97
+ onCacheWrite?.(cache);
95
98
  callbacks.onProgress?.(`LiteLLM: ${models.length} models discovered (source: ${source})`);
96
99
  return {
97
100
  access: apiKey,
@@ -109,10 +112,92 @@ function modifyLiteLLMModels(models, cred) {
109
112
  return models;
110
113
  return models.map((m) => (m.provider === PROVIDER_NAME ? { ...m, baseUrl: `${baseUrl}/v1` } : m));
111
114
  }
115
+ function prepareLiteLLMRequestPayload(payload, modelId, sessionId) {
116
+ let next;
117
+ const update = (key, value) => {
118
+ if (payload[key] !== undefined)
119
+ return;
120
+ next ??= { ...payload };
121
+ next[key] = value;
122
+ };
123
+ if (modelId && shouldSuppressReasoningContent(modelId)) {
124
+ update("include_reasoning", false);
125
+ update("reasoning_content", false);
126
+ update("merge_reasoning_content_in_choices", true);
127
+ update("thinking", { type: "disabled" });
128
+ }
129
+ if (sessionId) {
130
+ next ??= { ...payload };
131
+ next.litellm_session_id = sessionId;
132
+ }
133
+ return next;
134
+ }
135
+ function normalizeThinkTags(message) {
136
+ if (message.provider !== PROVIDER_NAME || !shouldSuppressReasoningContent(message.model))
137
+ return;
138
+ let changed = false;
139
+ const content = [];
140
+ const appendText = (text) => {
141
+ if (!text)
142
+ return;
143
+ const last = content.at(-1);
144
+ if (last?.type === "text") {
145
+ last.text += text;
146
+ return;
147
+ }
148
+ content.push({ type: "text", text });
149
+ };
150
+ const appendThinking = (thinking) => {
151
+ if (!thinking)
152
+ return;
153
+ const last = content.at(-1);
154
+ if (last?.type === "thinking") {
155
+ last.thinking += thinking;
156
+ return;
157
+ }
158
+ content.push({ type: "thinking", thinking });
159
+ };
160
+ for (let blockIndex = 0; blockIndex < message.content.length; blockIndex++) {
161
+ const block = message.content[blockIndex];
162
+ if (block.type !== "text") {
163
+ content.push(block);
164
+ continue;
165
+ }
166
+ let index = 0;
167
+ while (index < block.text.length) {
168
+ const start = block.text.indexOf("<think>", index);
169
+ if (start === -1) {
170
+ appendText(block.text.slice(index));
171
+ break;
172
+ }
173
+ changed = true;
174
+ appendText(block.text.slice(index, start));
175
+ const thinkingStart = start + "<think>".length;
176
+ const end = block.text.indexOf("</think>", thinkingStart);
177
+ if (end === -1) {
178
+ const isBeforeNonTextContent = message.content
179
+ .slice(blockIndex + 1)
180
+ .some((nextBlock) => nextBlock.type !== "text");
181
+ if (isBeforeNonTextContent)
182
+ appendThinking(block.text.slice(thinkingStart));
183
+ else
184
+ appendText(block.text.slice(thinkingStart));
185
+ index = block.text.length;
186
+ break;
187
+ }
188
+ appendThinking(block.text.slice(thinkingStart, end));
189
+ index = end + "</think>".length;
190
+ }
191
+ }
192
+ if (!changed)
193
+ return;
194
+ return { ...message, content };
195
+ }
112
196
  export default async function (pi) {
113
197
  const creds = await resolveCredentials();
114
198
  const cache = await readCache(getCachePath());
115
199
  const fp = creds.apiKey ? fingerprint(creds.apiKey) : undefined;
200
+ let cacheFetchedAt = cache?.fetchedAt ?? 0;
116
201
  const cacheValid = cache !== null &&
117
202
  creds.baseUrl !== undefined &&
118
203
  fp !== undefined &&
@@ -147,6 +232,7 @@ export default async function (pi) {
147
232
  models: result.models,
148
233
  };
149
234
  await writeCache(getCachePath(), next);
235
+ cacheFetchedAt = next.fetchedAt;
150
236
  if (isListModelsMode()) {
151
237
  process.stderr.write(`LiteLLM: ${result.models.length} models discovered (source: ${result.source}).\n`);
152
238
  }
@@ -155,52 +241,68 @@ export default async function (pi) {
155
241
  }
156
242
  const oauth = {
157
243
  name: "LiteLLM",
158
- login: loginLiteLLM,
244
+ login: (callbacks) => loginLiteLLM(callbacks, (next) => {
245
+ cacheFetchedAt = next.fetchedAt;
246
+ }),
159
247
  refreshToken: refreshLiteLLM,
160
248
  getApiKey: (cred) => cred.access,
161
249
  modifyModels: modifyLiteLLMModels,
162
250
  };
163
- pi.registerProvider(PROVIDER_NAME, {
164
- baseUrl: creds.baseUrl ? `${creds.baseUrl}/v1` : "https://litellm.example.com/v1",
165
- apiKey: ENV_API_KEY,
166
- api: "openai-completions",
167
- models,
168
- oauth,
169
- });
251
+ function registerProvider(baseUrl, models) {
252
+ pi.registerProvider(PROVIDER_NAME, {
253
+ baseUrl: baseUrl ? `${baseUrl}/v1` : "https://litellm.example.com/v1",
254
+ apiKey: ENV_API_KEY,
255
+ api: "openai-completions",
256
+ models,
257
+ oauth,
258
+ });
259
+ }
260
+ registerProvider(creds.baseUrl, models);
261
+ const updateCosts = setupLiteLLMCostTracking(pi, models);
262
+ let refreshInProgress = null;
263
+ function discoveryDisabledReason() {
264
+ if (isOffline())
265
+ return `${ENV_OFFLINE}=1`;
266
+ if (getDiscoveryTimeoutMs() === 0)
267
+ return `${ENV_TIMEOUT}=0`;
268
+ return null;
269
+ }
270
+ async function refreshModelsAndCosts() {
271
+ const fresh = await resolveCredentials();
272
+ const freshFp = fresh.apiKey ? fingerprint(fresh.apiKey) : undefined;
273
+ if (!fresh.baseUrl || !fresh.apiKey || !freshFp) {
274
+ throw new Error("no credentials. Run /login litellm or set env vars.");
275
+ }
276
+ const result = await discoverModels(fresh.baseUrl, fresh.apiKey, { timeoutMs: getDiscoveryTimeoutMs() });
277
+ const now = Date.now();
278
+ await writeCache(getCachePath(), {
279
+ baseUrl: fresh.baseUrl,
280
+ apiKeyFingerprint: freshFp,
281
+ fetchedAt: now,
282
+ source: result.source,
283
+ models: result.models,
284
+ });
285
+ registerProvider(fresh.baseUrl, result.models);
286
+ updateCosts(result.models);
287
+ cacheFetchedAt = now;
288
+ return { models: result.models, source: result.source };
289
+ }
290
+ function runRefresh() {
291
+ refreshInProgress ??= refreshModelsAndCosts().finally(() => {
292
+ refreshInProgress = null;
293
+ });
294
+ return refreshInProgress;
295
+ }
170
296
  pi.registerCommand("litellm-refresh", {
171
297
  description: "Re-discover models from the LiteLLM proxy.",
172
298
  handler: async (_args, ctx) => {
173
- if (isOffline()) {
174
- ctx.ui.notify(`LiteLLM refresh disabled (${ENV_OFFLINE}=1)`, "warning");
175
- return;
176
- }
177
- const timeoutMs = getDiscoveryTimeoutMs();
178
- if (timeoutMs === 0) {
179
- ctx.ui.notify(`LiteLLM refresh disabled (${ENV_TIMEOUT}=0)`, "warning");
180
- return;
181
- }
182
- const fresh = await resolveCredentials();
183
- const freshFp = fresh.apiKey ? fingerprint(fresh.apiKey) : undefined;
184
- if (!fresh.baseUrl || !fresh.apiKey || !freshFp) {
185
- ctx.ui.notify("LiteLLM refresh failed: no credentials. Run /login litellm or set env vars.", "error");
299
+ const disabledReason = discoveryDisabledReason();
300
+ if (disabledReason) {
301
+ ctx.ui.notify(`LiteLLM refresh disabled (${disabledReason})`, "warning");
186
302
  return;
187
303
  }
188
304
  try {
189
- const result = await discoverModels(fresh.baseUrl, fresh.apiKey, { timeoutMs });
190
- await writeCache(getCachePath(), {
191
- baseUrl: fresh.baseUrl,
192
- apiKeyFingerprint: freshFp,
193
- fetchedAt: Date.now(),
194
- source: result.source,
195
- models: result.models,
196
- });
197
- pi.registerProvider(PROVIDER_NAME, {
198
- baseUrl: `${fresh.baseUrl}/v1`,
199
- apiKey: ENV_API_KEY,
200
- api: "openai-completions",
201
- models: result.models,
202
- oauth,
203
- });
305
+ const result = await runRefresh();
204
306
  ctx.ui.notify(`LiteLLM: ${result.models.length} models refreshed (source: ${result.source})`, "info");
205
307
  }
206
308
  catch (error) {
@@ -209,19 +311,26 @@ export default async function (pi) {
209
311
  }
210
312
  },
211
313
  });
212
- setupLiteLLMCostTracking(pi);
213
314
  let sessionId;
214
- pi.on("session_start", async (_event, ctx) => {
315
+ pi.on("session_start", (_event, ctx) => {
215
316
  sessionId = getSessionIdFromFile(ctx.sessionManager.getSessionFile());
317
+ if (discoveryDisabledReason() || !cacheFetchedAt || Date.now() - cacheFetchedAt <= CACHE_STALE_MS)
318
+ return;
319
+ void runRefresh().catch(() => undefined);
216
320
  });
217
- pi.on("before_provider_request", (event) => {
218
- if (!sessionId)
321
+ pi.on("before_provider_request", (event, ctx) => {
322
+ if (ctx.model?.provider !== PROVIDER_NAME)
219
323
  return;
220
324
  if (typeof event.payload !== "object" || event.payload === null)
221
325
  return;
222
- return {
223
- ...event.payload,
224
- litellm_session_id: sessionId,
225
- };
326
+ return prepareLiteLLMRequestPayload(event.payload, ctx.model?.id, sessionId);
327
+ });
328
+ pi.on("message_end", (event) => {
329
+ if (event.message.role !== "assistant")
330
+ return;
331
+ const message = normalizeThinkTags(event.message);
332
+ if (!message)
333
+ return;
334
+ return { message };
226
335
  });
227
336
  }
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[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-provider-litellm",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
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": "^0.75.4",
59
+ "@earendil-works/pi-coding-agent": "^0.75.4"
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.75.4",
68
+ "@earendil-works/pi-coding-agent": "^0.75.4",
69
+ "@biomejs/biome": "^2.4.15",
70
+ "@types/node": "^25.9.1",
71
+ "@typescript/native-preview": "7.0.0-dev.20260519.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
  }