pi-provider-litellm 2.2.1 → 2.3.0

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
@@ -44,12 +44,14 @@ To configure an API key, run `/login`, choose `Sign in with an API key`, then ch
44
44
 
45
45
  You'll be prompted for the base URL and API key. Credentials are persisted to `~/.pi/agent/auth.json`.
46
46
 
47
+ If a proxy URL is already known — from `providers.<name>.baseUrl`, `LITELLM_BASE_URL`, or a previous login — pi offers it as the first option instead of asking you to retype it. Pick `Enter a different URL…` to point at another proxy.
48
+
47
49
  #### Enterprise SSO login
48
50
 
49
51
  If your LiteLLM proxy requires SSO/OAuth authentication (enterprise deployments), you can authenticate through LiteLLM's CLI SSO flow:
50
52
 
51
53
  1. Run `/login litellm` inside pi and select `Sign in with LiteLLM SSO`
52
- 2. Enter the proxy URL
54
+ 2. Confirm the offered proxy URL, or enter one if this is the first login
53
55
  3. Pi displays a verification code and opens the LiteLLM SSO login URL automatically
54
56
  4. Authenticate in the browser and confirm the displayed code
55
57
  5. If your account belongs to multiple teams, select the team for this session in Pi
@@ -236,6 +238,7 @@ Opening `/model` refreshes configured provider catalogs in the background using
236
238
  | Symptom | Likely cause |
237
239
  |---|---|
238
240
  | "no credentials" warning at startup | Env vars not set and no OAuth credential — run `/login litellm` |
