pi-provider-litellm 4.2.0 → 4.3.1

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.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Reads Pi's persisted catalog, so that a start does not re-prove what Pi already kept.
3
+ *
4
+ * The factory discovers before it returns because Pi's startup refresh runs with `allowNetwork:false` and returns before the
5
+ * network phase, so a provider that waits for Pi has no models when the session picks one (issue #137). That holds on a first
6
+ * run. It does not hold once Pi has persisted the catalog: with discovery switched off and a populated `models-store.json`,
7
+ * Pi offers the stored models and a request works, measured at a 5 ms factory against 1.8 s to discover.
8
+ *
9
+ * So the seed reads the stored catalog and hands those models over, instead of discovering them again. It only reads, and
10
+ * only `models-store.json`, which this repository's AGENTS.md assigns to Pi. Nothing here writes a model cache; the removed
11
+ * `litellm-models.json` is what that rule is about.
12
+ *
13
+ * The models are returned rather than an empty list on purpose. Pi restores its own catalog, so an empty list would do in
14
+ * the ordinary case. It would not do when Pi's active store is somewhere else: `ModelRuntime` takes a `modelsStorePath`, and
15
+ * resolves the default beside whatever `modelsPath` it was given. Every caller inside Pi passes an agent-directory path, but
16
+ * an SDK embedder need not. A catalog left in the default file would then satisfy this check while Pi's active store was
17
+ * empty, and the provider would have no models at all -- the very fault the seed exists to prevent. Returning the models
18
+ * costs nothing and cannot fail that way.
19
+ *
20
+ * Four things make a stored entry unusable, and all are checked here rather than left to Pi:
21
+ *
22
+ * - Age. Only a network refresh writes the store, and `pi -p` never runs one, so without a limit print mode would keep
23
+ * whatever catalog an interactive session last left. Pi's own remote catalogs skip the network for 4 hours after
24
+ * `checkedAt`, and so does this.
25
+ * - A different proxy. Stored models carry the `baseUrl` they were discovered from, so a changed root must still discover.
26
+ * - An older `litellmDiscoveryVersion`. Pi keeps a same-version stored entry over what startup discovery just proved
27
+ * (AGENTS.md), so skipping discovery for an older entry would hide the metadata a new version adds.
28
+ * - An entry Pi cannot restore. Pi's no-network restore publishes the stored models over the seed, so the seed cannot keep
29
+ * a short entry out of a session; it decides the catalog only when that restore fails. The restore runs
30
+ * `enrichCachedModel` (provider.ts), and when that throws Pi restores nothing, so the seed makes the same call and
31
+ * discovers instead.
32
+ *
33
+ * What is deliberately not checked is the credential. Nothing in Pi's store records which key discovered it, and a key scopes
34
+ * which models a LiteLLM proxy shows. Within those 4 hours, a key swapped on the same proxy, or a model added to it, therefore
35
+ * shows the stored catalog until Pi's own network refresh runs: interactive and RPC sessions run one in the background,
36
+ * `pi -p` does not. Recording a key fingerprint would mean writing a file of our own, which is the thing the rule forbids.
37
+ */
38
+
39
+ import { readFile } from "node:fs/promises";
40
+ import { join } from "node:path";
41
+ import { LITELLM_DISCOVERY_VERSION } from "./backend-identity.js";
42
+ import { enrichCachedModel } from "./discover.js";
43
+ import { LITELLM_IMAGES_API, type LiteLLMImageModel } from "./images.js";
44
+ import { isLiteLLMApi, resolveModelBaseUrl } from "./protocols.js";
45
+ import { imageBaseUrl } from "./provider.js";
46
+ import type { LiteLLMModel } from "./types.js";
47
+
48
+ /**
49
+ * Where Pi keeps it. `FileModelsStore` defaults to `<agentDir>/models-store.json`, and `ModelRuntime` resolves it beside
50
+ * whatever `modelsPath` it was given -- `modelsPath` itself is the model *config* (`models.json`), a different file with a
51
+ * different shape, which is deliberately not read here.
52
+ */
53
+ const STORE_FILENAME = "models-store.json";
54
+
55
+ function isRecord(value: unknown): value is Record<string, unknown> {
56
+ return typeof value === "object" && value !== null && !Array.isArray(value);
57
+ }
58
+
59
+ /** Pi's own remote catalogs skip the network for 4 hours after `checkedAt` (`REMOTE_CATALOG_REFRESH_INTERVAL_MS`). */
60
+ const STORE_FRESH_MS = 4 * 60 * 60 * 1000;
61
+
62
+ function isImageModel(value: Record<string, unknown>): boolean {
63
+ return value.type === "image" && value.api === LITELLM_IMAGES_API;
64
+ }
65
+
66
+ function isUsableModel(value: unknown, provider: string, baseUrl: string, allowInsecureHttp?: boolean): boolean {
67
+ if (!isRecord(value) || value.provider !== provider) return false;
68
+ if (value.litellmDiscoveryVersion !== LITELLM_DISCOVERY_VERSION) return false;
69
+ try {
70
+ // A stored baseUrl is per protocol -- `/v1` for the OpenAI APIs and images, the bare root for Messages -- so the root
71
+ // goes through the same resolution rather than being compared to it.
72
+ if (isImageModel(value)) return value.baseUrl === imageBaseUrl(baseUrl, allowInsecureHttp);
73
+ // `api` indexes LITELLM_PROTOCOLS in resolveModelBaseUrl, so an unknown one is a TypeError, not a bad model.
74
+ return isLiteLLMApi(value.api) && value.baseUrl === resolveModelBaseUrl(baseUrl, value.api, allowInsecureHttp);
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ async function readStore(agentDir: string): Promise<Record<string, unknown> | undefined> {
81
+ try {
82
+ const parsed: unknown = JSON.parse(await readFile(join(agentDir, STORE_FILENAME), "utf8"));
83
+ return isRecord(parsed) ? parsed : undefined;
84
+ } catch {
85
+ // Absent, unreadable, or not JSON: discover instead.
86
+ return undefined;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Pi's stored models for `provider`, when every one of them can be used as it is, and `undefined` otherwise. Never throws;
92
+ * anything it cannot read means "discover".
93
+ */
94
+ export async function readStoredModels(options: {
95
+ agentDir: string;
96
+ provider: string;
97
+ baseUrl: string;
98
+ allowInsecureHttp?: boolean;
99
+ }): Promise<(LiteLLMModel | LiteLLMImageModel)[] | undefined> {
100
+ const entry = (await readStore(options.agentDir))?.[options.provider];
101
+ if (!isRecord(entry) || !Array.isArray(entry.models) || entry.models.length === 0) return undefined;
102
+ if (typeof entry.checkedAt !== "number" || Date.now() - entry.checkedAt >= STORE_FRESH_MS) return undefined;
103
+ const usable = entry.models.every((model) =>
104
+ isUsableModel(model, options.provider, options.baseUrl, options.allowInsecureHttp),
105
+ );
106
+ if (!usable) return undefined;
107
+ try {
108
+ // Pi's restore path (provider.ts) runs the same call; when it throws, nothing is restored and the seed is the catalog.
109
+ // Image models carry no request policy, so that path restores them as stored.
110
+ return entry.models.map((model) => (isImageModel(model) ? model : enrichCachedModel(model))) as (
111
+ | LiteLLMModel
112
+ | LiteLLMImageModel
113
+ )[];
114
+ } catch {
115
+ return undefined;
116
+ }
117
+ }
package/src/provider.ts CHANGED
@@ -2,9 +2,14 @@ import {
2
2
  type AnyModel,
3
3
  type Api,
4
4
  type ApiStreamOptions,
5
+ type AssistantImages,
5
6
  type Credential,
6
7
  createProvider,
7
8
  getCurrentTools,
9
+ type ImageApi,
10
+ type ImageModel,
11
+ type ImagesContext,
12
+ type ImagesOptions,
8
13
  type Model,
9
14
  type Provider,
10
15
  type ProviderAuth,
@@ -13,6 +18,12 @@ import {
13
18
  } from "@earendil-works/pi-ai";
14
19
  import { LITELLM_DISCOVERY_VERSION } from "./backend-identity.js";
15
20
  import { enrichCachedModel, normalizeBaseUrl, restoreCachedModelPolicy } from "./discover.js";
21
+ import {
22
+ createLiteLLMImageApis,
23
+ type DiscoveredImageModel,
24
+ LITELLM_IMAGES_API,
25
+ type LiteLLMImageModel,
26
+ } from "./images.js";
16
27
  import { createLiteLLMProtocolApis, isLiteLLMApi, LITELLM_API_NAMES, resolveModelBaseUrl } from "./protocols.js";
17
28
  import type { DiscoveredModel, DiscoveryResult, LiteLLMApi, LiteLLMModel } from "./types.js";
18
29
 
@@ -23,7 +34,7 @@ export type LiteLLMProviderOptions = {
23
34
  auth: ProviderAuth;
24
35
  /** Catalog discovered during activation. Pi's startup refresh never allows network access, so
25
36
  * without a seed the provider stays empty until the user opens /model. */
26
- models?: readonly LiteLLMModel[];
37
+ models?: readonly (LiteLLMModel | LiteLLMImageModel)[];
27
38
  allowInsecureHttp?: boolean;
28
39
  resolveCredentialRoot: (credential?: Credential, requestBaseUrl?: string, apiKey?: string) => string | undefined;
29
40
  discover(credential: Credential, signal?: AbortSignal): Promise<DiscoveryResult & { baseUrl?: string }>;
@@ -34,6 +45,23 @@ function isChatModel(model: AnyModel): model is Model<Api> {
34
45
  return (model.type ?? "chat") === "chat";
35
46
  }
36
47
 
48
+ function isLiteLLMImageModel(model: AnyModel): model is LiteLLMImageModel {
49
+ return model.type === "image" && model.api === LITELLM_IMAGES_API;
50
+ }
51
+
52
+ export function imageBaseUrl(root: string, allowInsecureHttp = false): string {
53
+ return `${normalizeBaseUrl(root, allowInsecureHttp)}/v1`;
54
+ }
55
+
56
+ export function toNativeImageModels(
57
+ provider: string,
58
+ baseUrl: string,
59
+ models: readonly DiscoveredImageModel[],
60
+ allowInsecureHttp = false,
61
+ ): LiteLLMImageModel[] {
62
+ return models.map((model) => ({ ...model, provider, baseUrl: imageBaseUrl(baseUrl, allowInsecureHttp) }));
63
+ }
64
+
37
65
  export function toNativeModels(
38
66
  provider: string,
39
67
  baseUrl: string,
@@ -117,6 +145,27 @@ function modelRootError(
117
145
  }
118
146
  }
119
147
 
148
+ function imageRootError(
149
+ model: ImageModel<ImageApi>,
150
+ active: { root: string; canonical: string; hostname: string },
151
+ allowInsecureHttp = false,
152
+ ): Error | undefined {
153
+ let stored: { root: string; canonical: string; hostname: string };
154
+ try {
155
+ stored = proxyRoot(model.baseUrl, "Cached image model", allowInsecureHttp);
156
+ } catch (error) {
157
+ return error instanceof Error ? error : refreshRequired("Cached image model has an invalid LiteLLM model URL");
158
+ }
159
+ if (isPlaceholderHost(stored.hostname)) {
160
+ return refreshRequired("Cached image model uses a placeholder LiteLLM model host");
161
+ }
162
+ if (stored.canonical !== active.canonical) {
163
+ return refreshRequired(
164
+ `Cached image model has stale LiteLLM model root ${stored.root}; active credentials use ${active.root}`,
165
+ );
166
+ }
167
+ }
168
+
120
169
  function requestModel(
121
170
  provider: string,
122
171
  model: Model<LiteLLMApi>,
@@ -148,6 +197,40 @@ export function createLiteLLMProvider(options: LiteLLMProviderOptions): Provider
148
197
  reportedAvailabilityDiagnostics.add(message);
149
198
  process.stderr.write(`LiteLLM (${options.id}): ${message}\n`);
150
199
  };
200
+ type ActiveRoot = { root: string; canonical: string; hostname: string };
201
+ const resolveActiveRoot = (credential: Credential | undefined, hiddenCount: number): ActiveRoot | undefined => {
202
+ try {
203
+ const root = options.resolveCredentialRoot(credential);
204
+ if (!root) {
205
+ if (hiddenCount > 0) {
206
+ reportUnavailable(
207
+ `${hiddenCount} model(s) hidden because no LiteLLM base URL is configured; ` +
208
+ "set LITELLM_BASE_URL or run /login litellm",
209
+ );
210
+ }
211
+ return undefined;
212
+ }
213
+ return activeCredentialRoot(root, options.allowInsecureHttp);
214
+ } catch (error) {
215
+ reportUnavailable(error instanceof Error ? error.message : String(error));
216
+ return undefined;
217
+ }
218
+ };
219
+ const availableChat = (models: readonly Model<LiteLLMApi>[], active: ActiveRoot): Model<LiteLLMApi>[] => {
220
+ const available: Model<LiteLLMApi>[] = [];
221
+ for (const model of models) {
222
+ const error = modelRootError(model, active, options.allowInsecureHttp);
223
+ if (error) {
224
+ reportUnavailable(error.message);
225
+ continue;
226
+ }
227
+ available.push({
228
+ ...model,
229
+ baseUrl: resolveModelBaseUrl(active.root, model.api, options.allowInsecureHttp),
230
+ });
231
+ }
232
+ return available;
233
+ };
151
234
  const provider = createProvider<LiteLLMApi>({
152
235
  id: options.id,
153
236
  name: options.name,
@@ -157,41 +240,34 @@ export function createLiteLLMProvider(options: LiteLLMProviderOptions): Provider
157
240
  async fetchModels(context) {
158
241
  if (!context.credential) throw new Error("LiteLLM model discovery requires a credential");
159
242
  const result = await options.discover(context.credential, context.signal);
160
- return toNativeModels(options.id, result.baseUrl ?? options.baseUrl, result.models, options.allowInsecureHttp);
243
+ const root = result.baseUrl ?? options.baseUrl;
244
+ return [
245
+ ...toNativeModels(options.id, root, result.models, options.allowInsecureHttp),
246
+ ...toNativeImageModels(options.id, root, result.imageModels ?? [], options.allowInsecureHttp),
247
+ ];
161
248
  },
162
249
  filterModels(models, credential) {
163
- let active: { root: string; canonical: string; hostname: string };
164
- try {
165
- const root = options.resolveCredentialRoot(credential);
166
- if (!root) {
167
- if (models.length > 0) {
168
- reportUnavailable(
169
- `${models.length} model(s) hidden because no LiteLLM base URL is configured; ` +
170
- "set LITELLM_BASE_URL or run /login litellm",
171
- );
172
- }
173
- return [];
174
- }
175
- active = activeCredentialRoot(root, options.allowInsecureHttp);
176
- } catch (error) {
177
- reportUnavailable(error instanceof Error ? error.message : String(error));
178
- return [];
179
- }
180
- const available: Model<LiteLLMApi>[] = [];
181
- for (const model of models) {
182
- const error = modelRootError(model, active, options.allowInsecureHttp);
250
+ const active = resolveActiveRoot(credential, models.length);
251
+ return active ? availableChat(models, active) : [];
252
+ },
253
+ filterAllModels(models, credential) {
254
+ const active = resolveActiveRoot(credential, models.length);
255
+ if (!active) return [];
256
+ const chat = availableChat(models.filter(isChatModel) as Model<LiteLLMApi>[], active);
257
+ // Image models follow the same rule as chat: a cached root that no longer matches the
258
+ // active credential is hidden until a network refresh proves the new proxy's catalog.
259
+ const images = models.filter(isLiteLLMImageModel).flatMap((model) => {
260
+ const error = imageRootError(model, active, options.allowInsecureHttp);
183
261
  if (error) {
184
262
  reportUnavailable(error.message);
185
- continue;
263
+ return [];
186
264
  }
187
- available.push({
188
- ...model,
189
- baseUrl: resolveModelBaseUrl(active.root, model.api, options.allowInsecureHttp),
190
- });
191
- }
192
- return available;
265
+ return [{ ...model, baseUrl: imageBaseUrl(active.root, options.allowInsecureHttp) }];
266
+ });
267
+ return [...chat, ...images];
193
268
  },
194
269
  api: createLiteLLMProtocolApis(),
270
+ images: createLiteLLMImageApis(),
195
271
  });
196
272
  const refreshModels = provider.refreshModels;
197
273
  const guardedProvider: Provider<LiteLLMApi> = {
@@ -220,6 +296,38 @@ export function createLiteLLMProvider(options: LiteLLMProviderOptions): Provider
220
296
  context,
221
297
  requestOptions,
222
298
  ),
299
+ // Same request-time guard as chat: the credential's root, never a cached model URL, picks the
300
+ // host, so an off-catalog or stale entry cannot send this credential to another proxy.
301
+ generateImages: async (model: ImageModel<ImageApi>, context: ImagesContext, requestOptions?: ImagesOptions) => {
302
+ try {
303
+ const root = options.resolveCredentialRoot(
304
+ undefined,
305
+ requestOptions?.env?.LITELLM_BASE_URL,
306
+ requestOptions?.apiKey,
307
+ );
308
+ if (!root) throw refreshRequired("Active credentials do not identify a LiteLLM model host");
309
+ const active = activeCredentialRoot(root, options.allowInsecureHttp);
310
+ const error = imageRootError(model, active, options.allowInsecureHttp);
311
+ if (error) throw error;
312
+ return await provider.generateImages!(
313
+ { ...model, provider: options.id, baseUrl: imageBaseUrl(active.root, options.allowInsecureHttp) },
314
+ context,
315
+ requestOptions,
316
+ );
317
+ } catch (error) {
318
+ // `Provider.generateImages` never rejects.
319
+ const failed: AssistantImages = {
320
+ api: model.api,
321
+ provider: model.provider,
322
+ model: model.id,
323
+ output: [],
324
+ stopReason: requestOptions?.signal?.aborted ? "aborted" : "error",
325
+ errorMessage: error instanceof Error ? error.message : String(error),
326
+ timestamp: Date.now(),
327
+ };
328
+ return failed;
329
+ }
330
+ },
223
331
  };
224
332
  if (!refreshModels) return guardedProvider;
225
333
  const seedKey = (model: { id: string; baseUrl: string }) => `${model.id}\0${model.baseUrl}`;
@@ -227,16 +335,23 @@ export function createLiteLLMProvider(options: LiteLLMProviderOptions): Provider
227
335
  return {
228
336
  ...guardedProvider,
229
337
  refreshModels: async (context) => {
230
- // Pi 0.99 persists models of every type; this provider only publishes chat models, and the
231
- // policy helpers below read chat-only fields.
338
+ // Pi 0.99 persists models of every type; the policy helpers below read chat-only fields, so
339
+ // chat and image entries are restored separately. Other types are not this provider's.
232
340
  const storedModels = (context.stored?.models ?? []).filter(isChatModel);
341
+ const storedImageModels = (context.stored?.models ?? []).filter(isLiteLLMImageModel).flatMap((model) => {
342
+ if (model.litellmDiscoveryVersion === LITELLM_DISCOVERY_VERSION) return [model];
343
+ // An image entry carries no request policy to restore; a stale one is only kept when
344
+ // startup discovery proved the same route, otherwise the network refresh replaces it.
345
+ const fresh = seeded.get(seedKey(model));
346
+ return fresh && isLiteLLMImageModel(fresh) ? [fresh] : [];
347
+ });
233
348
  let legacyCount = 0;
234
349
  const models = storedModels.map((model) => {
235
350
  if ((model as LiteLLMModel).litellmDiscoveryVersion !== LITELLM_DISCOVERY_VERSION) {
236
351
  // Pi applies stored models over the seed, so a stale entry would otherwise mask what
237
352
  // startup discovery just proved for the same route.
238
353
  const fresh = seeded.get(seedKey(model));
239
- if (fresh) return fresh;
354
+ if (fresh && isChatModel(fresh)) return fresh;
240
355
  legacyCount++;
241
356
  return restoreCachedModelPolicy(model);
242
357
  }
@@ -247,7 +362,7 @@ export function createLiteLLMProvider(options: LiteLLMProviderOptions): Provider
247
362
  ...context,
248
363
  // The current pi-ai provider has no freshness gate, but force is the documented refresh contract field.
249
364
  force: context.force || (context.allowNetwork && legacyCount > 0),
250
- stored: context.stored && { ...context.stored, models },
365
+ stored: context.stored && { ...context.stored, models: [...models, ...storedImageModels] },
251
366
  });
252
367
  } catch (error) {
253
368
  if (context.allowNetwork && legacyCount > 0) {
package/src/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Model } from "@earendil-works/pi-ai";
2
2
  import type { BackendFamily, LITELLM_DISCOVERY_VERSION } from "./backend-identity.js";
3
+ import type { DiscoveredImageModel } from "./images.js";
3
4
  import type { ProxyVersion } from "./proxy-version.js";
4
5
 
5
6
  export type DiscoverySource = "model_info" | "models_list" | "health";
@@ -64,6 +65,8 @@ export type LiteLLMModel = Model<LiteLLMApi> & {
64
65
 
65
66
  export interface DiscoveryResult {
66
67
  models: DiscoveredModel[];
68
+ /** `mode: "image_generation"` routes from `/model/info`; absent when there are none. */
69
+ imageModels?: DiscoveredImageModel[];
67
70
  source: DiscoverySource;
68
71
  // Present only when a `/model/info` discovery had to read it and the proxy
69
72
  // answered. Absent means unknown, not old: discovery asks only when it must.
@@ -98,6 +101,7 @@ export interface ModelInfoEntry {
98
101
  supported_openai_params?: string[];
99
102
  input_cost_per_token?: number;
100
103
  output_cost_per_token?: number;
104
+ output_cost_per_image_token?: number;
101
105
  cache_read_input_token_cost?: number;
102
106
  cache_creation_input_token_cost?: number;
103
107
  max_input_tokens?: number;