pi-provider-litellm 2.2.0 → 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
@@ -115,6 +117,7 @@ Provider fields:
115
117
  | `headers` | `$LITELLM_HEADERS` for `litellm`; unset for aliases | JSON string env reference or inline object of request headers |
116
118
  | `displayName` | provider name | Label shown in Pi UI |
117
119
  | `enabled` | `true` | Set `false` to skip an alias |
120
+ | `allowInsecureHttp` | `false` | Set `true` to permit plaintext HTTP for this provider, for example `http://host.docker.internal`. Credentials and request data will not be encrypted. Loopback HTTP works without this setting. |
118
121
 
119
122
  `/login litellm` and Google ADC token auth remain scoped to the default `litellm` provider. Aliases use their configured `apiKey` or manually stored auth entries matching the alias name.
120
123
 
@@ -235,6 +238,7 @@ Opening `/model` refreshes configured provider catalogs in the background using
235
238
  | Symptom | Likely cause |
236
239
  |---|---|
237
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) |
238
242
  | "discovered no models" | Proxy returned an empty list — check pi's startup log and verify `/model/info`, `/v1/models`, or `/health` responds |
239
243
  | `/model/info` returning 401/403/404 | Expected behavior with virtual keys — extension falls back to `/v1/models` |
240
244
  | Discovery times out | Increase `LITELLM_DISCOVERY_TIMEOUT_MS` or set `LITELLM_OFFLINE=1` to fall back on cached models |
@@ -247,6 +251,27 @@ Opening `/model` refreshes configured provider catalogs in the background using
247
251
  | MCP tools not showing | Verify the proxy exposes `/mcp-rest/tools/list` and open `/model` after fixing the proxy |
248
252
  | Skills not affecting prompts | Verify the proxy exposes `/claude-code/marketplace.json` or `/v1/skills` and returns enabled skills |
249
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
+
250
275
  ## License
251
276
 
252
277
  MIT — see [LICENSE](./LICENSE).
@@ -1,9 +1,9 @@
1
1
  import type { Api, Model } from "@earendil-works/pi-ai";
2
2
  import type { DiscoveredModel, DiscoveryOptions, DiscoveryResult } from "./types.js";
3
- export declare function normalizeBaseUrl(input: string): string;
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
@@ -4,11 +4,11 @@ const DEFAULT_TIMEOUT_MS = 5000;
4
4
  const DEFAULT_CONTEXT_WINDOW = 128_000;
5
5
  const DEFAULT_MAX_TOKENS = 16_384;
6
6
  const KNOWN_PROVIDER_SET = new Set(getProviders());
7
- export function normalizeBaseUrl(input) {
7
+ export function normalizeBaseUrl(input, allowInsecureHttp = false) {
8
8
  const url = new URL(input);
9
9
  const hostname = url.hostname.toLowerCase();
10
10
  const loopback = hostname === "localhost" || hostname === "[::1]" || (isIP(hostname) === 4 && hostname.startsWith("127."));
11
- if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
11
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && (loopback || allowInsecureHttp))) {
12
12
  throw new Error("LiteLLM base URL must use HTTPS except for loopback hosts");
13
13
  }
14
14
  return input.replace(/\/+$/, "").replace(/\/v1\/?$/i, "");
@@ -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,32 +296,49 @@ 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 = {}) {
278
- const base = normalizeBaseUrl(baseUrl);
319
+ const base = normalizeBaseUrl(baseUrl, options.allowInsecureHttp);
279
320
  const progress = options.silent ? undefined : options.onProgress;
280
321
  progress?.("Querying /model/info endpoint...");
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";
@@ -335,7 +335,7 @@ async function resolveCredentials(definition, { executeHelpers = true } = {}) {
335
335
  apiKeyConfig = `$${ENV_API_KEY}`;
336
336
  }
337
337
  return {
338
- baseUrl: configuredBase ? normalizeBaseUrl(configuredBase) : undefined,
338
+ baseUrl: configuredBase ? normalizeBaseUrl(configuredBase, definition.allowInsecureHttp) : undefined,
339
339
  apiKey: apiKey || undefined,
340
340
  apiKeyConfig,
341
341
  };
@@ -393,6 +393,7 @@ function getProviderDefinitions(settings) {
393
393
  useDefaultEnv: isDefault,
394
394
  useGcloudTokenAuth: isDefault,
395
395
  enableOAuth: isDefault,
396
+ allowInsecureHttp: raw?.allowInsecureHttp === true,
396
397
  });
