pi-provider-litellm 3.2.0 → 3.4.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,15 +15,18 @@ 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";
21
- import { discoverModels, isGpt55Model, normalizeBaseUrl } from "./discover.js";
22
+ import { discoverModels, isGpt6OrNewerModel, isGpt55OrNewerModel, normalizeBaseUrl } from "./discover.js";
22
23
  import { getGcloudToken, hasGcloudAdcCredentials, isGcloudTokenAuthEnabled } from "./gcloud-token.js";
23
24
  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,25 @@ 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
+ // A transient failure leaves the stored credential intact, and a new login would need the same endpoint.
1364
+ if (!result.ok) throw new Error(result.transient ? result.message : `${result.message}; run /login litellm again`);
1365
+ return { ...credentials, ...result.token };
1366
+ }
1367
+
1088
1368
  async function refreshLiteLLM(
1089
1369
  credentials: OAuthCredentials,
1090
1370
  definition: ProviderDefinition,
@@ -1092,46 +1372,61 @@ async function refreshLiteLLM(
1092
1372
  ): Promise<OAuthCredentials> {
1093
1373
  signal?.throwIfAborted();
1094
1374
  if (credentials.flow === PKCE_FLOW) {
1375
+ const { baseUrl, clientId } = credentials;
1095
1376
  if (
1096
- typeof credentials.baseUrl !== "string" ||
1097
- !isAuthToken(credentials.clientId) ||
1377
+ typeof baseUrl !== "string" ||
1378
+ !isAuthToken(clientId) ||
1098
1379
  !isAuthToken(credentials.access) ||
1099
1380
  !isAuthToken(credentials.refresh) ||
1100
1381
  !Number.isSafeInteger(credentials.expires)
1101
1382
  ) {
1102
1383
  throw new Error("Invalid LiteLLM PKCE credential; run /login litellm again");
1103
1384
  }
1104
- if (Date.now() < credentials.expires) {
1105
- const backoffUntil = pkceTransientRefreshBackoff.get(credentials.refresh);
1106
- if (backoffUntil !== undefined && Date.now() < backoffUntil) return credentials;
1385
+ return refreshWithBackoff(credentials, () => {
1386
+ const root = requireCredentialRoot(normalizeBaseUrl(baseUrl, definition.allowInsecureHttp), definition.name);
1387
+ canonicalIssuer(root);
1388
+ const issuer = new URL(root);
1389
+ const tokenEndpoint = sameOriginUrl(credentials.tokenEndpoint, issuer, "token endpoint");
1390
+ const resource = sameOriginUrl(credentials.resource, issuer, "resource");
1391
+ return requestPkceToken(
1392
+ tokenEndpoint,
1393
+ new URLSearchParams({
1394
+ grant_type: "refresh_token",
1395
+ refresh_token: credentials.refresh,
1396
+ client_id: clientId,
1397
+ resource,
1398
+ }),
1399
+ signal,
1400
+ resolveHeaders(definition),
1401
+ credentials.refresh,
1402
+ );
1403
+ });
1404
+ }
1405
+ // Must precede the `!command` fallthrough below: an IdP refresh token is never executed.
1406
+ if (credentials.flow === OIDC_FLOW) {
1407
+ const { refresh, issuer, clientId, subject } = credentials;
1408
+ const tokenEndpoint = httpsUrl(credentials.tokenEndpoint, { allowQuery: true });
1409
+ if (!isAuthToken(refresh))
1410
+ throw new Error("LiteLLM OIDC credential has no refresh token; run /login litellm again");
1411
+ if (
1412
+ !tokenEndpoint ||
1413
+ typeof issuer !== "string" ||
1414
+ !isAuthToken(clientId) ||
1415
+ typeof subject !== "string" ||
1416
+ !subject ||
1417
+ !Number.isSafeInteger(credentials.expires)
1418
+ ) {
1419
+ throw new Error("Invalid LiteLLM OIDC credential; run /login litellm again");
1107
1420
  }
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,
1421
+ return refreshWithBackoff(credentials, () =>
1422
+ requestOidcToken(
1423
+ tokenEndpoint,
1424
+ new URLSearchParams({ grant_type: "refresh_token", refresh_token: refresh, client_id: clientId }),
1425
+ signal,
1426
+ { issuer, clientId, subject },
1427
+ refresh,
1428
+ ),
1127
1429
  );
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
1430
  }
1136
1431
  if (!credentials.refresh.startsWith("!")) {
1137
1432
  if (credentials.expires < PERMANENT_TOKEN_EXPIRES_AT) {
@@ -1419,12 +1714,13 @@ export function prepareLiteLLMRequestPayload(
1419
1714
  for (const [key, value] of Object.entries(REASONING_VISIBILITY_DEFAULTS)) update(key, value);
1420
1715
  }
1421
1716
 
1422
- // LiteLLM still routes gpt-5.5 tool+reasoning requests through chat completions.
1423
- // Drop reasoning until the gateway honors /v1/responses for this route.
1717
+ // GPT-5.5 and later reject reasoning alongside function tools on Chat Completions.
1718
+ // Drop it until the gateway serves /v1/responses for the route. Discovery decides
1719
+ // from deployment evidence; only a model with no backend evidence uses its route name.
1720
+ const routeOnlyId = model?.litellmBackendFamily === undefined ? modelId : undefined;
1424
1721
  if (
1425
1722
  openAIApi === "openai-completions" &&
1426
- modelId &&
1427
- isGpt55Model(modelId) &&
1723
+ (modelPolicy?.dropToolReasoning === true || (routeOnlyId !== undefined && isGpt55OrNewerModel(routeOnlyId))) &&
1428
1724
  Array.isArray(payload.tools) &&
1429
1725
  payload.tools.length > 0
1430
1726
  ) {
@@ -1433,6 +1729,17 @@ export function prepareLiteLLMRequestPayload(
1433
1729
  next ??= { ...payload };
1434
1730
  delete next[key];
1435
1731
  }
1732
+ // Send the model's own off effort when it declares one. GPT-6 also rejects an
1733
+ // omitted effort (it falls back to its default), and its error names `none` as
1734
+ // the fix, so it gets `none` even without carrier evidence for an off level.
1735
+ const declaredOff = model?.thinkingLevelMap?.off;
1736
+ const explicitOff =
1737
+ modelPolicy?.explicitToolReasoningOff === true || (routeOnlyId !== undefined && isGpt6OrNewerModel(routeOnlyId));
1738
+ const off = typeof declaredOff === "string" ? declaredOff : explicitOff ? "none" : undefined;
1739
+ if (off !== undefined && (next ?? payload).reasoning_effort !== off) {
1740
+ next ??= { ...payload };
1741
+ next.reasoning_effort = off;
1742
+ }
1436
1743
  const include = (next ?? payload).include;
1437
1744
  if (Array.isArray(include) && include.includes("reasoning.encrypted_content")) {
1438
1745
  next ??= { ...payload };
@@ -1568,6 +1875,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1568
1875
  const definitions = getProviderDefinitions(settings);
1569
1876
  const skillsEnabled = isFeatureEnabled(settings, "skills");
1570
1877
  const mcpEnabled = isFeatureEnabled(settings, "mcp");
1878
+ const mcpExposure = parseMcpExposurePolicy(settings?.mcp);
1571
1879
  const providerNames = new Set(definitions.map((definition) => definition.name));
1572
1880
  const oauthRuntimeRoots = new Map<string, { apiKey: string; root: string }>();
1573
1881
  let mcpUI: ExtensionContext["ui"] | undefined;
@@ -1593,6 +1901,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1593
1901
  sessionStarted = false;
1594
1902
  pendingMcpMessages.clear();
1595
1903
  });
1904
+ for (const warning of mcpExposure.warnings) notifyMcp(`LiteLLM MCP: ${warning}`);
1596
1905
 
1597
1906
  function discoveryDisabledReason(): string | null {
1598
1907
  if (isOffline()) return `${ENV_OFFLINE}=1`;
@@ -1645,6 +1954,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1645
1954
 
1646
1955
  // Each provider owns its own MCP catalog, keyed by provider name.
1647
1956
  const registeredMcpIdentities = new Map<string, string>();
1957
+ // Generated tool names each provider registered in its last successful pass; Pi cannot unregister,
1958
+ // so names that vanish from the catalog are withdrawn by re-registering them hidden.
1959
+ const registeredMcpToolNames = new Map<string, Set<string>>();
1648
1960
  let mcpSeeded = false;
1649
1961
  const mcpRegistrations = new Map<string, Promise<void>>();
1650
1962
  // Pi's registerTool throws only from assertActive(), whose staleness flag is set with `??=` and
@@ -1810,6 +2122,65 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1810
2122
  });
1811
2123
  }
1812
2124
 
2125
+ // Pi before 0.99 reports no `exposure` and declares every registered tool.
2126
+ function piHonorsToolExposure(): boolean {
2127
+ return pi.getAllTools().some((tool) => "exposure" in tool);
2128
+ }
2129
+
2130
+ const WITHDRAWN_MCP_MESSAGE = "This LiteLLM MCP tool is no longer offered by the proxy.";
2131
+ function withdrawMcpTools(names: ReadonlySet<string>): void {
2132
+ for (const name of names) {
2133
+ // Older Pi ignores `exposure`, so the stub stays callable and must fail without calling the proxy.
2134
+ pi.registerTool({
2135
+ name,
2136
+ label: "Withdrawn LiteLLM MCP tool",
2137
+ description: WITHDRAWN_MCP_MESSAGE,
2138
+ parameters: Type.Object({}),
2139
+ exposure: "hidden",
2140
+ async execute() {
2141
+ throw new Error(WITHDRAWN_MCP_MESSAGE);
2142
+ },
2143
+ });
2144
+ }
2145
+ if (piHonorsToolExposure()) pi.setActiveTools(pi.getActiveTools().filter((name) => !names.has(name)));
2146
+ }
2147
+
2148
+ let warnedMcpExposureIgnored = false;
2149
+ let warnedMcpUnreachable = false;
2150
+ // As in Pi's own MCP extension: tools that are not declared to the model are reached through
2151
+ // codemode (`codemode`, `codemode-deferred`) or tool_search (`deferred`). Activate the one the
2152
+ // configured exposure needs, and say so once when neither is available.
2153
+ function activateMcpDiscoveryTools(exposures: readonly McpExposure[]): void {
2154
+ const needsCodemode = exposures.includes("codemode") || exposures.includes("codemode-deferred");
2155
+ const needsToolSearch = exposures.includes("deferred");
2156
+ if (!needsCodemode && !needsToolSearch) return;
2157
+ const tools = pi.getAllTools();
2158
+ // Without exposure support every registered tool is declared, so nothing needs activating.
2159
+ if (!piHonorsToolExposure()) {
2160
+ if (warnedMcpExposureIgnored) return;
2161
+ warnedMcpExposureIgnored = true;
2162
+ notifyMcp(
2163
+ "LiteLLM MCP: this Pi version ignores litellm.mcp.exposure; MCP tools are declared directly to the model.",
2164
+ );
2165
+ return;
2166
+ }
2167
+ const hasCodemode = tools.some((tool) => tool.name === "codemode");
2168
+ const hasToolSearch = tools.some((tool) => tool.name === "tool_search");
2169
+ const active = pi.getActiveTools();
2170
+ const activate = [
2171
+ ...(needsCodemode && hasCodemode && !active.includes("codemode") ? ["codemode"] : []),
2172
+ ...(needsToolSearch && hasToolSearch && !active.includes("tool_search") ? ["tool_search"] : []),
2173
+ ];
2174
+ if (activate.length > 0) pi.setActiveTools([...active, ...activate]);
2175
+ const reachable = [...active, ...activate];
2176
+ if ((hasCodemode && reachable.includes("codemode")) || (hasToolSearch && reachable.includes("tool_search"))) return;
2177
+ if (warnedMcpUnreachable) return;
2178
+ warnedMcpUnreachable = true;
2179
+ notifyMcp(
2180
+ "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.",
2181
+ );
2182
+ }
2183
+
1813
2184
  async function registerMcpTools(
1814
2185
  definition: ProviderDefinition,
1815
2186
  auth: McpRuntimeAuth,
@@ -1850,6 +2221,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1850
2221
  signal,
1851
2222
  notify,
1852
2223
  namespace,
2224
+ mcpExposure.policy,
1853
2225
  );
1854
2226
  signal?.throwIfAborted();
1855
2227
  if (loginGeneration !== mcpLoginGeneration(definition.name)) return;
@@ -1872,6 +2244,27 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1872
2244
  }
1873
2245
  reportMcpRegistrationSuccess(notify);
1874
2246
  reportMcpPartialDiscovery(report.partialFailure, registeredNames, notify);
2247
+ const previousNames = registeredMcpToolNames.get(definition.name) ?? new Set<string>();
2248
+ const currentNames = new Set(registeredNames);
2249
+ // A partial or empty catalog may omit live tools, so it withdraws nothing and keeps the old names.
2250
+ const settled = definitions.length > 0 && !report.partialFailure;
2251
+ const withdrawn = settled ? new Set([...previousNames].filter((n) => !currentNames.has(n))) : new Set<string>();
2252
+ try {
2253
+ if (withdrawn.size > 0) withdrawMcpTools(withdrawn);
2254
+ } catch (error) {
2255
+ if (signal?.aborted) throw signal.reason;
2256
+ mcpRegistrationFatal = true;
2257
+ reportMcpRegistrationFatal(registeredNames.length, definitions.length, error, notify);
2258
+ return;
2259
+ }
2260
+ if (definitions.length > 0) {
2261
+ if (!settled) for (const name of previousNames) currentNames.add(name);
2262
+ registeredMcpToolNames.set(definition.name, currentNames);
2263
+ }
2264
+ if (withdrawn.size > 0 && isVerboseDiscovery()) {
2265
+ notifyMcp(`${label}: withdrew ${withdrawn.size} MCP tools no longer offered by the proxy.`, "info");
2266
+ }
2267
+ activateMcpDiscoveryTools(report.exposures);
1875
2268
  if (isVerboseDiscovery()) {
1876
2269
  notifyMcp(
1877
2270
  `${label}: registered ${registeredNames.length} of ${definitions.length} prepared MCP tools ` +