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/README.md +55 -9
- package/package.json +6 -6
- package/src/backend-identity.ts +17 -1
- package/src/discover.ts +180 -41
- package/src/index.ts +530 -150
- package/src/mcp-tools.ts +191 -9
- package/src/model-groups.ts +12 -11
- package/src/provider.ts +27 -12
- package/src/proxy-version.ts +68 -0
- package/src/skills.ts +10 -0
- package/src/types.ts +16 -0
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
696
|
-
|
|
697
|
-
): Promise<
|
|
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:
|
|
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:
|
|
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
|
-
|
|
728
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
|
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
|
|
1097
|
-
!isAuthToken(
|
|
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
|
-
|
|
1105
|
-
const
|
|
1106
|
-
|
|
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
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
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 ` +
|