pi-provider-litellm 3.2.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`;
@@ -1645,6 +1941,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1645
1941
 
1646
1942
  // Each provider owns its own MCP catalog, keyed by provider name.
1647
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>>();
1648
1947
  let mcpSeeded = false;
1649
1948
  const mcpRegistrations = new Map<string, Promise<void>>();
1650
1949
  // Pi's registerTool throws only from assertActive(), whose staleness flag is set with `??=` and
@@ -1810,6 +2109,65 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1810
2109
  });
1811
2110
  }
1812
2111
 
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
+
1813
2171
  async function registerMcpTools(
1814
2172
  definition: ProviderDefinition,
1815
2173
  auth: McpRuntimeAuth,
@@ -1850,6 +2208,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1850
2208
  signal,
1851
2209
  notify,
1852
2210
  namespace,
2211
+ mcpExposure.policy,
1853
2212
  );
1854
2213
  signal?.throwIfAborted();
1855
2214
  if (loginGeneration !== mcpLoginGeneration(definition.name)) return;
@@ -1872,6 +2231,27 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1872
2231
  }
1873
2232
  reportMcpRegistrationSuccess(notify);
1874
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);
2245
+ return;
2246
+ }
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);
1875
2255
  if (isVerboseDiscovery()) {
1876
2256
  notifyMcp(
1877
2257
  `${label}: registered ${registeredNames.length} of ${definitions.length} prepared MCP tools ` +