241
+ | `No models available` at startup, gone after a restart | A Pi startup race, not discovery — see [`No models available` at startup](#no-models-available-at-startup) |
239
242
  | "discovered no models" | Proxy returned an empty list — check pi's startup log and verify `/model/info`, `/v1/models`, or `/health` responds |
240
243
  | `/model/info` returning 401/403/404 | Expected behavior with virtual keys — extension falls back to `/v1/models` |
241
244
  | Discovery times out | Increase `LITELLM_DISCOVERY_TIMEOUT_MS` or set `LITELLM_OFFLINE=1` to fall back on cached models |
@@ -248,6 +251,27 @@ Opening `/model` refreshes configured provider catalogs in the background using
248
251
  | MCP tools not showing | Verify the proxy exposes `/mcp-rest/tools/list` and open `/model` after fixing the proxy |
249
252
  | Skills not affecting prompts | Verify the proxy exposes `/claude-code/marketplace.json` or `/v1/skills` and returns enabled skills |
250
253
 
254
+ ### `No models available` at startup
255
+
256
+ Pi 0.84.0 and later can finish startup before the provider availability snapshot is written, so the
257
+ initial model pick sees nothing even though discovery succeeded. The warning is intermittent and a
258
+ restart usually clears it. Nothing is wrong with the catalog: `/model` in the same session still
259
+ lists every discovered model, and picking one there fixes that session.
260
+
261
+ Scoping models keeps selection off that path, because Pi resolves the scope with its own awaited
262
+ availability pass before it picks a model. In `~/.pi/agent/settings.json`:
263
+
264
+ ```json
265
+ {
266
+ "enabledModels": ["litellm/*"]
267
+ }
268
+ ```
269
+
270
+ Your `defaultProvider` and `defaultModel` still win as long as they match a pattern. `/model` then
271
+ opens on the scoped list, with a toggle inside the picker for the full catalog.
272
+
273
+ The extension cannot set this for you — Pi reads `enabledModels` before extensions activate.
274
+
251
275
  ## License
252
276
 
253
277
  MIT — see [LICENSE](./LICENSE).
@@ -3,7 +3,7 @@ import type { DiscoveredModel, DiscoveryOptions, DiscoveryResult } from "./types
3
3
  export declare function normalizeBaseUrl(input: string, allowInsecureHttp?: boolean): string;
4
4
  export declare function isMoonshotModel(modelId: string): boolean;
5
5
  export declare function isGpt55Model(modelId: string): boolean;
6
- export declare function shouldSuppressReasoningContent(modelId: string): boolean;
6
+ export declare function emitsThinkTags(modelId: string): boolean;
7
7
  export declare function buildCompat(modelId: string): DiscoveredModel["compat"];
8
8
  export declare function enrichCachedModel(model: Model<Api>): Model<Api>;
9
9
  export declare function discoverModels(baseUrl: string, apiKey: string, options?: DiscoveryOptions & {
package/dist/discover.js CHANGED
@@ -38,7 +38,36 @@ export function isMoonshotModel(modelId) {
38
38
  export function isGpt55Model(modelId) {
39
39
  return GPT55_MODEL_PATTERN.test(modelId);
40
40
  }
41
- export function shouldSuppressReasoningContent(modelId) {
41
+ const MOONSHOT_ROUTE_PROVIDERS = new Set(["moonshot", "moonshotai"]);
42
+ function isMoonshotRoute(entry) {
43
+ const params = entry.litellm_params;
44
+ if (!params)
45
+ return false;
46
+ const model = params.model?.trim();
47
+ if (!model)
48
+ return false;
49
+ const providers = [
50
+ params.custom_llm_provider?.trim(),
51
+ model.includes("/") ? model.split("/", 1)[0] : undefined,
52
+ ].filter((provider) => Boolean(provider));
53
+ return providers.length > 0 && providers.every((provider) => MOONSHOT_ROUTE_PROVIDERS.has(provider.toLowerCase()));
54
+ }
55
+ function shouldSuppressReasoningContent(modelId, entry) {
56
+ const routeModelId = entry.litellm_params?.model;
57
+ return (isMoonshotRoute(entry) &&
58
+ !FORCED_THINKING_MODEL_PATTERN.test(modelId) &&
59
+ !(routeModelId && FORCED_THINKING_MODEL_PATTERN.test(routeModelId)));
60
+ }
61
+ function aggregateSuppressionEvidence(evidence) {
62
+ let hasEvidence = false;
63
+ for (const suppress of evidence) {
64
+ hasEvidence = true;
65
+ if (!suppress)
66
+ return false;
67
+ }
68
+ return hasEvidence;
69
+ }
70
+ export function emitsThinkTags(modelId) {
42
71
  return isMoonshotModel(modelId) && !FORCED_THINKING_MODEL_PATTERN.test(modelId);
43
72
  }
44
73
  export function buildCompat(modelId) {
@@ -172,7 +201,7 @@ function mapReasoningEfforts(info) {
172
201
  .map(([level, value, supported]) => [level, supported ? value : null]));
173
202
  return Object.keys(map).length > 0 ? map : undefined;
174
203
  }
175
- function mapFromModelInfo(entry) {
204
+ function mapFromModelInfo(entry, suppressReasoningContent = shouldSuppressReasoningContent(entry.model_name ?? "", entry)) {
176
205
  const id = entry.model_name;
177
206
  if (!id)
178
207
  return undefined;
@@ -195,6 +224,7 @@ function mapFromModelInfo(entry) {
195
224
  contextWindow: info.max_input_tokens ?? DEFAULT_CONTEXT_WINDOW,
196
225
  maxTokens: info.max_output_tokens ?? DEFAULT_MAX_TOKENS,
197
226
  compat: buildCompat(id),
227
+ ...(suppressReasoningContent ? { suppressReasoningContent: true } : {}),
198
228
  ...(responsesMode ? { api: "openai-responses" } : {}),
199
229
  };
200
230
  }
@@ -266,12 +296,23 @@ async function discoverFromHealth(base, apiKey, options) {
266
296
  return models.filter((model) => model !== undefined);
267
297
  }
268
298
  function deduplicateModels(models) {
269
- const seen = new Set();
270
- return models.filter((m) => {
271
- if (seen.has(m.id))
272
- return false;
273
- seen.add(m.id);
274
- return true;
299
+ const entries = new Map();
300
+ for (const model of models) {
301
+ const existing = entries.get(model.id);
302
+ if (existing) {
303
+ existing.suppressions.push(model.suppressReasoningContent === true);
304
+ }
305
+ else {
306
+ entries.set(model.id, { model, suppressions: [model.suppressReasoningContent === true] });
307
+ }
308
+ }
309
+ return [...entries.values()].map(({ model, suppressions }) => {
310
+ const deduplicated = { ...model };
311
+ if (aggregateSuppressionEvidence(suppressions))
312
+ deduplicated.suppressReasoningContent = true;
313
+ else
314
+ delete deduplicated.suppressReasoningContent;
315
+ return deduplicated;
275
316
  });
276
317
  }
277
318
  export async function discoverModels(baseUrl, apiKey, options = {}) {
@@ -281,17 +322,23 @@ export async function discoverModels(baseUrl, apiKey, options = {}) {
281
322
  const infoResult = await fetchJson(`${base}/model/info`, apiKey, options);
282
323
  if (infoResult.ok) {
283
324
  const entries = new Map();
325
+ const suppressionEvidence = new Map();
284
326
  for (const entry of infoResult.data.data ?? []) {
285
327
  if (!entry.model_name)
286
328
  continue;
287
329
  const previous = entries.get(entry.model_name);
330
+ const suppressions = suppressionEvidence.get(entry.model_name) ?? new Set();
331
+ suppressions.add(shouldSuppressReasoningContent(entry.model_name, entry));
332
+ suppressionEvidence.set(entry.model_name, suppressions);
288
333
  entries.set(entry.model_name, {
289
334
  ...previous,
290
335
  ...entry,
291
336
  model_info: { ...previous?.model_info, ...entry.model_info },
292
337
  });
293
338
  }
294
- let models = [...entries.values()].map(mapFromModelInfo).filter((m) => m !== undefined);
339
+ let models = [...entries.entries()]
340
+ .map(([id, entry]) => mapFromModelInfo(entry, aggregateSuppressionEvidence(suppressionEvidence.get(id))))
341
+ .filter((m) => m !== undefined);
295
342
  // LiteLLM's /model/info does NOT expand wildcard model_name entries (e.g.
296
343
  // "lemonade/*" backed by model: openai/* + check_provider_endpoint: true)
297
344
  // — it returns the literal wildcard only. The discovered ids live in
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { join } from "node:path";
4
4
  import { setTimeout as delay } from "node:timers/promises";
5
5
  import { getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
6
6
  import { setupLiteLLMCostTracking } from "./cost.js";
7
- import { discoverModels, isGpt55Model, isMoonshotModel, normalizeBaseUrl, shouldSuppressReasoningContent, } from "./discover.js";
7
+ import { discoverModels, emitsThinkTags, isGpt55Model, isMoonshotModel, normalizeBaseUrl } from "./discover.js";
8
8
  import { getGcloudToken, getGcloudTokenCacheKey, getGcloudTokenCommand, isGcloudTokenAuthEnabled, } from "./gcloud-token.js";
9
9
  import { getSessionIdFromFile } from "./litellm.js";
10
10
  import { createMcpToolDefinitions } from "./mcp-tools.js";
@@ -406,7 +406,51 @@ function getProviderDefinitions(settings) {
406
406
  }
407
407
  return definitions;
408
408
  }
409
- async function loginApiKey(interaction, allowInsecureHttp = false) {
409
+ const DIFFERENT_BASE_URL = "enter-a-different-url";
410
+ /**
411
+ * The proxy URL is the one thing a user has to retype on every re-login, and an expired SSO token is
412
+ * the common reason to re-login. Offer whatever we already know: settings, env, or the credential the
413
+ * dead session was issued against.
414
+ */
415
+ function knownBaseUrl(definition) {
416
+ const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
417
+ const candidates = [
418
+ [cleanConfig(definition.baseUrl), "configured"],
419
+ [definition.useDefaultEnv ? cleanConfig(process.env[ENV_BASE_URL]) : undefined, `$${ENV_BASE_URL}`],
420
+ [
421
+ stored?.type === "oauth"
422
+ ? cleanConfig(typeof stored.baseUrl === "string" ? stored.baseUrl : undefined)
423
+ : cleanConfig(stored?.env?.[ENV_BASE_URL]),
424
+ "previous login",
425
+ ],
426
+ ];
427
+ for (const [candidate, source] of candidates) {
428
+ if (!candidate)
429
+ continue;
430
+ try {
431
+ return { url: normalizeBaseUrl(candidate, definition.allowInsecureHttp), source };
432
+ }
433
+ catch {
434
+ // A URL we could not use anyway is not worth offering; try the next candidate.
435
+ }
436
+ }
437
+ return undefined;
438
+ }
439
+ async function promptBaseUrl(interaction, definition) {
440
+ const known = knownBaseUrl(definition);
441
+ if (known) {
442
+ const choice = await interaction.prompt({
443
+ type: "select",
444
+ message: "LiteLLM proxy URL:",
445
+ // Pi's login selector renders labels only, so the source has to ride along in the label.
446
+ options: [
447
+ { id: known.url, label: `${known.url} (${known.source})` },
448
+ { id: DIFFERENT_BASE_URL, label: "Enter a different URL…" },
449
+ ],
450
+ });
451
+ if (choice === known.url)
452
+ return known.url;
453
+ }
410
454
  const rawBaseUrl = (await interaction.prompt({
411
455
  type: "text",
412
456
  message: "Enter LiteLLM proxy URL (no trailing /v1):",
@@ -414,10 +458,14 @@ async function loginApiKey(interaction, allowInsecureHttp = false) {
414
458
  })).trim();
415
459
  if (!rawBaseUrl)
416
460
  throw new Error("Base URL is required");
461
+ return normalizeBaseUrl(rawBaseUrl, definition.allowInsecureHttp);
462
+ }
463
+ async function loginApiKey(interaction, definition) {
464
+ const baseUrl = await promptBaseUrl(interaction, definition);
417
465
  const key = (await interaction.prompt({ type: "secret", message: "Enter API key:" })).trim();
418
466
  if (!key)
419
467
  throw new Error("Both base URL and API key are required");
420
- return { type: "api_key", key, env: { [ENV_BASE_URL]: normalizeBaseUrl(rawBaseUrl, allowInsecureHttp) } };
468
+ return { type: "api_key", key, env: { [ENV_BASE_URL]: baseUrl } };
421
469
  }
422
470
  async function startCliSso(baseUrl, signal, headers) {
423
471
  const response = await fetch(`${baseUrl}/sso/cli/start`, {
@@ -560,15 +608,9 @@ async function loginWithPastedToken(interaction, baseUrl, headers) {
560
608
  }
561
609
  return { type: "oauth", access, refresh: "", expires, baseUrl };
562
610
  }
563
- async function loginOAuth(interaction, headers, allowInsecureHttp = false) {
564
- const rawBaseUrl = (await interaction.prompt({
565
- type: "text",
566
- message: "Enter LiteLLM proxy URL (no trailing /v1):",
567
- placeholder: "https://litellm.example.com",
568
- })).trim();
569
- if (!rawBaseUrl)
570
- throw new Error("Base URL is required");
571
- const baseUrl = normalizeBaseUrl(rawBaseUrl, allowInsecureHttp);
611
+ async function loginOAuth(interaction, definition) {
612
+ const headers = resolveHeaders(definition);
613
+ const baseUrl = await promptBaseUrl(interaction, definition);
572
614
  const cliSso = await startCliSso(baseUrl, interaction.signal, headers);
573
615
  if (!cliSso)
574
616
  return loginWithPastedToken(interaction, baseUrl, headers);
@@ -666,9 +708,7 @@ function createProviderAuth(definition) {
666
708
  return {
667
709
  apiKey: {
668
710
  name: `${definition.displayName} API key`,
669
- login: definition.name === PROVIDER_NAME
670
- ? (interaction) => loginApiKey(interaction, definition.allowInsecureHttp)
671
- : undefined,
711
+ login: definition.name === PROVIDER_NAME ? (interaction) => loginApiKey(interaction, definition) : undefined,
672
712
  check: async ({ ctx, credential }) => {
673
713
  const baseUrl = credential?.env?.[ENV_BASE_URL] ??
674
714
  definition.baseUrl ??
@@ -704,7 +744,7 @@ function createProviderAuth(definition) {
704
744
  ? {
705
745
  name: "LiteLLM SSO",
706
746
  loginLabel: "Sign in with LiteLLM SSO",
707
- login: (interaction) => loginOAuth(interaction, resolveHeaders(definition), definition.allowInsecureHttp),
747
+ login: (interaction) => loginOAuth(interaction, definition),
708
748
  refresh: async (credential, signal) => ({
709
749
  ...(await refreshLiteLLM(credential, signal)),
710
750
  type: "oauth",
@@ -782,7 +822,9 @@ const REASONING_SUPPRESSION_DEFAULTS = {
782
822
  thinking: { type: "disabled" },
783
823
  };
784
824
  const GEMINI_MODEL_PATTERN = /(?:^|[-_/.:])gemini(?=$|[-_/.:])/i;
785
- function prepareLiteLLMRequestPayload(payload, modelId, api, sessionId) {
825
+ function prepareLiteLLMRequestPayload(payload, model, sessionId) {
826
+ const modelId = model?.id;
827
+ const api = model?.api;
786
828
  let next;
787
829
  const update = (key, value) => {
788
830
  if (payload[key] !== undefined)
@@ -790,7 +832,7 @@ function prepareLiteLLMRequestPayload(payload, modelId, api, sessionId) {
790
832
  next ??= { ...payload };
791
833
  next[key] = value;
792
834
  };
793
- if (api !== "openai-responses" && modelId && shouldSuppressReasoningContent(modelId)) {
835
+ if (api !== "openai-responses" && model?.suppressReasoningContent === true) {
794
836
  for (const [key, value] of Object.entries(REASONING_SUPPRESSION_DEFAULTS)) {
795
837
  if (key !== "thinking")
796
838
  update(key, value);
@@ -867,7 +909,7 @@ function prepareLiteLLMRequestPayload(payload, modelId, api, sessionId) {
867
909
  return next;
868
910
  }
869
911
  function normalizeThinkTags(message, litellmProviderNames) {
870
- if (!litellmProviderNames.has(message.provider) || !shouldSuppressReasoningContent(message.model))
912
+ if (!litellmProviderNames.has(message.provider) || !emitsThinkTags(message.model))
871
913
  return;
872
914
  let changed = false;
873
915
  const content = [];
@@ -1146,7 +1188,7 @@ export default async function (pi) {
1146
1188
  return;
1147
1189
  if (typeof event.payload !== "object" || event.payload === null)
1148
1190
  return;
1149
- return prepareLiteLLMRequestPayload(event.payload, ctx.model?.id, ctx.model?.api, sessionId);
1191
+ return prepareLiteLLMRequestPayload(event.payload, ctx.model, sessionId);
1150
1192
  });
1151
1193
  // Skills enrichment is best-effort: an expired credential or an unreachable proxy must not
1152
1194
  // report an extension error on every turn. The failing model request states the real problem.
@@ -1,5 +1,5 @@
1
- import { type Credential, type Model, type Provider, type ProviderAuth } from "@earendil-works/pi-ai";
2
- import type { DiscoveredModel, DiscoveryResult, LiteLLMApi } from "./types.js";
1
+ import { type Credential, type Provider, type ProviderAuth } from "@earendil-works/pi-ai";
2
+ import type { DiscoveredModel, DiscoveryResult, LiteLLMApi, LiteLLMModel } from "./types.js";
3
3
  export type LiteLLMProviderOptions = {
4
4
  id: string;
5
5
  name: string;
@@ -7,10 +7,10 @@ export type LiteLLMProviderOptions = {
7
7
  auth: ProviderAuth;
8
8
  /** Catalog discovered during activation. Pi's startup refresh never allows network access, so
9
9
  * without a seed the provider stays empty until the user opens /model. */
10
- models?: readonly Model<LiteLLMApi>[];
10
+ models?: readonly LiteLLMModel[];
11
11
  discover(credential: Credential, signal?: AbortSignal): Promise<DiscoveryResult & {
12
12
  baseUrl?: string;
13
13
  }>;
14
14
  };
15
- export declare function toNativeModels(provider: string, baseUrl: string, models: readonly DiscoveredModel[]): Model<LiteLLMApi>[];
15
+ export declare function toNativeModels(provider: string, baseUrl: string, models: readonly DiscoveredModel[]): LiteLLMModel[];
16
16
  export declare function createLiteLLMProvider(options: LiteLLMProviderOptions): Provider<LiteLLMApi>;
package/dist/types.d.ts CHANGED
@@ -9,6 +9,10 @@ export type LiteLLMRuntimeAuth = {
9
9
  };
10
10
  export type DiscoveredModel = Omit<Model<"openai-completions">, "provider" | "api" | "baseUrl"> & {
11
11
  api?: LiteLLMApi;
12
+ suppressReasoningContent?: boolean;
13
+ };
14
+ export type LiteLLMModel = Model<LiteLLMApi> & {
15
+ suppressReasoningContent?: boolean;
12
16
  };
13
17
  export interface DiscoveryResult {
14
18
  models: DiscoveredModel[];
@@ -22,6 +26,10 @@ export interface DiscoveryOptions {
22
26
  }
23
27
  export interface ModelInfoEntry {
24
28
  model_name?: string;
29
+ litellm_params?: {
30
+ model?: string;
31
+ custom_llm_provider?: string;
32
+ };
25
33
  model_info?: {
26
34
  mode?: string | null;
27
35
  input_cost_per_token?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-provider-litellm",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "LiteLLM proxy provider extension for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -46,7 +46,7 @@
46
46
  "typecheck": "tsgo --noEmit",
47
47
  "lint": "biome check --error-on-warnings .",
48
48
  "format": "biome check --write .",
49
- "test": "vitest --run",
49
+ "test": "vitest --run --dir tests",
50
50
  "supply-chain:guard": "tsx scripts/supply-chain-guard.ts",
51
51
  "check": "npm run lint && npm run typecheck && npm test",
52
52
  "prepublishOnly": "npm run check && npm run clean && npm run build && npm run supply-chain:guard"