397
398
  const definitions = [makeDefinition(PROVIDER_NAME, defaultSettings, true)];
398
399
  for (const [name, raw] of Object.entries(providerSettings ?? {})) {
@@ -405,7 +406,51 @@ function getProviderDefinitions(settings) {
405
406
  }
406
407
  return definitions;
407
408
  }
408
- async function loginApiKey(interaction) {
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
+ }
409
454
  const rawBaseUrl = (await interaction.prompt({
410
455
  type: "text",
411
456
  message: "Enter LiteLLM proxy URL (no trailing /v1):",
@@ -413,10 +458,14 @@ async function loginApiKey(interaction) {
413
458
  })).trim();
414
459
  if (!rawBaseUrl)
415
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);
416
465
  const key = (await interaction.prompt({ type: "secret", message: "Enter API key:" })).trim();
417
466
  if (!key)
418
467
  throw new Error("Both base URL and API key are required");
419
- return { type: "api_key", key, env: { [ENV_BASE_URL]: normalizeBaseUrl(rawBaseUrl) } };
468
+ return { type: "api_key", key, env: { [ENV_BASE_URL]: baseUrl } };
420
469
  }
421
470
  async function startCliSso(baseUrl, signal, headers) {
422
471
  const response = await fetch(`${baseUrl}/sso/cli/start`, {
@@ -559,15 +608,9 @@ async function loginWithPastedToken(interaction, baseUrl, headers) {
559
608
  }
560
609
  return { type: "oauth", access, refresh: "", expires, baseUrl };
561
610
  }
562
- async function loginOAuth(interaction, headers) {
563
- const rawBaseUrl = (await interaction.prompt({
564
- type: "text",
565
- message: "Enter LiteLLM proxy URL (no trailing /v1):",
566
- placeholder: "https://litellm.example.com",
567
- })).trim();
568
- if (!rawBaseUrl)
569
- throw new Error("Base URL is required");
570
- const baseUrl = normalizeBaseUrl(rawBaseUrl);
611
+ async function loginOAuth(interaction, definition) {
612
+ const headers = resolveHeaders(definition);
613
+ const baseUrl = await promptBaseUrl(interaction, definition);
571
614
  const cliSso = await startCliSso(baseUrl, interaction.signal, headers);
572
615
  if (!cliSso)
573
616
  return loginWithPastedToken(interaction, baseUrl, headers);
@@ -610,7 +653,7 @@ async function resolveApiKeyAuth(definition, ctx, credential, executeHelpers = t
610
653
  if (stored) {
611
654
  source = "stored credential";
612
655
  creds = {
613
- baseUrl: baseUrl ? normalizeBaseUrl(baseUrl) : undefined,
656
+ baseUrl: baseUrl ? normalizeBaseUrl(baseUrl, definition.allowInsecureHttp) : undefined,
614
657
  apiKey: stored,
615
658
  };
616
659
  }
@@ -647,7 +690,7 @@ async function resolveApiKeyAuth(definition, ctx, credential, executeHelpers = t
647
690
  }
648
691
  }
649
692
  if (!creds.baseUrl && baseUrl)
650
- creds.baseUrl = normalizeBaseUrl(baseUrl);
693
+ creds.baseUrl = normalizeBaseUrl(baseUrl, definition.allowInsecureHttp);
651
694
  }
652
695
  if (!creds.apiKey)
653
696
  return undefined;
@@ -657,7 +700,7 @@ async function resolveApiKeyAuth(definition, ctx, credential, executeHelpers = t
657
700
  baseUrl: creds.baseUrl ? `${creds.baseUrl}/v1` : undefined,
658
701
  headers: await resolveHeadersFromContext(definition, ctx.env),
659
702
  },
660
- env: baseUrl ? { [ENV_BASE_URL]: normalizeBaseUrl(baseUrl) } : undefined,
703
+ env: baseUrl ? { [ENV_BASE_URL]: normalizeBaseUrl(baseUrl, definition.allowInsecureHttp) } : undefined,
661
704
  source: source ?? creds.apiKeyConfig ?? ENV_API_KEY,
662
705
  };
663
706
  }
@@ -665,7 +708,7 @@ function createProviderAuth(definition) {
665
708
  return {
666
709
  apiKey: {
667
710
  name: `${definition.displayName} API key`,
668
- login: definition.name === PROVIDER_NAME ? loginApiKey : undefined,
711
+ login: definition.name === PROVIDER_NAME ? (interaction) => loginApiKey(interaction, definition) : undefined,
669
712
  check: async ({ ctx, credential }) => {
670
713
  const baseUrl = credential?.env?.[ENV_BASE_URL] ??
671
714
  definition.baseUrl ??
@@ -701,14 +744,16 @@ function createProviderAuth(definition) {
701
744
  ? {
702
745
  name: "LiteLLM SSO",
703
746
  loginLabel: "Sign in with LiteLLM SSO",
704
- login: (interaction) => loginOAuth(interaction, resolveHeaders(definition)),
747
+ login: (interaction) => loginOAuth(interaction, definition),
705
748
  refresh: async (credential, signal) => ({
706
749
  ...(await refreshLiteLLM(credential, signal)),
707
750
  type: "oauth",
708
751
  }),
709
752
  toAuth: async (credential) => ({
710
753
  apiKey: credential.access,
711
- baseUrl: credential.baseUrl ? `${normalizeBaseUrl(String(credential.baseUrl))}/v1` : undefined,
754
+ baseUrl: credential.baseUrl
755
+ ? `${normalizeBaseUrl(String(credential.baseUrl), definition.allowInsecureHttp)}/v1`
756
+ : undefined,
712
757
  headers: resolveHeaders(definition),
713
758
  }),
714
759
  }
@@ -777,7 +822,9 @@ const REASONING_SUPPRESSION_DEFAULTS = {
777
822
  thinking: { type: "disabled" },
778
823
  };
779
824
  const GEMINI_MODEL_PATTERN = /(?:^|[-_/.:])gemini(?=$|[-_/.:])/i;
780
- function prepareLiteLLMRequestPayload(payload, modelId, api, sessionId) {
825
+ function prepareLiteLLMRequestPayload(payload, model, sessionId) {
826
+ const modelId = model?.id;
827
+ const api = model?.api;
781
828
  let next;
782
829
  const update = (key, value) => {
783
830
  if (payload[key] !== undefined)
@@ -785,7 +832,7 @@ function prepareLiteLLMRequestPayload(payload, modelId, api, sessionId) {
785
832
  next ??= { ...payload };
786
833
  next[key] = value;
787
834
  };
788
- if (api !== "openai-responses" && modelId && shouldSuppressReasoningContent(modelId)) {
835
+ if (api !== "openai-responses" && model?.suppressReasoningContent === true) {
789
836
  for (const [key, value] of Object.entries(REASONING_SUPPRESSION_DEFAULTS)) {
790
837
  if (key !== "thinking")
791
838
  update(key, value);
@@ -862,7 +909,7 @@ function prepareLiteLLMRequestPayload(payload, modelId, api, sessionId) {
862
909
  return next;
863
910
  }
864
911
  function normalizeThinkTags(message, litellmProviderNames) {
865
- if (!litellmProviderNames.has(message.provider) || !shouldSuppressReasoningContent(message.model))
912
+ if (!litellmProviderNames.has(message.provider) || !emitsThinkTags(message.model))
866
913
  return;
867
914
  let changed = false;
868
915
  const content = [];
@@ -939,23 +986,29 @@ export default async function (pi) {
939
986
  const baseUrl = cleanConfig(definition.baseUrl) ??
940
987
  (definition.useDefaultEnv ? cleanConfig(process.env[ENV_BASE_URL]) : undefined) ??
941
988
  "https://litellm.example.com";
942
- return `${normalizeBaseUrl(baseUrl)}/v1`;
989
+ return `${normalizeBaseUrl(baseUrl, definition.allowInsecureHttp)}/v1`;
943
990
  }
944
991
  async function authForCredential(definition, credential, executeHelpers = true) {
945
992
  if (credential?.type === "oauth") {
946
993
  const baseUrl = typeof credential.baseUrl === "string"
947
- ? normalizeBaseUrl(credential.baseUrl)
948
- : normalizeBaseUrl(requestBaseUrl(definition));
949
- return { baseUrl, apiKey: credential.access, headers: resolveHeaders(definition) };
994
+ ? normalizeBaseUrl(credential.baseUrl, definition.allowInsecureHttp)
995
+ : normalizeBaseUrl(requestBaseUrl(definition), definition.allowInsecureHttp);
996
+ return {
997
+ baseUrl,
998
+ apiKey: credential.access,
999
+ headers: resolveHeaders(definition),
1000
+ allowInsecureHttp: definition.allowInsecureHttp,
1001
+ };
950
1002
  }
951
1003
  const resolved = await resolveApiKeyAuth(definition, { env: async (name) => process.env[name] }, credential, executeHelpers);
952
1004
  if (!resolved?.auth.apiKey) {
953
1005
  throw new Error(`no credentials for ${definition.name}. Run /login litellm or set env vars.`);
954
1006
  }
955
1007
  return {
956
- baseUrl: normalizeBaseUrl(resolved.auth.baseUrl ?? requestBaseUrl(definition)),
1008
+ baseUrl: normalizeBaseUrl(resolved.auth.baseUrl ?? requestBaseUrl(definition), definition.allowInsecureHttp),
957
1009
  apiKey: resolved.auth.apiKey,
958
1010
  headers: resolved.auth.headers,
1011
+ allowInsecureHttp: definition.allowInsecureHttp,
959
1012
  };
960
1013
  }
961
1014
  let registeredMcpIdentity;
@@ -963,7 +1016,7 @@ export default async function (pi) {
963
1016
  let defaultRuntimeAuth;
964
1017
  function mcpCatalogIdentity(auth) {
965
1018
  return JSON.stringify({
966
- baseUrl: normalizeBaseUrl(auth.baseUrl),
1019
+ baseUrl: normalizeBaseUrl(auth.baseUrl, auth.allowInsecureHttp),
967
1020
  apiKey: auth.apiKey,
968
1021
  headers: Object.entries(auth.headers ?? {}).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
969
1022
  });
@@ -979,9 +1032,10 @@ export default async function (pi) {
979
1032
  return undefined;
980
1033
  const headers = Object.fromEntries(Object.entries(auth.auth.headers ?? provider?.headers ?? {}).filter((entry) => typeof entry[1] === "string"));
981
1034
  return {
982
- baseUrl: normalizeBaseUrl(baseUrl),
1035
+ baseUrl: normalizeBaseUrl(baseUrl, definitions[0]?.allowInsecureHttp),
983
1036
  apiKey,
984
1037
  headers: Object.keys(headers).length > 0 ? headers : undefined,
1038
+ allowInsecureHttp: definitions[0]?.allowInsecureHttp,
985
1039
  };
986
1040
  }
987
1041
  async function resolveDefaultRuntimeAuth(ctx) {
@@ -1056,11 +1110,12 @@ export default async function (pi) {
1056
1110
  timeoutMs: getDiscoveryTimeoutMs(),
1057
1111
  signal,
1058
1112
  headers: auth.headers,
1113
+ allowInsecureHttp: auth.allowInsecureHttp,
1059
1114
  silent: !isVerboseDiscovery(),
1060
1115
  onProgress: isVerboseDiscovery() ? (message) => process.stderr.write(`LiteLLM: ${message}\n`) : undefined,
1061
1116
  });
1062
1117
  signal?.throwIfAborted();
1063
- return { ...result, baseUrl: `${normalizeBaseUrl(auth.baseUrl)}/v1` };
1118
+ return { ...result, baseUrl: `${normalizeBaseUrl(auth.baseUrl, auth.allowInsecureHttp)}/v1` };
1064
1119
  }
1065
1120
  // Pi's startup path only ever refreshes with allowNetwork:false, and it returns before the
1066
1121
  // network phase, so the provider would stay empty until the user opens /model. Discover here
@@ -1133,7 +1188,7 @@ export default async function (pi) {
1133
1188
  return;
1134
1189
  if (typeof event.payload !== "object" || event.payload === null)
1135
1190
  return;
1136
- return prepareLiteLLMRequestPayload(event.payload, ctx.model?.id, ctx.model?.api, sessionId);
1191
+ return prepareLiteLLMRequestPayload(event.payload, ctx.model, sessionId);
1137
1192
  });
1138
1193
  // Skills enrichment is best-effort: an expired credential or an unreachable proxy must not
1139
1194
  // report an extension error on every turn. The failing model request states the real problem.
@@ -1147,7 +1202,7 @@ export default async function (pi) {
1147
1202
  if (!auth)
1148
1203
  return;
1149
1204
  defaultRuntimeAuth = auth;
1150
- const skills = await listSkills(auth.baseUrl, auth.apiKey, auth.headers);
1205
+ const skills = await listSkills(auth.baseUrl, auth.apiKey, auth.headers, auth.allowInsecureHttp);
1151
1206
  section = createSkillsPromptSection(skills);
1152
1207
  }
1153
1208
  catch (error) {
@@ -1,5 +1,5 @@
1
1
  import { type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
2
2
  import type { LiteLLMMcpTool, LiteLLMRuntimeAuth } from "./types.js";
3
- export declare function discoverMcpTools(baseUrl: string, apiKey: string, headers?: Record<string, string>, onProgress?: (message: string) => void, parentSignal?: AbortSignal): Promise<LiteLLMMcpTool[]>;
4
- export declare function executeMcpTool(baseUrl: string, apiKey: string, serverId: string, toolName: string, args: Record<string, unknown>, headers?: Record<string, string>): Promise<string>;
3
+ export declare function discoverMcpTools(baseUrl: string, apiKey: string, headers?: Record<string, string>, onProgress?: (message: string) => void, parentSignal?: AbortSignal, allowInsecureHttp?: boolean): Promise<LiteLLMMcpTool[]>;
4
+ export declare function executeMcpTool(baseUrl: string, apiKey: string, serverId: string, toolName: string, args: Record<string, unknown>, headers?: Record<string, string>, allowInsecureHttp?: boolean): Promise<string>;
5
5
  export declare function createMcpToolDefinitions(getAuth: (ctx?: ExtensionContext) => Promise<LiteLLMRuntimeAuth>, onProgress?: (message: string) => void, signal?: AbortSignal): Promise<ToolDefinition[]>;
package/dist/mcp-tools.js CHANGED
@@ -49,11 +49,11 @@ function normalizeMcpTool(value) {
49
49
  input_schema: inputSchema,
50
50
  };
51
51
  }
52
- export async function discoverMcpTools(baseUrl, apiKey, headers, onProgress, parentSignal) {
52
+ export async function discoverMcpTools(baseUrl, apiKey, headers, onProgress, parentSignal, allowInsecureHttp = false) {
53
53
  onProgress?.("Discovering MCP tools from server...");
54
54
  try {
55
55
  onProgress?.("Querying MCP tools/list endpoint...");
56
- const response = await fetch(`${normalizeBaseUrl(baseUrl)}/mcp-rest/tools/list`, {
56
+ const response = await fetch(`${normalizeBaseUrl(baseUrl, allowInsecureHttp)}/mcp-rest/tools/list`, {
57
57
  headers: {
58
58
  ...headers,
59
59
  Authorization: `Bearer ${apiKey}`,
@@ -80,9 +80,9 @@ export async function discoverMcpTools(baseUrl, apiKey, headers, onProgress, par
80
80
  throw error;
81
81
  }
82
82
  }
83
- export async function executeMcpTool(baseUrl, apiKey, serverId, toolName, args, headers) {
83
+ export async function executeMcpTool(baseUrl, apiKey, serverId, toolName, args, headers, allowInsecureHttp = false) {
84
84
  for (let attempt = 0; attempt <= MCP_RETRY_ATTEMPTS; attempt++) {
85
- const result = await executeMcpToolOnce(baseUrl, apiKey, serverId, toolName, args, headers);
85
+ const result = await executeMcpToolOnce(baseUrl, apiKey, serverId, toolName, args, headers, allowInsecureHttp);
86
86
  if (attempt < MCP_RETRY_ATTEMPTS && result.retryable) {
87
87
  await sleep(MCP_RETRY_DELAY_MS);
88
88
  continue;
@@ -91,11 +91,11 @@ export async function executeMcpTool(baseUrl, apiKey, serverId, toolName, args,
91
91
  }
92
92
  return `Error calling ${toolName} on ${serverId}: retry attempts exhausted`;
93
93
  }
94
- async function executeMcpToolOnce(baseUrl, apiKey, serverId, toolName, args, headers) {
94
+ async function executeMcpToolOnce(baseUrl, apiKey, serverId, toolName, args, headers, allowInsecureHttp = false) {
95
95
  try {
96
96
  let response;
97
97
  try {
98
- response = await fetch(`${normalizeBaseUrl(baseUrl)}/mcp-rest/tools/call`, {
98
+ response = await fetch(`${normalizeBaseUrl(baseUrl, allowInsecureHttp)}/mcp-rest/tools/call`, {
99
99
  method: "POST",
100
100
  headers: {
101
101
  ...headers,
@@ -152,7 +152,7 @@ function buildParameters(inputSchema) {
152
152
  }
153
153
  export async function createMcpToolDefinitions(getAuth, onProgress, signal) {
154
154
  const discoveryAuth = await getAuth();
155
- const tools = await discoverMcpTools(discoveryAuth.baseUrl, discoveryAuth.apiKey, discoveryAuth.headers, onProgress, signal);
155
+ const tools = await discoverMcpTools(discoveryAuth.baseUrl, discoveryAuth.apiKey, discoveryAuth.headers, onProgress, signal, discoveryAuth.allowInsecureHttp);
156
156
  return tools.map((mcpTool) => {
157
157
  const safeServer = sanitizeName(mcpTool.server_name);
158
158
  const safeTool = sanitizeName(mcpTool.name);
@@ -170,7 +170,7 @@ export async function createMcpToolDefinitions(getAuth, onProgress, signal) {
170
170
  const args = Object.keys(rawParams).length === 1 && rawParams.args && typeof rawParams.args === "object"
171
171
  ? rawParams.args
172
172
  : rawParams;
173
- const text = await executeMcpTool(auth.baseUrl, auth.apiKey, mcpTool.server_id ?? mcpTool.server_name, mcpTool.name, args, auth.headers);
173
+ const text = await executeMcpTool(auth.baseUrl, auth.apiKey, mcpTool.server_id ?? mcpTool.server_name, mcpTool.name, args, auth.headers, auth.allowInsecureHttp);
174
174
  return {
175
175
  content: [{ type: "text", text }],
176
176
  details: { server: mcpTool.server_name, serverId: mcpTool.server_id, tool: mcpTool.name },
@@ -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/skills.d.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  import { type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
2
2
  import type { LiteLLMRuntimeAuth, LiteLLMSkill } from "./types.js";
3
- export declare function listSkills(baseUrl: string, apiKey: string, headers?: Record<string, string>): Promise<LiteLLMSkill[]>;
3
+ export declare function listSkills(baseUrl: string, apiKey: string, headers?: Record<string, string>, allowInsecureHttp?: boolean): Promise<LiteLLMSkill[]>;
4
4
  export declare function createSkill(baseUrl: string, apiKey: string, input: {
5
5
  name: string;
6
6
  description?: string;
7
7
  source?: Record<string, unknown>;
8
8
  code?: string;
9
9
  inputSchema?: Record<string, unknown>;
10
- }, headers?: Record<string, string>): Promise<unknown>;
11
- export declare function deleteSkill(baseUrl: string, apiKey: string, skillId: string, headers?: Record<string, string>): Promise<void>;
10
+ }, headers?: Record<string, string>, allowInsecureHttp?: boolean): Promise<unknown>;
11
+ export declare function deleteSkill(baseUrl: string, apiKey: string, skillId: string, headers?: Record<string, string>, allowInsecureHttp?: boolean): Promise<void>;
12
12
  export declare function createSkillsPromptSection(skills: LiteLLMSkill[]): string | undefined;
13
13
  export declare function createSkillToolDefinitions(getAuth: (ctx?: ExtensionContext) => Promise<LiteLLMRuntimeAuth>): ToolDefinition[];
14
14
  export declare function resetSkillsCache(): void;
package/dist/skills.js CHANGED
@@ -17,8 +17,8 @@ function getSkillsFromBody(body) {
17
17
  }
18
18
  return [];
19
19
  }
20
- export async function listSkills(baseUrl, apiKey, headers) {
21
- const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
20
+ export async function listSkills(baseUrl, apiKey, headers, allowInsecureHttp = false) {
21
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl, allowInsecureHttp);
22
22
  if (skillsCache &&
23
23
  skillsCache.baseUrl === normalizedBaseUrl &&
24
24
  skillsCache.apiKey === apiKey &&
@@ -46,10 +46,10 @@ export async function listSkills(baseUrl, apiKey, headers) {
46
46
  skillsCache = { baseUrl: normalizedBaseUrl, apiKey, fetchedAt: Date.now(), skills };
47
47
  return skills;
48
48
  }
49
- export async function createSkill(baseUrl, apiKey, input, headers) {
49
+ export async function createSkill(baseUrl, apiKey, input, headers, allowInsecureHttp = false) {
50
50
  if (!input.source && !input.code)
51
51
  throw new Error("code is required when source is omitted");
52
- const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
52
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl, allowInsecureHttp);
53
53
  let usedSkillHub = input.source !== undefined;
54
54
  const skillHubPayload = input.source
55
55
  ? {
@@ -105,8 +105,8 @@ export async function createSkill(baseUrl, apiKey, input, headers) {
105
105
  }
106
106
  return response.json().catch(() => ({}));
107
107
  }
108
- export async function deleteSkill(baseUrl, apiKey, skillId, headers) {
109
- const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
108
+ export async function deleteSkill(baseUrl, apiKey, skillId, headers, allowInsecureHttp = false) {
109
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl, allowInsecureHttp);
110
110
  let skillHubError;
111
111
  const skillHubResponse = await fetch(`${normalizedBaseUrl}/claude-code/plugins/${encodeURIComponent(skillId)}`, {
112
112
  method: "DELETE",
@@ -182,7 +182,7 @@ export function createSkillToolDefinitions(getAuth) {
182
182
  parameters: Type.Object({}),
183
183
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
184
184
  const auth = await getAuth(ctx);
185
- const skills = await listSkills(auth.baseUrl, auth.apiKey, auth.headers);
185
+ const skills = await listSkills(auth.baseUrl, auth.apiKey, auth.headers, auth.allowInsecureHttp);
186
186
  return { content: [{ type: "text", text: formatSkills(skills) }], details: { count: skills.length } };
187
187
  },
188
188
  }),
@@ -203,7 +203,7 @@ export function createSkillToolDefinitions(getAuth) {
203
203
  source,
204
204
  code: params.code,
205
205
  inputSchema,
206
- }, auth.headers);
206
+ }, auth.headers, auth.allowInsecureHttp);
207
207
  return { content: [{ type: "text", text: "LiteLLM skill created." }], details: { result } };
208
208
  },
209
209
  }),
@@ -214,7 +214,7 @@ export function createSkillToolDefinitions(getAuth) {
214
214
  parameters: DeleteSkillParams,
215
215
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
216
216
  const auth = await getAuth(ctx);
217
- await deleteSkill(auth.baseUrl, auth.apiKey, params.skillId, auth.headers);
217
+ await deleteSkill(auth.baseUrl, auth.apiKey, params.skillId, auth.headers, auth.allowInsecureHttp);
218
218
  return { content: [{ type: "text", text: `LiteLLM skill deleted: ${params.skillId}` }], details: {} };
219
219
  },
220
220
  }),
package/dist/types.d.ts CHANGED
@@ -5,9 +5,14 @@ export type LiteLLMRuntimeAuth = {
5
5
  baseUrl: string;
6
6
  apiKey: string;
7
7
  headers?: Record<string, string>;
8
+ allowInsecureHttp?: boolean;
8
9
  };
9
10
  export type DiscoveredModel = Omit<Model<"openai-completions">, "provider" | "api" | "baseUrl"> & {
10
11
  api?: LiteLLMApi;
12
+ suppressReasoningContent?: boolean;
13
+ };
14
+ export type LiteLLMModel = Model<LiteLLMApi> & {
15
+ suppressReasoningContent?: boolean;
11
16
  };
12
17
  export interface DiscoveryResult {
13
18
  models: DiscoveredModel[];
@@ -17,9 +22,14 @@ export interface DiscoveryOptions {
17
22
  timeoutMs?: number;
18
23
  signal?: AbortSignal;
19
24
  headers?: Record<string, string>;
25
+ allowInsecureHttp?: boolean;
20
26
  }
21
27
  export interface ModelInfoEntry {
22
28
  model_name?: string;
29
+ litellm_params?: {
30
+ model?: string;
31
+ custom_llm_provider?: string;
32
+ };
23
33
  model_info?: {
24
34
  mode?: string | null;
25
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.0",
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"