pi-provider-litellm 3.1.0 → 3.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/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { createHash, randomBytes } from "node:crypto";
2
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
3
3
  import { existsSync, linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { createServer } from "node:http";
@@ -15,6 +15,7 @@ import type {
15
15
  OAuthCredentials,
16
16
  ProviderAuth,
17
17
  } from "@earendil-works/pi-ai";
18
+ import { Type } from "@earendil-works/pi-ai";
18
19
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
20
  import { getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
20
21
  import { setupLiteLLMCostTracking } from "./cost.js";
@@ -24,6 +25,8 @@ import {
24
25
  createMcpToolDefinitions,
25
26
  credentialFingerprint,
26
27
  McpAccessDeniedError,
28
+ type McpExposure,
29
+ parseMcpExposurePolicy,
27
30
  reportMcpCatalogOutcome,
28
31
  reportMcpPartialDiscovery,
29
32
  reportMcpRegistrationFatal,
@@ -61,6 +64,9 @@ const CLI_SSO_EXPIRES_IN_SECONDS = 600;
61
64
  const CLI_AUTH_DISCOVERY_PATH = "/.well-known/litellm-cli-auth";
62
65
  const PKCE_CALLBACK_TIMEOUT_MS = 10 * 60 * 1000;
63
66
  const PKCE_FLOW = "litellm_cli_pkce";
67
+ // Persisted in auth.json; never rename.
68
+ const OIDC_FLOW = "oidc_pkce";
69
+ const OIDC_DISCOVERY_PATH = "/.well-known/openid-configuration";
64
70
  const DEFAULT_CLI_JWT_EXPIRATION_HOURS = 24;
65
71
  const TOKEN_REFRESH_LEAD_MS = 5 * 60 * 1000;
66
72
  const PERMANENT_TOKEN_EXPIRES_AT = Number.MAX_SAFE_INTEGER;
@@ -74,6 +80,7 @@ type RawProviderSettings = {
74
80
  headers?: unknown;
75
81
  enabled?: unknown;
76
82
  allowInsecureHttp?: unknown;
83
+ oidc?: unknown;
77
84
  };
78
85
 
79
86
  type McpRuntimeAuth = LiteLLMRuntimeAuth & { mcpPauseSource?: string };
@@ -88,6 +95,8 @@ type ProviderDefinition = {
88
95
  useGcloudTokenAuth: boolean;
89
96
  enableOAuth: boolean;
90
97
  allowInsecureHttp: boolean;
98
+ /** Raw `oidc` setting, validated at login so a bad value never breaks startup. */
99
+ oidc?: unknown;
91
100
  };
92
101
 
93
102
  function isPlainObject(value: unknown): value is Record<string, unknown> {
@@ -507,6 +516,7 @@ function getProviderDefinitions(settings: Record<string, unknown> | undefined):
507
516
  useGcloudTokenAuth: isDefault,
508
517
  enableOAuth: isDefault,
509
518
  allowInsecureHttp: raw?.allowInsecureHttp === true,
519
+ oidc: isDefault ? raw?.oidc : undefined,
510
520
  });
511
521
 
512
522
  const definitions = [makeDefinition(PROVIDER_NAME, defaultSettings, true)];
@@ -609,7 +619,9 @@ type PkceToken = {
609
619
  teamId?: string;
610
620
  };
611
621
 
612
- type PkceTokenResult = { ok: true; token: PkceToken } | { ok: false; transient: boolean; message: string };
622
+ type TokenFailure = { ok: false; transient: boolean; message: string };
623
+
624
+ type PkceTokenResult = { ok: true; token: PkceToken } | TokenFailure;
613
625
 
614
626
  function authRequestHeaders(headers?: Record<string, string>, contentType?: string): Headers {
615
627
  const result = new Headers(headers);
@@ -642,12 +654,17 @@ function isAuthToken(value: unknown): value is string {
642
654
  return typeof value === "string" && /^[\x21-\x7e]+$/.test(value);
643
655
  }
644
656
 
657
+ /** The OAuth `error` code when it is a plain code; descriptions and other fields are never echoed. */
658
+ function oauthErrorCode(value: unknown): string | undefined {
659
+ return typeof value === "string" && /^[a-z_]{1,64}$/.test(value) ? value : undefined;
660
+ }
661
+
645
662
  async function readAuthJson(response: Response, signal: AbortSignal | undefined, stage: string): Promise<unknown> {
646
663
  try {
647
664
  return await response.json();
648
665
  } catch {
649
666
  if (signal?.aborted) throw signal.reason;
650
- throw new Error(`LiteLLM CLI auth ${stage} returned invalid JSON`);
667
+ throw new Error(`${stage} returned invalid JSON`);
651
668
  }
652
669
  }
653
670
 
@@ -664,7 +681,7 @@ async function discoverPkce(
664
681
  });
665
682
  if (response.status === 404) return undefined;
666
683
  if (!response.ok) throw new Error(`LiteLLM CLI auth discovery failed (HTTP ${response.status})`);
667
- const data = await readAuthJson(response, signal, "discovery");
684
+ const data = await readAuthJson(response, signal, "LiteLLM CLI auth discovery");
668
685
  if (!isPlainObject(data) || data.contract_version !== 1)
669
686
  throw new Error("LiteLLM CLI auth discovery has unsupported contract version");
670
687
  if (!Array.isArray(data.code_challenge_methods_supported) || !data.code_challenge_methods_supported.includes("S256"))
@@ -688,13 +705,14 @@ async function discoverPkce(
688
705
  };
689
706
  }
690
707
 
691
- async function requestPkceToken(
708
+ /** POSTs a token form without following redirects; each flow validates its own success fields. */
709
+ async function postTokenForm(
692
710
  endpoint: string,
693
711
  form: URLSearchParams,
694
712
  signal: AbortSignal | undefined,
695
- headers?: Record<string, string>,
696
- existingRefreshToken?: string,
697
- ): Promise<PkceTokenResult> {
713
+ headers: Record<string, string> | undefined,
714
+ label: string,
715
+ ): Promise<{ ok: true; data: Record<string, unknown> } | TokenFailure> {
698
716
  let response: Response;
699
717
  try {
700
718
  response = await fetch(endpoint, {
@@ -706,7 +724,7 @@ async function requestPkceToken(
706
724
  });
707
725
  } catch {
708
726
  if (signal?.aborted) throw signal.reason;
709
- return { ok: false, transient: true, message: "LiteLLM token exchange failed (network error)" };
727
+ return { ok: false, transient: true, message: `${label} token exchange failed (network error)` };
710
728
  }
711
729
  let data: Record<string, unknown> | undefined;
712
730
  try {
@@ -715,23 +733,36 @@ async function requestPkceToken(
715
733
  } catch (error) {
716
734
  if (signal?.aborted) throw signal.reason;
717
735
  if (response.ok && !(error instanceof SyntaxError)) {
718
- return { ok: false, transient: true, message: "LiteLLM token exchange failed (network error)" };
736
+ return { ok: false, transient: true, message: `${label} token exchange failed (network error)` };
719
737
  }
720
738
  }
721
739
  signal?.throwIfAborted();
722
740
  if (!response.ok) {
741
+ const code = oauthErrorCode(data?.error);
723
742
  return {
724
743
  ok: false,
725
744
  transient: response.status === 429 || response.status >= 500,
726
- message:
727
- data?.error === "invalid_grant"
728
- ? "LiteLLM token exchange rejected (invalid_grant)"
729
- : `LiteLLM token exchange failed (HTTP ${response.status})`,
745
+ message: code
746
+ ? `${label} token exchange rejected (${code})`
747
+ : `${label} token exchange failed (HTTP ${response.status})`,
730
748
  };
731
749
  }
732
- const expires = typeof data?.expires_in === "number" ? Date.now() + data.expires_in * 1_000 : NaN;
750
+ return { ok: true, data: data ?? {} };
751
+ }
752
+
753
+ async function requestPkceToken(
754
+ endpoint: string,
755
+ form: URLSearchParams,
756
+ signal: AbortSignal | undefined,
757
+ headers?: Record<string, string>,
758
+ existingRefreshToken?: string,
759
+ ): Promise<PkceTokenResult> {
760
+ const response = await postTokenForm(endpoint, form, signal, headers, "LiteLLM");
761
+ if (!response.ok) return response;
762
+ const { data } = response;
763
+ const expires = typeof data.expires_in === "number" ? Date.now() + data.expires_in * 1_000 : NaN;
733
764
  if (
734
- !isAuthToken(data?.access_token) ||
765
+ !isAuthToken(data.access_token) ||
735
766
  (!isAuthToken(data.refresh_token) && !isAuthToken(existingRefreshToken)) ||
736
767
  typeof data.token_type !== "string" ||
737
768
  data.token_type.toLowerCase() !== "bearer" ||
@@ -755,12 +786,26 @@ async function requestPkceToken(
755
786
  };
756
787
  }
757
788
 
758
- async function loginPkce(
759
- interaction: AuthInteraction,
760
- baseUrl: string,
761
- discovery: CliAuthDiscovery,
762
- headers?: Record<string, string>,
763
- ): Promise<OAuthCredential> {
789
+ function constantTimeEqual(value: unknown, expected: string): boolean {
790
+ if (typeof value !== "string") return false;
791
+ const left = Buffer.from(value);
792
+ const right = Buffer.from(expected);
793
+ return left.length === right.length && timingSafeEqual(left, right);
794
+ }
795
+
796
+ type LoopbackCallback = { redirectUri: string; state: string; code: () => Promise<string> };
797
+
798
+ /**
799
+ * Serves an RFC 8252 loopback redirect on 127.0.0.1 for the lifetime of `run`. `code()` resolves with
800
+ * the authorization code of the first callback carrying the expected state.
801
+ */
802
+ async function withLoopbackCallback<T>(
803
+ signal: AbortSignal | undefined,
804
+ label: string,
805
+ ports: readonly number[],
806
+ run: (callback: LoopbackCallback) => Promise<T>,
807
+ ): Promise<T> {
808
+ const state = randomBytes(32).toString("base64url");
764
809
  const server = createServer();
765
810
  let settleCallback: ((result: { code?: string; error?: Error }) => void) | undefined;
766
811
  server.on("request", (request, response) => {
@@ -780,14 +825,15 @@ async function loginPkce(
780
825
  response.writeHead(405, { Allow: "GET" }).end();
781
826
  return;
782
827
  }
783
- if (url.searchParams.get("state") !== state) {
828
+ if (!constantTimeEqual(url.searchParams.get("state"), state)) {
784
829
  response.writeHead(400).end("Invalid OAuth state");
785
830
  return;
786
831
  }
787
832
  const error = url.searchParams.get("error");
788
833
  if (error) {
789
834
  response.writeHead(400).end("OAuth login failed");
790
- settleCallback?.({ error: new Error("LiteLLM PKCE login was denied") });
835
+ const code = oauthErrorCode(error);
836
+ settleCallback?.({ error: new Error(`${label} login was denied${code ? ` (${code})` : ""}`) });
791
837
  return;
792
838
  }
793
839
  const code = url.searchParams.get("code");
@@ -802,102 +848,42 @@ async function loginPkce(
802
848
  response.end("<!doctype html><title>LiteLLM login complete</title><p>You can close this window.</p>");
803
849
  settleCallback?.({ code });
804
850
  });
805
- const verifier = randomBytes(32).toString("base64url");
806
- const state = randomBytes(32).toString("base64url");
807
851
  try {
808
- await new Promise<void>((resolve, reject) => {
809
- const onError = (error: Error) => reject(error);
810
- server.once("error", onError);
811
- server.listen(0, "127.0.0.1", () => {
812
- server.off("error", onError);
813
- resolve();
814
- });
815
- });
816
- const address = server.address();
817
- if (!address || typeof address === "string") throw new Error("LiteLLM PKCE callback failed to start");
818
- const redirectUri = `http://127.0.0.1:${address.port}/callback`;
819
- const registrationResponse = await fetch(discovery.registrationEndpoint, {
820
- method: "POST",
821
- headers: authRequestHeaders(headers, "application/json"),
822
- body: JSON.stringify({
823
- client_name: "pi-provider-litellm",
824
- redirect_uris: [redirectUri],
825
- token_endpoint_auth_method: "none",
826
- grant_types: ["authorization_code", "refresh_token"],
827
- response_types: ["code"],
828
- }),
829
- redirect: "manual",
830
- signal: boundedLoginSignal(interaction.signal),
831
- });
832
- if (!registrationResponse.ok)
833
- throw new Error(`LiteLLM PKCE client registration failed (HTTP ${registrationResponse.status})`);
834
- const registration = await readAuthJson(registrationResponse, interaction.signal, "registration");
835
- if (
836
- !isPlainObject(registration) ||
837
- !isAuthToken(registration.client_id) ||
838
- !Array.isArray(registration.redirect_uris) ||
839
- !registration.redirect_uris.includes(redirectUri)
840
- ) {
841
- throw new Error("LiteLLM PKCE client registration returned an invalid response");
852
+ // Try each pre-registered port in order; without any, the OS assigns one (RFC 8252 §7.3).
853
+ const candidates = ports.length > 0 ? ports : [0];
854
+ for (const [index, port] of candidates.entries()) {
855
+ try {
856
+ await new Promise<void>((resolve, reject) => {
857
+ const onError = (error: Error) => reject(error);
858
+ server.once("error", onError);
859
+ server.listen(port, "127.0.0.1", () => {
860
+ server.off("error", onError);
861
+ resolve();
862
+ });
863
+ });
864
+ break;
865
+ } catch (error) {
866
+ if (index === candidates.length - 1) throw error;
867
+ }
842
868
  }
843
- const authorizationUrl = new URL(discovery.authorizationEndpoint);
844
- for (const [key, value] of Object.entries({
845
- client_id: registration.client_id,
846
- redirect_uri: redirectUri,
847
- response_type: "code",
848
- resource: discovery.resource,
849
- code_challenge: createHash("sha256").update(verifier).digest("base64url"),
850
- code_challenge_method: "S256",
851
- state,
852
- }))
853
- authorizationUrl.searchParams.set(key, value);
854
- interaction.notify({
855
- type: "auth_url",
856
- url: authorizationUrl.toString(),
857
- instructions: "Open this URL in a browser to sign in with LiteLLM.",
858
- });
859
- const code = await new Promise<string>((resolve, reject) => {
860
- const timeout = setTimeout(() => finish(new Error("LiteLLM PKCE login timed out")), PKCE_CALLBACK_TIMEOUT_MS);
861
- const onAbort = () => finish(interaction.signal?.reason ?? new Error("LiteLLM PKCE login cancelled"));
862
- const finish = (error?: Error, value?: string) => {
863
- clearTimeout(timeout);
864
- interaction.signal?.removeEventListener("abort", onAbort);
865
- settleCallback = undefined;
866
- if (error) reject(error);
867
- else resolve(value!);
868
- };
869
- settleCallback = ({ code, error }) => finish(error, code);
870
- interaction.signal?.addEventListener("abort", onAbort, { once: true });
871
- if (interaction.signal?.aborted) onAbort();
872
- });
873
- const result = await requestPkceToken(
874
- discovery.tokenEndpoint,
875
- new URLSearchParams({
876
- grant_type: "authorization_code",
877
- code,
878
- redirect_uri: redirectUri,
879
- client_id: registration.client_id,
880
- code_verifier: verifier,
881
- resource: discovery.resource,
882
- }),
883
- interaction.signal,
884
- headers,
885
- );
886
- if (!result.ok) throw new Error(result.message);
887
- const credential: PkceCredentials = {
888
- type: "oauth",
889
- access: result.token.access,
890
- refresh: result.token.refresh,
891
- expires: result.token.expires,
892
- baseUrl,
893
- flow: PKCE_FLOW,
894
- clientId: registration.client_id,
895
- tokenEndpoint: discovery.tokenEndpoint,
896
- resource: discovery.resource,
897
- userId: result.token.userId,
898
- teamId: result.token.teamId,
899
- };
900
- return { ...credential, type: "oauth" };
869
+ const address = server.address();
870
+ if (!address || typeof address === "string") throw new Error(`${label} callback failed to start`);
871
+ const code = () =>
872
+ new Promise<string>((resolve, reject) => {
873
+ const timeout = setTimeout(() => finish(new Error(`${label} login timed out`)), PKCE_CALLBACK_TIMEOUT_MS);
874
+ const onAbort = () => finish(signal?.reason ?? new Error(`${label} login cancelled`));
875
+ const finish = (error?: Error, value?: string) => {
876
+ clearTimeout(timeout);
877
+ signal?.removeEventListener("abort", onAbort);
878
+ settleCallback = undefined;
879
+ if (error) reject(error);
880
+ else resolve(value!);
881
+ };
882
+ settleCallback = ({ code, error }) => finish(error, code);
883
+ signal?.addEventListener("abort", onAbort, { once: true });
884
+ if (signal?.aborted) onAbort();
885
+ });
886
+ return await run({ redirectUri: `http://127.0.0.1:${address.port}/callback`, state, code });
901
887
  } finally {
902
888
  await new Promise<void>((resolve) => {
903
889
  server.close(() => resolve());
@@ -906,6 +892,277 @@ async function loginPkce(
906
892
  }
907
893
  }
908
894
 
895
+ async function loginPkce(
896
+ interaction: AuthInteraction,
897
+ baseUrl: string,
898
+ discovery: CliAuthDiscovery,
899
+ headers?: Record<string, string>,
900
+ ): Promise<OAuthCredential> {
901
+ const verifier = randomBytes(32).toString("base64url");
902
+ return withLoopbackCallback(
903
+ interaction.signal,
904
+ "LiteLLM PKCE",
905
+ [],
906
+ async ({ redirectUri, state, code: callbackCode }) => {
907
+ const registrationResponse = await fetch(discovery.registrationEndpoint, {
908
+ method: "POST",
909
+ headers: authRequestHeaders(headers, "application/json"),
910
+ body: JSON.stringify({
911
+ client_name: "pi-provider-litellm",
912
+ redirect_uris: [redirectUri],
913
+ token_endpoint_auth_method: "none",
914
+ grant_types: ["authorization_code", "refresh_token"],
915
+ response_types: ["code"],
916
+ }),
917
+ redirect: "manual",
918
+ signal: boundedLoginSignal(interaction.signal),
919
+ });
920
+ if (!registrationResponse.ok)
921
+ throw new Error(`LiteLLM PKCE client registration failed (HTTP ${registrationResponse.status})`);
922
+ const registration = await readAuthJson(
923
+ registrationResponse,
924
+ interaction.signal,
925
+ "LiteLLM CLI auth registration",
926
+ );
927
+ if (
928
+ !isPlainObject(registration) ||
929
+ !isAuthToken(registration.client_id) ||
930
+ !Array.isArray(registration.redirect_uris) ||
931
+ !registration.redirect_uris.includes(redirectUri)
932
+ ) {
933
+ throw new Error("LiteLLM PKCE client registration returned an invalid response");
934
+ }
935
+ const authorizationUrl = new URL(discovery.authorizationEndpoint);
936
+ for (const [key, value] of Object.entries({
937
+ client_id: registration.client_id,
938
+ redirect_uri: redirectUri,
939
+ response_type: "code",
940
+ resource: discovery.resource,
941
+ code_challenge: createHash("sha256").update(verifier).digest("base64url"),
942
+ code_challenge_method: "S256",
943
+ state,
944
+ }))
945
+ authorizationUrl.searchParams.set(key, value);
946
+ interaction.notify({
947
+ type: "auth_url",
948
+ url: authorizationUrl.toString(),
949
+ instructions: "Open this URL in a browser to sign in with LiteLLM.",
950
+ });
951
+ const code = await callbackCode();
952
+ const result = await requestPkceToken(
953
+ discovery.tokenEndpoint,
954
+ new URLSearchParams({
955
+ grant_type: "authorization_code",
956
+ code,
957
+ redirect_uri: redirectUri,
958
+ client_id: registration.client_id,
959
+ code_verifier: verifier,
960
+ resource: discovery.resource,
961
+ }),
962
+ interaction.signal,
963
+ headers,
964
+ );
965
+ if (!result.ok) throw new Error(result.message);
966
+ const credential: PkceCredentials = {
967
+ type: "oauth",
968
+ access: result.token.access,
969
+ refresh: result.token.refresh,
970
+ expires: result.token.expires,
971
+ baseUrl,
972
+ flow: PKCE_FLOW,
973
+ clientId: registration.client_id,
974
+ tokenEndpoint: discovery.tokenEndpoint,
975
+ resource: discovery.resource,
976
+ userId: result.token.userId,
977
+ teamId: result.token.teamId,
978
+ };
979
+ return { ...credential, type: "oauth" };
980
+ },
981
+ );
982
+ }
983
+
984
+ type OidcConfig = { issuer: string; clientId: string; scope: string; redirectPorts: number[] };
985
+
986
+ type OidcDiscovery = { issuer: string; authorizationEndpoint: string; tokenEndpoint: string };
987
+
988
+ type OidcCredentials = OAuthCredentials & {
989
+ flow: typeof OIDC_FLOW;
990
+ baseUrl: string;
991
+ issuer: string;
992
+ clientId: string;
993
+ tokenEndpoint: string;
994
+ subject: string;
995
+ };
996
+
997
+ type OidcTokenResult =
998
+ | { ok: true; token: { access: string; refresh: string; expires: number; subject: string } }
999
+ | TokenFailure;
1000
+
1001
+ /** An https URL without credentials or fragment, returned verbatim. */
1002
+ function httpsUrl(value: unknown, { allowQuery }: { allowQuery: boolean }): string | undefined {
1003
+ if (typeof value !== "string" || value.includes("#") || (!allowQuery && value.includes("?"))) return undefined;
1004
+ try {
1005
+ const url = new URL(value);
1006
+ return url.protocol === "https:" && !url.username && !url.password ? value : undefined;
1007
+ } catch {
1008
+ return undefined;
1009
+ }
1010
+ }
1011
+
1012
+ function parseOidcConfig(raw: unknown): OidcConfig {
1013
+ const invalid = (field: string, expected: string) =>
1014
+ new Error(`Invalid LiteLLM ${field} setting: expected ${expected}`);
1015
+ if (!isPlainObject(raw)) throw invalid("oidc", "an object");
1016
+ const issuer = httpsUrl(raw.issuer, { allowQuery: false });
1017
+ if (!issuer) throw invalid("oidc.issuer", "an https URL without credentials, query, or fragment");
1018
+ if (!isAuthToken(raw.clientId)) throw invalid("oidc.clientId", "a non-empty string without spaces");
1019
+ const scope = raw.scope ?? "openid";
1020
+ if (typeof scope !== "string" || !scope.split(" ").includes("openid"))
1021
+ throw invalid("oidc.scope", 'a space-separated scope list that includes "openid"');
1022
+ const redirectPorts = raw.redirectPorts ?? [];
1023
+ if (
1024
+ !Array.isArray(redirectPorts) ||
1025
+ !redirectPorts.every((port) => Number.isInteger(port) && port >= 1 && port <= 65535)
1026
+ )
1027
+ throw invalid("oidc.redirectPorts", "an array of port numbers from 1 to 65535");
1028
+ return { issuer, clientId: raw.clientId, scope, redirectPorts };
1029
+ }
1030
+
1031
+ // IdP requests never carry the proxy's headers: LITELLM_HEADERS and provider headers can hold gateway credentials.
1032
+ async function discoverOidc(issuer: string, signal?: AbortSignal): Promise<OidcDiscovery> {
1033
+ const withoutSlash = (value: string) => value.replace(/\/+$/, "");
1034
+ const response = await fetch(`${withoutSlash(issuer)}${OIDC_DISCOVERY_PATH}`, {
1035
+ headers: authRequestHeaders(),
1036
+ redirect: "manual",
1037
+ signal: boundedLoginSignal(signal),
1038
+ });
1039
+ if (!response.ok) throw new Error(`OIDC discovery failed (HTTP ${response.status})`);
1040
+ const data = await readAuthJson(response, signal, "OIDC discovery");
1041
+ if (!isPlainObject(data) || typeof data.issuer !== "string" || withoutSlash(data.issuer) !== withoutSlash(issuer))
1042
+ throw new Error("OIDC discovery issuer does not match the configured issuer");
1043
+ const methods = data.code_challenge_methods_supported;
1044
+ if (methods !== undefined && (!Array.isArray(methods) || !methods.includes("S256")))
1045
+ throw new Error("OIDC discovery does not support S256");
1046
+ const authorizationEndpoint = httpsUrl(data.authorization_endpoint, { allowQuery: true });
1047
+ if (!authorizationEndpoint) throw new Error("OIDC discovery has invalid authorization_endpoint");
1048
+ const tokenEndpoint = httpsUrl(data.token_endpoint, { allowQuery: true });
1049
+ if (!tokenEndpoint) throw new Error("OIDC discovery has invalid token_endpoint");
1050
+ return { issuer: data.issuer, authorizationEndpoint, tokenEndpoint };
1051
+ }
1052
+
1053
+ function jwtClaims(token: string): Record<string, unknown> | undefined {
1054
+ const [, payload, ...rest] = token.split(".");
1055
+ if (rest.length !== 1 || !payload || !/^[\w-]+$/.test(payload)) return undefined;
1056
+ try {
1057
+ const claims: unknown = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1058
+ return isPlainObject(claims) ? claims : undefined;
1059
+ } catch {
1060
+ return undefined;
1061
+ }
1062
+ }
1063
+
1064
+ /**
1065
+ * Exchanges a grant for an id_token and checks its claims (OIDC Core §3.1.3.7). The signature is
1066
+ * deliberately not verified here: the proxy verifies it against the IdP's JWKS before honouring the
1067
+ * bearer, so these checks only stop Pi from storing a token meant for another client or login.
1068
+ */
1069
+ async function requestOidcToken(
1070
+ endpoint: string,
1071
+ form: URLSearchParams,
1072
+ signal: AbortSignal | undefined,
1073
+ expected: { issuer: string; clientId: string; nonce?: string; subject?: string },
1074
+ existingRefreshToken = "",
1075
+ ): Promise<OidcTokenResult> {
1076
+ const response = await postTokenForm(endpoint, form, signal, undefined, "OIDC");
1077
+ if (!response.ok) return response;
1078
+ const { id_token: idToken, refresh_token: refreshToken } = response.data;
1079
+ const claims = isAuthToken(idToken) ? jwtClaims(idToken) : undefined;
1080
+ if (!isAuthToken(idToken) || !claims)
1081
+ return { ok: false, transient: false, message: "OIDC token response has no valid id_token" };
1082
+ const invalid = (claim: string): TokenFailure => ({
1083
+ ok: false,
1084
+ transient: false,
1085
+ message: `OIDC id_token has invalid ${claim}`,
1086
+ });
1087
+ if (claims.iss !== expected.issuer) return invalid("iss");
1088
+ if (!(Array.isArray(claims.aud) ? claims.aud : [claims.aud]).includes(expected.clientId)) return invalid("aud");
1089
+ // OIDC Core §3.1.3.7 (errata set 2): azp is optional, even with several audiences, but when present it must name us.
1090
+ if (claims.azp !== undefined && claims.azp !== expected.clientId) return invalid("azp");
1091
+ if (typeof claims.sub !== "string" || !claims.sub) return invalid("sub");
1092
+ if (expected.subject !== undefined && claims.sub !== expected.subject) return invalid("sub");
1093
+ const expiresAt = typeof claims.exp === "number" ? Math.floor(claims.exp * 1_000) : NaN;
1094
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now()) return invalid("exp");
1095
+ if (expected.nonce !== undefined && !constantTimeEqual(claims.nonce, expected.nonce)) return invalid("nonce");
1096
+ return {
1097
+ ok: true,
1098
+ token: {
1099
+ access: idToken,
1100
+ // The refresh token is optional, and an IdP that does not rotate it keeps the existing one valid.
1101
+ refresh: isAuthToken(refreshToken) ? refreshToken : existingRefreshToken,
1102
+ // Refresh ahead of expiry, but by at most half the remaining lifetime, so a short-lived id_token is
1103
+ // still used rather than treated as already expired.
1104
+ expires: expiresAt - Math.ceil(Math.min(TOKEN_REFRESH_LEAD_MS, (expiresAt - Date.now()) / 2)),
1105
+ subject: claims.sub,
1106
+ },
1107
+ };
1108
+ }
1109
+
1110
+ async function loginOidc(interaction: AuthInteraction, baseUrl: string, config: OidcConfig): Promise<OAuthCredential> {
1111
+ const discovery = await discoverOidc(config.issuer, interaction.signal);
1112
+ const verifier = randomBytes(32).toString("base64url");
1113
+ const nonce = randomBytes(32).toString("base64url");
1114
+ return withLoopbackCallback(
1115
+ interaction.signal,
1116
+ "OIDC",
1117
+ config.redirectPorts,
1118
+ async ({ redirectUri, state, code }) => {
1119
+ const authorizationUrl = new URL(discovery.authorizationEndpoint);
1120
+ for (const [key, value] of Object.entries({
1121
+ response_type: "code",
1122
+ client_id: config.clientId,
1123
+ redirect_uri: redirectUri,
1124
+ scope: config.scope,
1125
+ state,
1126
+ nonce,
1127
+ code_challenge: createHash("sha256").update(verifier).digest("base64url"),
1128
+ code_challenge_method: "S256",
1129
+ }))
1130
+ authorizationUrl.searchParams.set(key, value);
1131
+ interaction.notify({
1132
+ type: "auth_url",
1133
+ url: authorizationUrl.toString(),
1134
+ instructions: "Open this URL in a browser to sign in with your identity provider.",
1135
+ });
1136
+ const result = await requestOidcToken(
1137
+ discovery.tokenEndpoint,
1138
+ new URLSearchParams({
1139
+ grant_type: "authorization_code",
1140
+ code: await code(),
1141
+ redirect_uri: redirectUri,
1142
+ client_id: config.clientId,
1143
+ code_verifier: verifier,
1144
+ }),
1145
+ interaction.signal,
1146
+ { issuer: discovery.issuer, clientId: config.clientId, nonce },
1147
+ );
1148
+ if (!result.ok) throw new Error(result.message);
1149
+ const credential: OidcCredentials = {
1150
+ type: "oauth",
1151
+ access: result.token.access,
1152
+ refresh: result.token.refresh,
1153
+ expires: result.token.expires,
1154
+ baseUrl,
1155
+ flow: OIDC_FLOW,
1156
+ issuer: discovery.issuer,
1157
+ clientId: config.clientId,
1158
+ tokenEndpoint: discovery.tokenEndpoint,
1159
+ subject: result.token.subject,
1160
+ };
1161
+ return { ...credential, type: "oauth" };
1162
+ },
1163
+ );
1164
+ }
1165
+
909
1166
  async function startCliSso(
910
1167
  baseUrl: string,
911
1168
  signal?: AbortSignal,
@@ -1055,8 +1312,12 @@ async function loginWithPastedToken(
1055
1312
  }
1056
1313
 
1057
1314
  async function loginOAuth(interaction: AuthInteraction, definition: ProviderDefinition): Promise<OAuthCredential> {
1058
- const headers = resolveHeaders(definition);
1315
+ const oidc = definition.oidc === undefined ? undefined : parseOidcConfig(definition.oidc);
1059
1316
  const baseUrl = await promptBaseUrl(interaction, definition);
1317
+ // Direct OIDC sends nothing to the proxy. The IdP is never derived from it either: the proxy's own OAuth
1318
+ // metadata describes LiteLLM's authorization server, not the IdP that signs the JWTs it accepts.
1319
+ if (oidc) return loginOidc(interaction, baseUrl, oidc);
1320
+ const headers = resolveHeaders(definition);
1060
1321
  const discovery = await discoverPkce(baseUrl, interaction.signal, headers);
1061
1322
  if (discovery) return loginPkce(interaction, baseUrl, discovery, headers);
1062
1323
  const cliSso = await startCliSso(baseUrl, interaction.signal, headers);
@@ -1085,6 +1346,24 @@ async function loginOAuth(interaction: AuthInteraction, definition: ProviderDefi
1085
1346
  // still-failing token endpoint (e.g. during an outage or rate limit).
1086
1347
  const pkceTransientRefreshBackoff = new Map<string, number>();
1087
1348
 
1349
+ async function refreshWithBackoff(
1350
+ credentials: OAuthCredentials,
1351
+ request: () => Promise<PkceTokenResult | OidcTokenResult>,
1352
+ ): Promise<OAuthCredentials> {
1353
+ if (Date.now() < credentials.expires) {
1354
+ const backoffUntil = pkceTransientRefreshBackoff.get(credentials.refresh);
1355
+ if (backoffUntil !== undefined && Date.now() < backoffUntil) return credentials;
1356
+ }
1357
+ const result = await request();
1358
+ if (!result.ok && result.transient && Date.now() < credentials.expires) {
1359
+ pkceTransientRefreshBackoff.set(credentials.refresh, Date.now() + PKCE_TRANSIENT_REFRESH_BACKOFF_MS);
1360
+ return credentials;
1361
+ }
1362
+ pkceTransientRefreshBackoff.delete(credentials.refresh);
1363
+ if (!result.ok) throw new Error(`${result.message}; run /login litellm again`);
1364
+ return { ...credentials, ...result.token };
1365
+ }
1366
+
1088
1367
  async function refreshLiteLLM(
1089
1368
  credentials: OAuthCredentials,
1090
1369
  definition: ProviderDefinition,
@@ -1092,46 +1371,61 @@ async function refreshLiteLLM(
1092
1371
  ): Promise<OAuthCredentials> {
1093
1372
  signal?.throwIfAborted();
1094
1373
  if (credentials.flow === PKCE_FLOW) {
1374
+ const { baseUrl, clientId } = credentials;
1095
1375
  if (
1096
- typeof credentials.baseUrl !== "string" ||
1097
- !isAuthToken(credentials.clientId) ||
1376
+ typeof baseUrl !== "string" ||
1377
+ !isAuthToken(clientId) ||
1098
1378
  !isAuthToken(credentials.access) ||
1099
1379
  !isAuthToken(credentials.refresh) ||
1100
1380
  !Number.isSafeInteger(credentials.expires)
1101
1381
  ) {
1102
1382
  throw new Error("Invalid LiteLLM PKCE credential; run /login litellm again");
1103
1383
  }
1104
- if (Date.now() < credentials.expires) {
1105
- const backoffUntil = pkceTransientRefreshBackoff.get(credentials.refresh);
1106
- if (backoffUntil !== undefined && Date.now() < backoffUntil) return credentials;
1384
+ return refreshWithBackoff(credentials, () => {
1385
+ const root = requireCredentialRoot(normalizeBaseUrl(baseUrl, definition.allowInsecureHttp), definition.name);
1386
+ canonicalIssuer(root);
1387
+ const issuer = new URL(root);
1388
+ const tokenEndpoint = sameOriginUrl(credentials.tokenEndpoint, issuer, "token endpoint");
1389
+ const resource = sameOriginUrl(credentials.resource, issuer, "resource");
1390
+ return requestPkceToken(
1391
+ tokenEndpoint,
1392
+ new URLSearchParams({
1393
+ grant_type: "refresh_token",
1394
+ refresh_token: credentials.refresh,
1395
+ client_id: clientId,
1396
+ resource,
1397
+ }),
1398
+ signal,
1399
+ resolveHeaders(definition),
1400
+ credentials.refresh,
1401
+ );
1402
+ });
1403
+ }
1404
+ // Must precede the `!command` fallthrough below: an IdP refresh token is never executed.
1405
+ if (credentials.flow === OIDC_FLOW) {
1406
+ const { refresh, issuer, clientId, subject } = credentials;
1407
+ const tokenEndpoint = httpsUrl(credentials.tokenEndpoint, { allowQuery: true });
1408
+ if (!isAuthToken(refresh))
1409
+ throw new Error("LiteLLM OIDC credential has no refresh token; run /login litellm again");
1410
+ if (
1411
+ !tokenEndpoint ||
1412
+ typeof issuer !== "string" ||
1413
+ !isAuthToken(clientId) ||
1414
+ typeof subject !== "string" ||
1415
+ !subject ||
1416
+ !Number.isSafeInteger(credentials.expires)
1417
+ ) {
1418
+ throw new Error("Invalid LiteLLM OIDC credential; run /login litellm again");
1107
1419
  }
1108
- const baseUrl = requireCredentialRoot(
1109
- normalizeBaseUrl(credentials.baseUrl, definition.allowInsecureHttp),
1110
- definition.name,
1111
- );
1112
- canonicalIssuer(baseUrl);
1113
- const issuer = new URL(baseUrl);
1114
- const tokenEndpoint = sameOriginUrl(credentials.tokenEndpoint, issuer, "token endpoint");
1115
- const resource = sameOriginUrl(credentials.resource, issuer, "resource");
1116
- const result = await requestPkceToken(
1117
- tokenEndpoint,
1118
- new URLSearchParams({
1119
- grant_type: "refresh_token",
1120
- refresh_token: credentials.refresh,
1121
- client_id: credentials.clientId,
1122
- resource,
1123
- }),
1124
- signal,
1125
- resolveHeaders(definition),
1126
- credentials.refresh,
1420
+ return refreshWithBackoff(credentials, () =>
1421
+ requestOidcToken(
1422
+ tokenEndpoint,
1423
+ new URLSearchParams({ grant_type: "refresh_token", refresh_token: refresh, client_id: clientId }),
1424
+ signal,
1425
+ { issuer, clientId, subject },
1426
+ refresh,
1427
+ ),
1127
1428
  );
1128
- if (!result.ok && result.transient && Date.now() < credentials.expires) {
1129
- pkceTransientRefreshBackoff.set(credentials.refresh, Date.now() + PKCE_TRANSIENT_REFRESH_BACKOFF_MS);
1130
- return credentials;
1131
- }
1132
- pkceTransientRefreshBackoff.delete(credentials.refresh);
1133
- if (!result.ok) throw new Error(`${result.message}; run /login litellm again`);
1134
- return { ...credentials, ...result.token };
1135
1429
  }
1136
1430
  if (!credentials.refresh.startsWith("!")) {
1137
1431
  if (credentials.expires < PERMANENT_TOKEN_EXPIRES_AT) {
@@ -1568,6 +1862,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1568
1862
  const definitions = getProviderDefinitions(settings);
1569
1863
  const skillsEnabled = isFeatureEnabled(settings, "skills");
1570
1864
  const mcpEnabled = isFeatureEnabled(settings, "mcp");
1865
+ const mcpExposure = parseMcpExposurePolicy(settings?.mcp);
1571
1866
  const providerNames = new Set(definitions.map((definition) => definition.name));
1572
1867
  const oauthRuntimeRoots = new Map<string, { apiKey: string; root: string }>();
1573
1868
  let mcpUI: ExtensionContext["ui"] | undefined;
@@ -1593,6 +1888,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1593
1888
  sessionStarted = false;
1594
1889
  pendingMcpMessages.clear();
1595
1890
  });
1891
+ for (const warning of mcpExposure.warnings) notifyMcp(`LiteLLM MCP: ${warning}`);
1596
1892
 
1597
1893
  function discoveryDisabledReason(): string | null {
1598
1894
  if (isOffline()) return `${ENV_OFFLINE}=1`;
@@ -1643,8 +1939,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1643
1939
  };
1644
1940
  }
1645
1941
 
1646
- let registeredMcpIdentity: string | undefined;
1647
- let mcpRegistration: Promise<void> | undefined;
1942
+ // Each provider owns its own MCP catalog, keyed by provider name.
1943
+ const registeredMcpIdentities = new Map<string, string>();
1944
+ // Generated tool names each provider registered in its last successful pass; Pi cannot unregister,
1945
+ // so names that vanish from the catalog are withdrawn by re-registering them hidden.
1946
+ const registeredMcpToolNames = new Map<string, Set<string>>();
1947
+ let mcpSeeded = false;
1948
+ const mcpRegistrations = new Map<string, Promise<void>>();
1648
1949
  // Pi's registerTool throws only from assertActive(), whose staleness flag is set with `??=` and
1649
1950
  // never cleared, so a refusal is fatal for this extension instance rather than a per-tool or
1650
1951
  // retryable condition. Once seen, stop attempting registration here; a reload creates a fresh
@@ -1655,7 +1956,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1655
1956
  const mcpPauseInMemory = new Set<string>();
1656
1957
  let mcpPauseSalt: Buffer | undefined;
1657
1958
  let mcpPausePersistent = true;
1658
- let mcpLoginGeneration = 0;
1959
+ // Keyed by provider: only the default provider has /login, and its login must not cancel an
1960
+ // alias's in-flight registration, which login does not retry.
1961
+ const mcpLoginGenerations = new Map<string, number>();
1962
+ const mcpLoginGeneration = (name: string) => mcpLoginGenerations.get(name) ?? 0;
1659
1963
 
1660
1964
  function getMcpPauseSalt(): Buffer {
1661
1965
  if (!mcpPauseSalt) {
@@ -1724,8 +2028,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1724
2028
  }
1725
2029
 
1726
2030
  function resumeMcpDiscovery(): void {
1727
- mcpLoginGeneration += 1;
1728
- registeredMcpIdentity = undefined;
2031
+ mcpLoginGenerations.set(PROVIDER_NAME, mcpLoginGeneration(PROVIDER_NAME) + 1);
2032
+ registeredMcpIdentities.delete(PROVIDER_NAME);
1729
2033
  // The new login ID selects a fresh scope; other processes may still use the old scopes.
1730
2034
  }
1731
2035
 
@@ -1746,17 +2050,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1746
2050
  });
1747
2051
  }
1748
2052
 
1749
- async function getRuntimeAuth(ctx: ExtensionContext): Promise<LiteLLMRuntimeAuth | undefined> {
1750
- const auth = await ctx.modelRegistry.getProviderAuth(PROVIDER_NAME);
2053
+ async function getRuntimeAuth(
2054
+ ctx: ExtensionContext,
2055
+ definition = definitions[0]!,
2056
+ ): Promise<LiteLLMRuntimeAuth | undefined> {
2057
+ const auth = await ctx.modelRegistry.getProviderAuth(definition.name);
1751
2058
  if (!auth) return undefined;
1752
- const provider = ctx.modelRegistry.getProvider(PROVIDER_NAME);
2059
+ const provider = ctx.modelRegistry.getProvider(definition.name);
1753
2060
  const apiKey = auth.auth.apiKey;
1754
2061
  const baseUrl = cleanConfig(auth.auth.baseUrl) ?? cleanConfig(auth.env?.[ENV_BASE_URL]) ?? provider?.baseUrl;
1755
2062
  if (!baseUrl || !apiKey) return undefined;
1756
- const runtimeRoot = requireCredentialRoot(
1757
- normalizeBaseUrl(baseUrl, definitions[0]?.allowInsecureHttp),
1758
- PROVIDER_NAME,
1759
- );
2063
+ const runtimeRoot = requireCredentialRoot(normalizeBaseUrl(baseUrl, definition.allowInsecureHttp), definition.name);
1760
2064
  const headers = Object.fromEntries(
1761
2065
  Object.entries(auth.auth.headers ?? provider?.headers ?? {}).filter(
1762
2066
  (entry): entry is [string, string] => typeof entry[1] === "string",
@@ -1766,16 +2070,23 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1766
2070
  baseUrl: runtimeRoot,
1767
2071
  apiKey,
1768
2072
  headers: Object.keys(headers).length > 0 ? headers : undefined,
1769
- allowInsecureHttp: definitions[0]?.allowInsecureHttp,
2073
+ allowInsecureHttp: definition.allowInsecureHttp,
1770
2074
  };
1771
2075
  }
1772
2076
 
2077
+ function missingCredentials(definition: ProviderDefinition): string {
2078
+ const fix = definition.name === PROVIDER_NAME ? "Run /login litellm or set env vars" : "Set its baseUrl and apiKey";
2079
+ return `no credentials for ${definition.name}. ${fix}.`;
2080
+ }
2081
+
2082
+ async function requireRuntimeAuth(ctx: ExtensionContext, definition = definitions[0]!): Promise<LiteLLMRuntimeAuth> {
2083
+ const auth = await getRuntimeAuth(ctx, definition);
2084
+ if (auth) return auth;
2085
+ throw new Error(missingCredentials(definition));
2086
+ }
2087
+
1773
2088
  async function resolveDefaultRuntimeAuth(ctx?: ExtensionContext): Promise<LiteLLMRuntimeAuth> {
1774
- if (ctx?.modelRegistry) {
1775
- const auth = await getRuntimeAuth(ctx);
1776
- if (auth) return auth;
1777
- throw new Error("no credentials for litellm. Run /login litellm or set env vars.");
1778
- }
2089
+ if (ctx?.modelRegistry) return requireRuntimeAuth(ctx);
1779
2090
  if (!defaultRuntimeAuth) throw new Error("no credentials for litellm. Run /login litellm or set env vars.");
1780
2091
  return defaultRuntimeAuth;
1781
2092
  }
@@ -1798,34 +2109,109 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1798
2109
  });
1799
2110
  }
1800
2111
 
1801
- async function registerMcpTools(auth: McpRuntimeAuth, credential: Credential, signal?: AbortSignal): Promise<void> {
2112
+ // Pi before 0.99 reports no `exposure` and declares every registered tool.
2113
+ function piHonorsToolExposure(): boolean {
2114
+ return pi.getAllTools().some((tool) => "exposure" in tool);
2115
+ }
2116
+
2117
+ const WITHDRAWN_MCP_MESSAGE = "This LiteLLM MCP tool is no longer offered by the proxy.";
2118
+ function withdrawMcpTools(names: ReadonlySet<string>): void {
2119
+ for (const name of names) {
2120
+ // Older Pi ignores `exposure`, so the stub stays callable and must fail without calling the proxy.
2121
+ pi.registerTool({
2122
+ name,
2123
+ label: "Withdrawn LiteLLM MCP tool",
2124
+ description: WITHDRAWN_MCP_MESSAGE,
2125
+ parameters: Type.Object({}),
2126
+ exposure: "hidden",
2127
+ async execute() {
2128
+ throw new Error(WITHDRAWN_MCP_MESSAGE);
2129
+ },
2130
+ });
2131
+ }
2132
+ if (piHonorsToolExposure()) pi.setActiveTools(pi.getActiveTools().filter((name) => !names.has(name)));
2133
+ }
2134
+
2135
+ let warnedMcpExposureIgnored = false;
2136
+ let warnedMcpUnreachable = false;
2137
+ // As in Pi's own MCP extension: tools that are not declared to the model are reached through
2138
+ // codemode (`codemode`, `codemode-deferred`) or tool_search (`deferred`). Activate the one the
2139
+ // configured exposure needs, and say so once when neither is available.
2140
+ function activateMcpDiscoveryTools(exposures: readonly McpExposure[]): void {
2141
+ const needsCodemode = exposures.includes("codemode") || exposures.includes("codemode-deferred");
2142
+ const needsToolSearch = exposures.includes("deferred");
2143
+ if (!needsCodemode && !needsToolSearch) return;
2144
+ const tools = pi.getAllTools();
2145
+ // Without exposure support every registered tool is declared, so nothing needs activating.
2146
+ if (!piHonorsToolExposure()) {
2147
+ if (warnedMcpExposureIgnored) return;
2148
+ warnedMcpExposureIgnored = true;
2149
+ notifyMcp(
2150
+ "LiteLLM MCP: this Pi version ignores litellm.mcp.exposure; MCP tools are declared directly to the model.",
2151
+ );
2152
+ return;
2153
+ }
2154
+ const hasCodemode = tools.some((tool) => tool.name === "codemode");
2155
+ const hasToolSearch = tools.some((tool) => tool.name === "tool_search");
2156
+ const active = pi.getActiveTools();
2157
+ const activate = [
2158
+ ...(needsCodemode && hasCodemode && !active.includes("codemode") ? ["codemode"] : []),
2159
+ ...(needsToolSearch && hasToolSearch && !active.includes("tool_search") ? ["tool_search"] : []),
2160
+ ];
2161
+ if (activate.length > 0) pi.setActiveTools([...active, ...activate]);
2162
+ const reachable = [...active, ...activate];
2163
+ if ((hasCodemode && reachable.includes("codemode")) || (hasToolSearch && reachable.includes("tool_search"))) return;
2164
+ if (warnedMcpUnreachable) return;
2165
+ warnedMcpUnreachable = true;
2166
+ notifyMcp(
2167
+ "LiteLLM MCP: tools with codemode or deferred exposure are reachable only through Pi's codemode or tool_search tool, and neither is available; they cannot be called.",
2168
+ );
2169
+ }
2170
+
2171
+ async function registerMcpTools(
2172
+ definition: ProviderDefinition,
2173
+ auth: McpRuntimeAuth,
2174
+ credential: Credential,
2175
+ signal?: AbortSignal,
2176
+ ): Promise<void> {
1802
2177
  if (!mcpEnabled || discoveryDisabledReason() || mcpRegistrationFatal || isMcpPaused(auth, credential)) return;
1803
- const loginGeneration = mcpLoginGeneration;
2178
+ const loginGeneration = mcpLoginGeneration(definition.name);
1804
2179
  const identity = mcpCatalogIdentity(auth, credential);
1805
- while (mcpRegistration) {
1806
- await waitForMcpRegistration(mcpRegistration, signal);
2180
+ const isDefault = definition.name === PROVIDER_NAME;
2181
+ // The default provider keeps its unprefixed tool names and diagnostics; an alias is scoped by name.
2182
+ const namespace = isDefault ? undefined : definition.name;
2183
+ const notify = isDefault
2184
+ ? notifyMcp
2185
+ : Object.assign((message: string) => notifyMcp(message), { incidentScope: definition.name });
2186
+ const label = isDefault ? "LiteLLM MCP" : `LiteLLM MCP (${JSON.stringify(definition.name)})`;
2187
+ let pending = mcpRegistrations.get(definition.name);
2188
+ while (pending) {
2189
+ await waitForMcpRegistration(pending, signal);
1807
2190
  signal?.throwIfAborted();
1808
2191
  if (
1809
2192
  mcpRegistrationFatal ||
1810
2193
  isMcpPaused(auth, credential) ||
1811
- loginGeneration !== mcpLoginGeneration ||
1812
- registeredMcpIdentity === identity
2194
+ loginGeneration !== mcpLoginGeneration(definition.name) ||
2195
+ registeredMcpIdentities.get(definition.name) === identity
1813
2196
  )
1814
2197
  return;
2198
+ pending = mcpRegistrations.get(definition.name);
1815
2199
  }
1816
- if (registeredMcpIdentity === identity) return;
2200
+ if (registeredMcpIdentities.get(definition.name) === identity) return;
1817
2201
 
1818
2202
  const registration = (async () => {
1819
2203
  try {
1820
2204
  signal?.throwIfAborted();
1821
2205
  const { definitions, report } = await createMcpToolDefinitions(
1822
- (ctx) => (ctx?.modelRegistry ? resolveDefaultRuntimeAuth(ctx) : Promise.resolve(auth)),
1823
- isVerboseDiscovery() ? (message) => notifyMcp(`LiteLLM MCP: ${message}`, "info") : undefined,
2206
+ (ctx) => (ctx?.modelRegistry ? requireRuntimeAuth(ctx, definition) : Promise.resolve(auth)),
2207
+ isVerboseDiscovery() ? (message) => notifyMcp(`${label}: ${message}`, "info") : undefined,
1824
2208
  signal,
1825
- notifyMcp,
2209
+ notify,
2210
+ namespace,
2211
+ mcpExposure.policy,
1826
2212
  );
1827
2213
  signal?.throwIfAborted();
1828
- if (loginGeneration !== mcpLoginGeneration) return;
2214
+ if (loginGeneration !== mcpLoginGeneration(definition.name)) return;
1829
2215
  const registeredNames: string[] = [];
1830
2216
  try {
1831
2217
  for (const definition of definitions) {
@@ -1840,14 +2226,35 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1840
2226
  // Fatal for this instance: report once, with a bounded Pi-authored cause and no proxy text,
1841
2227
  // then stop retrying so a stale instance cannot churn discovery on every later refresh.
1842
2228
  mcpRegistrationFatal = true;
1843
- reportMcpRegistrationFatal(registeredNames.length, definitions.length, error, notifyMcp);
2229
+ reportMcpRegistrationFatal(registeredNames.length, definitions.length, error, notify);
2230
+ return;
2231
+ }
2232
+ reportMcpRegistrationSuccess(notify);
2233
+ reportMcpPartialDiscovery(report.partialFailure, registeredNames, notify);
2234
+ const previousNames = registeredMcpToolNames.get(definition.name) ?? new Set<string>();
2235
+ const currentNames = new Set(registeredNames);
2236
+ // A partial or empty catalog may omit live tools, so it withdraws nothing and keeps the old names.
2237
+ const settled = definitions.length > 0 && !report.partialFailure;
2238
+ const withdrawn = settled ? new Set([...previousNames].filter((n) => !currentNames.has(n))) : new Set<string>();
2239
+ try {
2240
+ if (withdrawn.size > 0) withdrawMcpTools(withdrawn);
2241
+ } catch (error) {
2242
+ if (signal?.aborted) throw signal.reason;
2243
+ mcpRegistrationFatal = true;
2244
+ reportMcpRegistrationFatal(registeredNames.length, definitions.length, error, notify);
1844
2245
  return;
1845
2246
  }
1846
- reportMcpRegistrationSuccess();
1847
- reportMcpPartialDiscovery(report.partialFailure, registeredNames, notifyMcp);
2247
+ if (definitions.length > 0) {
2248
+ if (!settled) for (const name of previousNames) currentNames.add(name);
2249
+ registeredMcpToolNames.set(definition.name, currentNames);
2250
+ }
2251
+ if (withdrawn.size > 0 && isVerboseDiscovery()) {
2252
+ notifyMcp(`${label}: withdrew ${withdrawn.size} MCP tools no longer offered by the proxy.`, "info");
2253
+ }
2254
+ activateMcpDiscoveryTools(report.exposures);
1848
2255
  if (isVerboseDiscovery()) {
1849
2256
  notifyMcp(
1850
- `LiteLLM MCP: registered ${registeredNames.length} of ${definitions.length} prepared MCP tools ` +
2257
+ `${label}: registered ${registeredNames.length} of ${definitions.length} prepared MCP tools ` +
1851
2258
  `(${report.discovered} raw, ${report.enveloped} enveloped).`,
1852
2259
  "info",
1853
2260
  );
@@ -1855,26 +2262,29 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1855
2262
  // A catalog that produced nothing or came from a partial-failure response is not settled:
1856
2263
  // leaving the identity unset lets a later refresh retry discovery, which is network-only and
1857
2264
  // non-blocking. Re-registering surviving tools is safe because Pi replaces tools by name.
1858
- reportMcpCatalogOutcome(report.discovered, definitions.length, notifyMcp);
1859
- if (definitions.length > 0 && !report.partialFailure) registeredMcpIdentity = identity;
2265
+ reportMcpCatalogOutcome(report.discovered, definitions.length, notify);
2266
+ if (definitions.length > 0 && !report.partialFailure) registeredMcpIdentities.set(definition.name, identity);
1860
2267
  } catch (error) {
1861
2268
  if (signal?.aborted) throw signal.reason;
1862
- if (loginGeneration !== mcpLoginGeneration) return;
2269
+ if (loginGeneration !== mcpLoginGeneration(definition.name)) return;
1863
2270
  if (error instanceof McpAccessDeniedError) {
1864
2271
  pauseMcpDiscovery(auth, credential);
1865
- notifyMcp("LiteLLM MCP: access denied; discovery paused until /login litellm succeeds.");
2272
+ // Aliases have no /login; their pause scope is their URL, key, and headers, so a change lifts it.
2273
+ notifyMcp(
2274
+ `${label}: access denied; discovery paused until ${isDefault ? "/login litellm succeeds" : "its credentials change"}.`,
2275
+ );
1866
2276
  return;
1867
2277
  }
1868
2278
  notifyMcp(
1869
- `LiteLLM (${PROVIDER_NAME}): MCP tool discovery failed (${error instanceof Error ? error.message : String(error)}).`,
2279
+ `LiteLLM (${definition.name}): MCP tool discovery failed (${error instanceof Error ? error.message : String(error)}).`,
1870
2280
  );
1871
2281
  }
1872
2282
  })();
1873
- mcpRegistration = registration;
2283
+ mcpRegistrations.set(definition.name, registration);
1874
2284
  try {
1875
2285
  await registration;
1876
2286
  } finally {
1877
- if (mcpRegistration === registration) mcpRegistration = undefined;
2287
+ if (mcpRegistrations.get(definition.name) === registration) mcpRegistrations.delete(definition.name);
1878
2288
  }
1879
2289
  }
1880
2290
 
@@ -1896,7 +2306,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1896
2306
  // network phase, so the provider would stay empty until the user opens /model. Discover here
1897
2307
  // instead, the way 1.x did, and hand the catalog over as the provider's baseline models.
1898
2308
  async function seedModels(definition: ProviderDefinition): Promise<Model<LiteLLMApi>[]> {
1899
- if (discoveryDisabledReason() || isHostOffline()) return [];
2309
+ const disabledReason = discoveryDisabledReason() ?? (isHostOffline() ? "PI_OFFLINE" : null);
2310
+ if (disabledReason) {
2311
+ if (isVerboseDiscovery()) {
2312
+ process.stderr.write(`LiteLLM (${definition.name}): startup discovery skipped (${disabledReason}).\n`);
2313
+ }
2314
+ return [];
2315
+ }
1900
2316
  try {
1901
2317
  const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
1902
2318
  // executeHelpers:false — activation must never run the user's key helper as a side effect,
@@ -1916,6 +2332,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1916
2332
  }
1917
2333
 
1918
2334
  const seeded = await Promise.all(definitions.map(seedModels));
2335
+ // Pi runs a provider's network phase only once a credential resolves, so /litellm-refresh reads an
2336
+ // unchanged count as "no credentials" rather than as a successful refresh.
2337
+ const networkRefreshAttempts = new Map<string, number>();
1919
2338
 
1920
2339
  for (const [index, definition] of definitions.entries()) {
1921
2340
  const auth = createProviderAuth(
@@ -1975,24 +2394,26 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1975
2394
  Object.assign(provider, { headers: resolveHeaders(definition) });
1976
2395
  const refreshModels = provider.refreshModels!;
1977
2396
  provider.refreshModels = async (context) => {
1978
- const loginGeneration = mcpLoginGeneration;
2397
+ const loginGeneration = mcpLoginGeneration(definition.name);
2398
+ if (context.allowNetwork) {
2399
+ networkRefreshAttempts.set(definition.name, (networkRefreshAttempts.get(definition.name) ?? 0) + 1);
2400
+ }
1979
2401
  try {
1980
2402
  await refreshModels(context);
1981
2403
  } finally {
1982
2404
  if (
1983
- definition.name === PROVIDER_NAME &&
1984
2405
  context.allowNetwork &&
1985
2406
  !discoveryDisabledReason() &&
1986
- loginGeneration === mcpLoginGeneration &&
2407
+ loginGeneration === mcpLoginGeneration(definition.name) &&
1987
2408
  context.credential
1988
2409
  ) {
1989
2410
  // Best-effort: refreshing the cached default auth / MCP catalog must not let a bad or
1990
2411
  // placeholder credential override refreshModels' own try/throw outcome via `finally`.
1991
2412
  try {
1992
2413
  const auth = await authForCredential(definition, context.credential);
1993
- if (loginGeneration === mcpLoginGeneration) {
1994
- defaultRuntimeAuth = auth;
1995
- void registerMcpTools(auth, context.credential, context.signal).catch(() => undefined);
2414
+ if (loginGeneration === mcpLoginGeneration(definition.name)) {
2415
+ if (definition.name === PROVIDER_NAME) defaultRuntimeAuth = auth;
2416
+ void registerMcpTools(definition, auth, context.credential, context.signal).catch(() => undefined);
1996
2417
  }
1997
2418
  } catch {
1998
2419
  // ignored — authForCredential already reported/will report this via the paths that use it directly.
@@ -2003,6 +2424,54 @@ export default async function (pi: ExtensionAPI): Promise<void> {
2003
2424
  pi.registerProvider(provider);
2004
2425
  }
2005
2426
 
2427
+ // Pi documents PI_OFFLINE as disabling model catalog refreshes, including the one /model starts, and a
2428
+ // provider cannot tell that refresh from an automatic one. This is the explicit refresh: it reaches only
2429
+ // the configured proxies, so LITELLM_OFFLINE=1 and a zero timeout still disable it and models.dev stays off.
2430
+ pi.registerCommand("litellm-refresh", {
2431
+ description: "Refresh LiteLLM model catalogs from the proxy, even with PI_OFFLINE set",
2432
+ getArgumentCompletions: (prefix) => {
2433
+ const matches = [...providerNames].filter((name) => name.startsWith(prefix));
2434
+ return matches.length > 0 ? matches.map((name) => ({ value: name, label: name })) : null;
2435
+ },
2436
+ handler: async (args, ctx) => {
2437
+ const notify = (message: string, level: "info" | "warning"): void => {
2438
+ if (ctx.hasUI) ctx.ui.notify(message, level);
2439
+ else process.stderr.write(`${message}\n`);
2440
+ };
2441
+ const disabledReason = discoveryDisabledReason();
2442
+ if (disabledReason) {
2443
+ notify(`LiteLLM: model refresh skipped (${disabledReason}).`, "warning");
2444
+ return;
2445
+ }
2446
+ const requested = args.trim();
2447
+ const selected = requested ? definitions.filter(({ name }) => name === requested) : definitions;
2448
+ if (selected.length === 0) {
2449
+ const configured = [...providerNames].map((name) => JSON.stringify(name)).join(", ");
2450
+ notify(`LiteLLM: unknown provider ${JSON.stringify(requested)}; configured: ${configured}.`, "warning");
2451
+ return;
2452
+ }
2453
+ const attempts = new Map(selected.map(({ name }) => [name, networkRefreshAttempts.get(name) ?? 0]));
2454
+ const result = await ctx.modelRegistry.refresh({
2455
+ providers: selected.map(({ name }) => name),
2456
+ allowNetwork: true,
2457
+ force: true,
2458
+ });
2459
+ const models = ctx.modelRegistry.getAll();
2460
+ for (const definition of selected) {
2461
+ const label = `LiteLLM (${JSON.stringify(definition.name)})`;
2462
+ const error = result.errors.get(definition.name);
2463
+ if (error) {
2464
+ notify(`${label}: model refresh failed (${error.message}).`, "warning");
2465
+ } else if ((networkRefreshAttempts.get(definition.name) ?? 0) === attempts.get(definition.name)) {
2466
+ notify(`${label}: model refresh skipped; ${missingCredentials(definition)}`, "warning");
2467
+ } else {
2468
+ const count = models.filter((model) => model.provider === definition.name).length;
2469
+ notify(`${label}: refreshed ${count} model${count === 1 ? "" : "s"}.`, "info");
2470
+ }
2471
+ }
2472
+ },
2473
+ });
2474
+
2006
2475
  setupLiteLLMCostTracking(pi, [...providerNames]);
2007
2476
 
2008
2477
  if (skillsEnabled) {
@@ -2026,6 +2495,27 @@ export default async function (pi: ExtensionAPI): Promise<void> {
2026
2495
  );
2027
2496
  });
2028
2497
 
2498
+ // Router fallbacks are invisible to discovery, so protocol and model policy were chosen for the
2499
+ // requested route even when another group answered. Name the route only; header text is proxy-supplied.
2500
+ const warnedFallbackRoutes = new Set<string>();
2501
+ pi.on("after_provider_response", (event, ctx) => {
2502
+ const model = ctx.model;
2503
+ if (!model?.provider || !providerNames.has(model.provider)) return;
2504
+ if (event.status < 200 || event.status >= 300) return;
2505
+ const attemptedFallbacks = Number(event.headers["x-litellm-attempted-fallbacks"]);
2506
+ if (!Number.isSafeInteger(attemptedFallbacks) || attemptedFallbacks <= 0) return;
2507
+ const key = `${model.provider}\0${model.id}`;
2508
+ if (warnedFallbackRoutes.has(key)) return;
2509
+ warnedFallbackRoutes.add(key);
2510
+ const route = JSON.stringify(model.id);
2511
+ const message =
2512
+ `LiteLLM (${JSON.stringify(model.provider)}): a fallback served ${route}, but protocol and model handling were chosen ` +
2513
+ `for ${route}'s own deployments. If its fallbacks cross model families, pin the protocol with ` +
2514
+ "`model_info.supported_endpoints`.";
2515
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
2516
+ else process.stderr.write(`${message}\n`);
2517
+ });
2518
+
2029
2519
  // Skills enrichment is best-effort: an expired credential or an unreachable proxy must not
2030
2520
  // report an extension error on every turn. The failing model request states the real problem.
2031
2521
  pi.on("before_agent_start", async (event, ctx) => {
@@ -2048,6 +2538,40 @@ export default async function (pi: ExtensionAPI): Promise<void> {
2048
2538
  return { systemPrompt: `${event.systemPrompt}\n\n${section}` };
2049
2539
  });
2050
2540
 
2541
+ // MCP tools otherwise register only from refreshModels' `finally`, which needs
2542
+ // context.allowNetwork. Pi core sets that solely for the interactive TUI and the RPC
2543
+ // background refresh, so `-p` and `--list-models` register no MCP tool at all, even when
2544
+ // credentials resolve and the proxy is reachable — the defect #136 fixed for models.
2545
+ //
2546
+ // Seeding here rather than at activation is deliberate: registerMcpTools serialises callers
2547
+ // per provider, so an activation-time call would become an in-flight registration
2548
+ // that Pi's own refresh then waits on. Running once before the first turn keeps that ordering
2549
+ // intact, still precedes any tool use, and leaves startup latency untouched.
2550
+ async function seedMcpTools(definition: ProviderDefinition): Promise<void> {
2551
+ try {
2552
+ const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
2553
+ // executeHelpers:false — seeding must never run the user's key helper as a side effect.
2554
+ const auth = await authForCredential(definition, stored, false);
2555
+ // An env-configured provider has no stored credential and must still seed. The credential
2556
+ // only derives the pause identity, so the resolved key reproduces the stored api_key scope.
2557
+ const credential: Credential = stored ?? { type: "api_key", key: auth.apiKey };
2558
+ await registerMcpTools(definition, auth, credential, AbortSignal.timeout(getSeedTimeoutMs()));
2559
+ } catch (error) {
2560
+ if (isVerboseDiscovery()) {
2561
+ process.stderr.write(
2562
+ `LiteLLM (${definition.name}): MCP seeding skipped (${error instanceof Error ? error.message : String(error)}).\n`,
2563
+ );
2564
+ }
2565
+ }
2566
+ }
2567
+
2568
+ pi.on("before_agent_start", async () => {
2569
+ if (!mcpEnabled || mcpSeeded || discoveryDisabledReason() || isHostOffline()) return;
2570
+ mcpSeeded = true;
2571
+ // Catalogs are independent per provider, so every configured provider seeds in parallel.
2572
+ await Promise.all(definitions.map(seedMcpTools));
2573
+ });
2574
+
2051
2575
  pi.on("message_end", (event, ctx) => {
2052
2576
  if (event.message.role !== "assistant") return;
2053
2577
  // Resolve the discovered model that produced this message so the display