pi-provider-litellm 3.1.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -14
- package/package.json +6 -6
- package/src/backend-identity.ts +31 -5
- package/src/discover.ts +220 -56
- package/src/index.ts +721 -197
- package/src/mcp-tools.ts +225 -30
- package/src/model-groups.ts +44 -13
- package/src/provider.ts +27 -12
- package/src/proxy-version.ts +68 -0
- package/src/public-catalog.ts +15 -8
- package/src/skills.ts +33 -76
- 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`;
|
|
@@ -1643,8 +1939,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1643
1939
|
};
|
|
1644
1940
|
}
|
|
1645
1941
|
|
|
1646
|
-
|
|
1647
|
-
|
|
1942
|
+
// Each provider owns its own MCP catalog, keyed by provider name.
|
|
1943
|
+
const registeredMcpIdentities = new Map<string, string>();
|
|
1944
|
+
// Generated tool names each provider registered in its last successful pass; Pi cannot unregister,
|
|
1945
|
+
// so names that vanish from the catalog are withdrawn by re-registering them hidden.
|
|
1946
|
+
const registeredMcpToolNames = new Map<string, Set<string>>();
|
|
1947
|
+
let mcpSeeded = false;
|
|
1948
|
+
const mcpRegistrations = new Map<string, Promise<void>>();
|
|
1648
1949
|
// Pi's registerTool throws only from assertActive(), whose staleness flag is set with `??=` and
|
|
1649
1950
|
// never cleared, so a refusal is fatal for this extension instance rather than a per-tool or
|
|
1650
1951
|
// retryable condition. Once seen, stop attempting registration here; a reload creates a fresh
|
|
@@ -1655,7 +1956,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1655
1956
|
const mcpPauseInMemory = new Set<string>();
|
|
1656
1957
|
let mcpPauseSalt: Buffer | undefined;
|
|
1657
1958
|
let mcpPausePersistent = true;
|
|
1658
|
-
|
|
1959
|
+
// Keyed by provider: only the default provider has /login, and its login must not cancel an
|
|
1960
|
+
// alias's in-flight registration, which login does not retry.
|
|
1961
|
+
const mcpLoginGenerations = new Map<string, number>();
|
|
1962
|
+
const mcpLoginGeneration = (name: string) => mcpLoginGenerations.get(name) ?? 0;
|
|
1659
1963
|
|
|
1660
1964
|
function getMcpPauseSalt(): Buffer {
|
|
1661
1965
|
if (!mcpPauseSalt) {
|
|
@@ -1724,8 +2028,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1724
2028
|
}
|
|
1725
2029
|
|
|
1726
2030
|
function resumeMcpDiscovery(): void {
|
|
1727
|
-
mcpLoginGeneration
|
|
1728
|
-
|
|
2031
|
+
mcpLoginGenerations.set(PROVIDER_NAME, mcpLoginGeneration(PROVIDER_NAME) + 1);
|
|
2032
|
+
registeredMcpIdentities.delete(PROVIDER_NAME);
|
|
1729
2033
|
// The new login ID selects a fresh scope; other processes may still use the old scopes.
|
|
1730
2034
|
}
|
|
1731
2035
|
|
|
@@ -1746,17 +2050,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1746
2050
|
});
|
|
1747
2051
|
}
|
|
1748
2052
|
|
|
1749
|
-
async function getRuntimeAuth(
|
|
1750
|
-
|
|
2053
|
+
async function getRuntimeAuth(
|
|
2054
|
+
ctx: ExtensionContext,
|
|
2055
|
+
definition = definitions[0]!,
|
|
2056
|
+
): Promise<LiteLLMRuntimeAuth | undefined> {
|
|
2057
|
+
const auth = await ctx.modelRegistry.getProviderAuth(definition.name);
|
|
1751
2058
|
if (!auth) return undefined;
|
|
1752
|
-
const provider = ctx.modelRegistry.getProvider(
|
|
2059
|
+
const provider = ctx.modelRegistry.getProvider(definition.name);
|
|
1753
2060
|
const apiKey = auth.auth.apiKey;
|
|
1754
2061
|
const baseUrl = cleanConfig(auth.auth.baseUrl) ?? cleanConfig(auth.env?.[ENV_BASE_URL]) ?? provider?.baseUrl;
|
|
1755
2062
|
if (!baseUrl || !apiKey) return undefined;
|
|
1756
|
-
const runtimeRoot = requireCredentialRoot(
|
|
1757
|
-
normalizeBaseUrl(baseUrl, definitions[0]?.allowInsecureHttp),
|
|
1758
|
-
PROVIDER_NAME,
|
|
1759
|
-
);
|
|
2063
|
+
const runtimeRoot = requireCredentialRoot(normalizeBaseUrl(baseUrl, definition.allowInsecureHttp), definition.name);
|
|
1760
2064
|
const headers = Object.fromEntries(
|
|
1761
2065
|
Object.entries(auth.auth.headers ?? provider?.headers ?? {}).filter(
|
|
1762
2066
|
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
@@ -1766,16 +2070,23 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1766
2070
|
baseUrl: runtimeRoot,
|
|
1767
2071
|
apiKey,
|
|
1768
2072
|
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
1769
|
-
allowInsecureHttp:
|
|
2073
|
+
allowInsecureHttp: definition.allowInsecureHttp,
|
|
1770
2074
|
};
|
|
1771
2075
|
}
|
|
1772
2076
|
|
|
2077
|
+
function missingCredentials(definition: ProviderDefinition): string {
|
|
2078
|
+
const fix = definition.name === PROVIDER_NAME ? "Run /login litellm or set env vars" : "Set its baseUrl and apiKey";
|
|
2079
|
+
return `no credentials for ${definition.name}. ${fix}.`;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
async function requireRuntimeAuth(ctx: ExtensionContext, definition = definitions[0]!): Promise<LiteLLMRuntimeAuth> {
|
|
2083
|
+
const auth = await getRuntimeAuth(ctx, definition);
|
|
2084
|
+
if (auth) return auth;
|
|
2085
|
+
throw new Error(missingCredentials(definition));
|
|
2086
|
+
}
|
|
2087
|
+
|
|
1773
2088
|
async function resolveDefaultRuntimeAuth(ctx?: ExtensionContext): Promise<LiteLLMRuntimeAuth> {
|
|
1774
|
-
if (ctx?.modelRegistry)
|
|
1775
|
-
const auth = await getRuntimeAuth(ctx);
|
|
1776
|
-
if (auth) return auth;
|
|
1777
|
-
throw new Error("no credentials for litellm. Run /login litellm or set env vars.");
|
|
1778
|
-
}
|
|
2089
|
+
if (ctx?.modelRegistry) return requireRuntimeAuth(ctx);
|
|
1779
2090
|
if (!defaultRuntimeAuth) throw new Error("no credentials for litellm. Run /login litellm or set env vars.");
|
|
1780
2091
|
return defaultRuntimeAuth;
|
|
1781
2092
|
}
|
|
@@ -1798,34 +2109,109 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1798
2109
|
});
|
|
1799
2110
|
}
|
|
1800
2111
|
|
|
1801
|
-
|
|
2112
|
+
// Pi before 0.99 reports no `exposure` and declares every registered tool.
|
|
2113
|
+
function piHonorsToolExposure(): boolean {
|
|
2114
|
+
return pi.getAllTools().some((tool) => "exposure" in tool);
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
const WITHDRAWN_MCP_MESSAGE = "This LiteLLM MCP tool is no longer offered by the proxy.";
|
|
2118
|
+
function withdrawMcpTools(names: ReadonlySet<string>): void {
|
|
2119
|
+
for (const name of names) {
|
|
2120
|
+
// Older Pi ignores `exposure`, so the stub stays callable and must fail without calling the proxy.
|
|
2121
|
+
pi.registerTool({
|
|
2122
|
+
name,
|
|
2123
|
+
label: "Withdrawn LiteLLM MCP tool",
|
|
2124
|
+
description: WITHDRAWN_MCP_MESSAGE,
|
|
2125
|
+
parameters: Type.Object({}),
|
|
2126
|
+
exposure: "hidden",
|
|
2127
|
+
async execute() {
|
|
2128
|
+
throw new Error(WITHDRAWN_MCP_MESSAGE);
|
|
2129
|
+
},
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2132
|
+
if (piHonorsToolExposure()) pi.setActiveTools(pi.getActiveTools().filter((name) => !names.has(name)));
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
let warnedMcpExposureIgnored = false;
|
|
2136
|
+
let warnedMcpUnreachable = false;
|
|
2137
|
+
// As in Pi's own MCP extension: tools that are not declared to the model are reached through
|
|
2138
|
+
// codemode (`codemode`, `codemode-deferred`) or tool_search (`deferred`). Activate the one the
|
|
2139
|
+
// configured exposure needs, and say so once when neither is available.
|
|
2140
|
+
function activateMcpDiscoveryTools(exposures: readonly McpExposure[]): void {
|
|
2141
|
+
const needsCodemode = exposures.includes("codemode") || exposures.includes("codemode-deferred");
|
|
2142
|
+
const needsToolSearch = exposures.includes("deferred");
|
|
2143
|
+
if (!needsCodemode && !needsToolSearch) return;
|
|
2144
|
+
const tools = pi.getAllTools();
|
|
2145
|
+
// Without exposure support every registered tool is declared, so nothing needs activating.
|
|
2146
|
+
if (!piHonorsToolExposure()) {
|
|
2147
|
+
if (warnedMcpExposureIgnored) return;
|
|
2148
|
+
warnedMcpExposureIgnored = true;
|
|
2149
|
+
notifyMcp(
|
|
2150
|
+
"LiteLLM MCP: this Pi version ignores litellm.mcp.exposure; MCP tools are declared directly to the model.",
|
|
2151
|
+
);
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
const hasCodemode = tools.some((tool) => tool.name === "codemode");
|
|
2155
|
+
const hasToolSearch = tools.some((tool) => tool.name === "tool_search");
|
|
2156
|
+
const active = pi.getActiveTools();
|
|
2157
|
+
const activate = [
|
|
2158
|
+
...(needsCodemode && hasCodemode && !active.includes("codemode") ? ["codemode"] : []),
|
|
2159
|
+
...(needsToolSearch && hasToolSearch && !active.includes("tool_search") ? ["tool_search"] : []),
|
|
2160
|
+
];
|
|
2161
|
+
if (activate.length > 0) pi.setActiveTools([...active, ...activate]);
|
|
2162
|
+
const reachable = [...active, ...activate];
|
|
2163
|
+
if ((hasCodemode && reachable.includes("codemode")) || (hasToolSearch && reachable.includes("tool_search"))) return;
|
|
2164
|
+
if (warnedMcpUnreachable) return;
|
|
2165
|
+
warnedMcpUnreachable = true;
|
|
2166
|
+
notifyMcp(
|
|
2167
|
+
"LiteLLM MCP: tools with codemode or deferred exposure are reachable only through Pi's codemode or tool_search tool, and neither is available; they cannot be called.",
|
|
2168
|
+
);
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
async function registerMcpTools(
|
|
2172
|
+
definition: ProviderDefinition,
|
|
2173
|
+
auth: McpRuntimeAuth,
|
|
2174
|
+
credential: Credential,
|
|
2175
|
+
signal?: AbortSignal,
|
|
2176
|
+
): Promise<void> {
|
|
1802
2177
|
if (!mcpEnabled || discoveryDisabledReason() || mcpRegistrationFatal || isMcpPaused(auth, credential)) return;
|
|
1803
|
-
const loginGeneration = mcpLoginGeneration;
|
|
2178
|
+
const loginGeneration = mcpLoginGeneration(definition.name);
|
|
1804
2179
|
const identity = mcpCatalogIdentity(auth, credential);
|
|
1805
|
-
|
|
1806
|
-
|
|
2180
|
+
const isDefault = definition.name === PROVIDER_NAME;
|
|
2181
|
+
// The default provider keeps its unprefixed tool names and diagnostics; an alias is scoped by name.
|
|
2182
|
+
const namespace = isDefault ? undefined : definition.name;
|
|
2183
|
+
const notify = isDefault
|
|
2184
|
+
? notifyMcp
|
|
2185
|
+
: Object.assign((message: string) => notifyMcp(message), { incidentScope: definition.name });
|
|
2186
|
+
const label = isDefault ? "LiteLLM MCP" : `LiteLLM MCP (${JSON.stringify(definition.name)})`;
|
|
2187
|
+
let pending = mcpRegistrations.get(definition.name);
|
|
2188
|
+
while (pending) {
|
|
2189
|
+
await waitForMcpRegistration(pending, signal);
|
|
1807
2190
|
signal?.throwIfAborted();
|
|
1808
2191
|
if (
|
|
1809
2192
|
mcpRegistrationFatal ||
|
|
1810
2193
|
isMcpPaused(auth, credential) ||
|
|
1811
|
-
loginGeneration !== mcpLoginGeneration ||
|
|
1812
|
-
|
|
2194
|
+
loginGeneration !== mcpLoginGeneration(definition.name) ||
|
|
2195
|
+
registeredMcpIdentities.get(definition.name) === identity
|
|
1813
2196
|
)
|
|
1814
2197
|
return;
|
|
2198
|
+
pending = mcpRegistrations.get(definition.name);
|
|
1815
2199
|
}
|
|
1816
|
-
if (
|
|
2200
|
+
if (registeredMcpIdentities.get(definition.name) === identity) return;
|
|
1817
2201
|
|
|
1818
2202
|
const registration = (async () => {
|
|
1819
2203
|
try {
|
|
1820
2204
|
signal?.throwIfAborted();
|
|
1821
2205
|
const { definitions, report } = await createMcpToolDefinitions(
|
|
1822
|
-
(ctx) => (ctx?.modelRegistry ?
|
|
1823
|
-
isVerboseDiscovery() ? (message) => notifyMcp(
|
|
2206
|
+
(ctx) => (ctx?.modelRegistry ? requireRuntimeAuth(ctx, definition) : Promise.resolve(auth)),
|
|
2207
|
+
isVerboseDiscovery() ? (message) => notifyMcp(`${label}: ${message}`, "info") : undefined,
|
|
1824
2208
|
signal,
|
|
1825
|
-
|
|
2209
|
+
notify,
|
|
2210
|
+
namespace,
|
|
2211
|
+
mcpExposure.policy,
|
|
1826
2212
|
);
|
|
1827
2213
|
signal?.throwIfAborted();
|
|
1828
|
-
if (loginGeneration !== mcpLoginGeneration) return;
|
|
2214
|
+
if (loginGeneration !== mcpLoginGeneration(definition.name)) return;
|
|
1829
2215
|
const registeredNames: string[] = [];
|
|
1830
2216
|
try {
|
|
1831
2217
|
for (const definition of definitions) {
|
|
@@ -1840,14 +2226,35 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1840
2226
|
// Fatal for this instance: report once, with a bounded Pi-authored cause and no proxy text,
|
|
1841
2227
|
// then stop retrying so a stale instance cannot churn discovery on every later refresh.
|
|
1842
2228
|
mcpRegistrationFatal = true;
|
|
1843
|
-
reportMcpRegistrationFatal(registeredNames.length, definitions.length, error,
|
|
2229
|
+
reportMcpRegistrationFatal(registeredNames.length, definitions.length, error, notify);
|
|
2230
|
+
return;
|
|
2231
|
+
}
|
|
2232
|
+
reportMcpRegistrationSuccess(notify);
|
|
2233
|
+
reportMcpPartialDiscovery(report.partialFailure, registeredNames, notify);
|
|
2234
|
+
const previousNames = registeredMcpToolNames.get(definition.name) ?? new Set<string>();
|
|
2235
|
+
const currentNames = new Set(registeredNames);
|
|
2236
|
+
// A partial or empty catalog may omit live tools, so it withdraws nothing and keeps the old names.
|
|
2237
|
+
const settled = definitions.length > 0 && !report.partialFailure;
|
|
2238
|
+
const withdrawn = settled ? new Set([...previousNames].filter((n) => !currentNames.has(n))) : new Set<string>();
|
|
2239
|
+
try {
|
|
2240
|
+
if (withdrawn.size > 0) withdrawMcpTools(withdrawn);
|
|
2241
|
+
} catch (error) {
|
|
2242
|
+
if (signal?.aborted) throw signal.reason;
|
|
2243
|
+
mcpRegistrationFatal = true;
|
|
2244
|
+
reportMcpRegistrationFatal(registeredNames.length, definitions.length, error, notify);
|
|
1844
2245
|
return;
|
|
1845
2246
|
}
|
|
1846
|
-
|
|
1847
|
-
|
|
2247
|
+
if (definitions.length > 0) {
|
|
2248
|
+
if (!settled) for (const name of previousNames) currentNames.add(name);
|
|
2249
|
+
registeredMcpToolNames.set(definition.name, currentNames);
|
|
2250
|
+
}
|
|
2251
|
+
if (withdrawn.size > 0 && isVerboseDiscovery()) {
|
|
2252
|
+
notifyMcp(`${label}: withdrew ${withdrawn.size} MCP tools no longer offered by the proxy.`, "info");
|
|
2253
|
+
}
|
|
2254
|
+
activateMcpDiscoveryTools(report.exposures);
|
|
1848
2255
|
if (isVerboseDiscovery()) {
|
|
1849
2256
|
notifyMcp(
|
|
1850
|
-
|
|
2257
|
+
`${label}: registered ${registeredNames.length} of ${definitions.length} prepared MCP tools ` +
|
|
1851
2258
|
`(${report.discovered} raw, ${report.enveloped} enveloped).`,
|
|
1852
2259
|
"info",
|
|
1853
2260
|
);
|
|
@@ -1855,26 +2262,29 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1855
2262
|
// A catalog that produced nothing or came from a partial-failure response is not settled:
|
|
1856
2263
|
// leaving the identity unset lets a later refresh retry discovery, which is network-only and
|
|
1857
2264
|
// non-blocking. Re-registering surviving tools is safe because Pi replaces tools by name.
|
|
1858
|
-
reportMcpCatalogOutcome(report.discovered, definitions.length,
|
|
1859
|
-
if (definitions.length > 0 && !report.partialFailure)
|
|
2265
|
+
reportMcpCatalogOutcome(report.discovered, definitions.length, notify);
|
|
2266
|
+
if (definitions.length > 0 && !report.partialFailure) registeredMcpIdentities.set(definition.name, identity);
|
|
1860
2267
|
} catch (error) {
|
|
1861
2268
|
if (signal?.aborted) throw signal.reason;
|
|
1862
|
-
if (loginGeneration !== mcpLoginGeneration) return;
|
|
2269
|
+
if (loginGeneration !== mcpLoginGeneration(definition.name)) return;
|
|
1863
2270
|
if (error instanceof McpAccessDeniedError) {
|
|
1864
2271
|
pauseMcpDiscovery(auth, credential);
|
|
1865
|
-
|
|
2272
|
+
// Aliases have no /login; their pause scope is their URL, key, and headers, so a change lifts it.
|
|
2273
|
+
notifyMcp(
|
|
2274
|
+
`${label}: access denied; discovery paused until ${isDefault ? "/login litellm succeeds" : "its credentials change"}.`,
|
|
2275
|
+
);
|
|
1866
2276
|
return;
|
|
1867
2277
|
}
|
|
1868
2278
|
notifyMcp(
|
|
1869
|
-
`LiteLLM (${
|
|
2279
|
+
`LiteLLM (${definition.name}): MCP tool discovery failed (${error instanceof Error ? error.message : String(error)}).`,
|
|
1870
2280
|
);
|
|
1871
2281
|
}
|
|
1872
2282
|
})();
|
|
1873
|
-
|
|
2283
|
+
mcpRegistrations.set(definition.name, registration);
|
|
1874
2284
|
try {
|
|
1875
2285
|
await registration;
|
|
1876
2286
|
} finally {
|
|
1877
|
-
if (
|
|
2287
|
+
if (mcpRegistrations.get(definition.name) === registration) mcpRegistrations.delete(definition.name);
|
|
1878
2288
|
}
|
|
1879
2289
|
}
|
|
1880
2290
|
|
|
@@ -1896,7 +2306,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1896
2306
|
// network phase, so the provider would stay empty until the user opens /model. Discover here
|
|
1897
2307
|
// instead, the way 1.x did, and hand the catalog over as the provider's baseline models.
|
|
1898
2308
|
async function seedModels(definition: ProviderDefinition): Promise<Model<LiteLLMApi>[]> {
|
|
1899
|
-
|
|
2309
|
+
const disabledReason = discoveryDisabledReason() ?? (isHostOffline() ? "PI_OFFLINE" : null);
|
|
2310
|
+
if (disabledReason) {
|
|
2311
|
+
if (isVerboseDiscovery()) {
|
|
2312
|
+
process.stderr.write(`LiteLLM (${definition.name}): startup discovery skipped (${disabledReason}).\n`);
|
|
2313
|
+
}
|
|
2314
|
+
return [];
|
|
2315
|
+
}
|
|
1900
2316
|
try {
|
|
1901
2317
|
const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
|
|
1902
2318
|
// executeHelpers:false — activation must never run the user's key helper as a side effect,
|
|
@@ -1916,6 +2332,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1916
2332
|
}
|
|
1917
2333
|
|
|
1918
2334
|
const seeded = await Promise.all(definitions.map(seedModels));
|
|
2335
|
+
// Pi runs a provider's network phase only once a credential resolves, so /litellm-refresh reads an
|
|
2336
|
+
// unchanged count as "no credentials" rather than as a successful refresh.
|
|
2337
|
+
const networkRefreshAttempts = new Map<string, number>();
|
|
1919
2338
|
|
|
1920
2339
|
for (const [index, definition] of definitions.entries()) {
|
|
1921
2340
|
const auth = createProviderAuth(
|
|
@@ -1975,24 +2394,26 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1975
2394
|
Object.assign(provider, { headers: resolveHeaders(definition) });
|
|
1976
2395
|
const refreshModels = provider.refreshModels!;
|
|
1977
2396
|
provider.refreshModels = async (context) => {
|
|
1978
|
-
const loginGeneration = mcpLoginGeneration;
|
|
2397
|
+
const loginGeneration = mcpLoginGeneration(definition.name);
|
|
2398
|
+
if (context.allowNetwork) {
|
|
2399
|
+
networkRefreshAttempts.set(definition.name, (networkRefreshAttempts.get(definition.name) ?? 0) + 1);
|
|
2400
|
+
}
|
|
1979
2401
|
try {
|
|
1980
2402
|
await refreshModels(context);
|
|
1981
2403
|
} finally {
|
|
1982
2404
|
if (
|
|
1983
|
-
definition.name === PROVIDER_NAME &&
|
|
1984
2405
|
context.allowNetwork &&
|
|
1985
2406
|
!discoveryDisabledReason() &&
|
|
1986
|
-
loginGeneration === mcpLoginGeneration &&
|
|
2407
|
+
loginGeneration === mcpLoginGeneration(definition.name) &&
|
|
1987
2408
|
context.credential
|
|
1988
2409
|
) {
|
|
1989
2410
|
// Best-effort: refreshing the cached default auth / MCP catalog must not let a bad or
|
|
1990
2411
|
// placeholder credential override refreshModels' own try/throw outcome via `finally`.
|
|
1991
2412
|
try {
|
|
1992
2413
|
const auth = await authForCredential(definition, context.credential);
|
|
1993
|
-
if (loginGeneration === mcpLoginGeneration) {
|
|
1994
|
-
defaultRuntimeAuth = auth;
|
|
1995
|
-
void registerMcpTools(auth, context.credential, context.signal).catch(() => undefined);
|
|
2414
|
+
if (loginGeneration === mcpLoginGeneration(definition.name)) {
|
|
2415
|
+
if (definition.name === PROVIDER_NAME) defaultRuntimeAuth = auth;
|
|
2416
|
+
void registerMcpTools(definition, auth, context.credential, context.signal).catch(() => undefined);
|
|
1996
2417
|
}
|
|
1997
2418
|
} catch {
|
|
1998
2419
|
// ignored — authForCredential already reported/will report this via the paths that use it directly.
|
|
@@ -2003,6 +2424,54 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
2003
2424
|
pi.registerProvider(provider);
|
|
2004
2425
|
}
|
|
2005
2426
|
|
|
2427
|
+
// Pi documents PI_OFFLINE as disabling model catalog refreshes, including the one /model starts, and a
|
|
2428
|
+
// provider cannot tell that refresh from an automatic one. This is the explicit refresh: it reaches only
|
|
2429
|
+
// the configured proxies, so LITELLM_OFFLINE=1 and a zero timeout still disable it and models.dev stays off.
|
|
2430
|
+
pi.registerCommand("litellm-refresh", {
|
|
2431
|
+
description: "Refresh LiteLLM model catalogs from the proxy, even with PI_OFFLINE set",
|
|
2432
|
+
getArgumentCompletions: (prefix) => {
|
|
2433
|
+
const matches = [...providerNames].filter((name) => name.startsWith(prefix));
|
|
2434
|
+
return matches.length > 0 ? matches.map((name) => ({ value: name, label: name })) : null;
|
|
2435
|
+
},
|
|
2436
|
+
handler: async (args, ctx) => {
|
|
2437
|
+
const notify = (message: string, level: "info" | "warning"): void => {
|
|
2438
|
+
if (ctx.hasUI) ctx.ui.notify(message, level);
|
|
2439
|
+
else process.stderr.write(`${message}\n`);
|
|
2440
|
+
};
|
|
2441
|
+
const disabledReason = discoveryDisabledReason();
|
|
2442
|
+
if (disabledReason) {
|
|
2443
|
+
notify(`LiteLLM: model refresh skipped (${disabledReason}).`, "warning");
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
const requested = args.trim();
|
|
2447
|
+
const selected = requested ? definitions.filter(({ name }) => name === requested) : definitions;
|
|
2448
|
+
if (selected.length === 0) {
|
|
2449
|
+
const configured = [...providerNames].map((name) => JSON.stringify(name)).join(", ");
|
|
2450
|
+
notify(`LiteLLM: unknown provider ${JSON.stringify(requested)}; configured: ${configured}.`, "warning");
|
|
2451
|
+
return;
|
|
2452
|
+
}
|
|
2453
|
+
const attempts = new Map(selected.map(({ name }) => [name, networkRefreshAttempts.get(name) ?? 0]));
|
|
2454
|
+
const result = await ctx.modelRegistry.refresh({
|
|
2455
|
+
providers: selected.map(({ name }) => name),
|
|
2456
|
+
allowNetwork: true,
|
|
2457
|
+
force: true,
|
|
2458
|
+
});
|
|
2459
|
+
const models = ctx.modelRegistry.getAll();
|
|
2460
|
+
for (const definition of selected) {
|
|
2461
|
+
const label = `LiteLLM (${JSON.stringify(definition.name)})`;
|
|
2462
|
+
const error = result.errors.get(definition.name);
|
|
2463
|
+
if (error) {
|
|
2464
|
+
notify(`${label}: model refresh failed (${error.message}).`, "warning");
|
|
2465
|
+
} else if ((networkRefreshAttempts.get(definition.name) ?? 0) === attempts.get(definition.name)) {
|
|
2466
|
+
notify(`${label}: model refresh skipped; ${missingCredentials(definition)}`, "warning");
|
|
2467
|
+
} else {
|
|
2468
|
+
const count = models.filter((model) => model.provider === definition.name).length;
|
|
2469
|
+
notify(`${label}: refreshed ${count} model${count === 1 ? "" : "s"}.`, "info");
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
},
|
|
2473
|
+
});
|
|
2474
|
+
|
|
2006
2475
|
setupLiteLLMCostTracking(pi, [...providerNames]);
|
|
2007
2476
|
|
|
2008
2477
|
if (skillsEnabled) {
|
|
@@ -2026,6 +2495,27 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
2026
2495
|
);
|
|
2027
2496
|
});
|
|
2028
2497
|
|
|
2498
|
+
// Router fallbacks are invisible to discovery, so protocol and model policy were chosen for the
|
|
2499
|
+
// requested route even when another group answered. Name the route only; header text is proxy-supplied.
|
|
2500
|
+
const warnedFallbackRoutes = new Set<string>();
|
|
2501
|
+
pi.on("after_provider_response", (event, ctx) => {
|
|
2502
|
+
const model = ctx.model;
|
|
2503
|
+
if (!model?.provider || !providerNames.has(model.provider)) return;
|
|
2504
|
+
if (event.status < 200 || event.status >= 300) return;
|
|
2505
|
+
const attemptedFallbacks = Number(event.headers["x-litellm-attempted-fallbacks"]);
|
|
2506
|
+
if (!Number.isSafeInteger(attemptedFallbacks) || attemptedFallbacks <= 0) return;
|
|
2507
|
+
const key = `${model.provider}\0${model.id}`;
|
|
2508
|
+
if (warnedFallbackRoutes.has(key)) return;
|
|
2509
|
+
warnedFallbackRoutes.add(key);
|
|
2510
|
+
const route = JSON.stringify(model.id);
|
|
2511
|
+
const message =
|
|
2512
|
+
`LiteLLM (${JSON.stringify(model.provider)}): a fallback served ${route}, but protocol and model handling were chosen ` +
|
|
2513
|
+
`for ${route}'s own deployments. If its fallbacks cross model families, pin the protocol with ` +
|
|
2514
|
+
"`model_info.supported_endpoints`.";
|
|
2515
|
+
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
2516
|
+
else process.stderr.write(`${message}\n`);
|
|
2517
|
+
});
|
|
2518
|
+
|
|
2029
2519
|
// Skills enrichment is best-effort: an expired credential or an unreachable proxy must not
|
|
2030
2520
|
// report an extension error on every turn. The failing model request states the real problem.
|
|
2031
2521
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
@@ -2048,6 +2538,40 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
2048
2538
|
return { systemPrompt: `${event.systemPrompt}\n\n${section}` };
|
|
2049
2539
|
});
|
|
2050
2540
|
|
|
2541
|
+
// MCP tools otherwise register only from refreshModels' `finally`, which needs
|
|
2542
|
+
// context.allowNetwork. Pi core sets that solely for the interactive TUI and the RPC
|
|
2543
|
+
// background refresh, so `-p` and `--list-models` register no MCP tool at all, even when
|
|
2544
|
+
// credentials resolve and the proxy is reachable — the defect #136 fixed for models.
|
|
2545
|
+
//
|
|
2546
|
+
// Seeding here rather than at activation is deliberate: registerMcpTools serialises callers
|
|
2547
|
+
// per provider, so an activation-time call would become an in-flight registration
|
|
2548
|
+
// that Pi's own refresh then waits on. Running once before the first turn keeps that ordering
|
|
2549
|
+
// intact, still precedes any tool use, and leaves startup latency untouched.
|
|
2550
|
+
async function seedMcpTools(definition: ProviderDefinition): Promise<void> {
|
|
2551
|
+
try {
|
|
2552
|
+
const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
|
|
2553
|
+
// executeHelpers:false — seeding must never run the user's key helper as a side effect.
|
|
2554
|
+
const auth = await authForCredential(definition, stored, false);
|
|
2555
|
+
// An env-configured provider has no stored credential and must still seed. The credential
|
|
2556
|
+
// only derives the pause identity, so the resolved key reproduces the stored api_key scope.
|
|
2557
|
+
const credential: Credential = stored ?? { type: "api_key", key: auth.apiKey };
|
|
2558
|
+
await registerMcpTools(definition, auth, credential, AbortSignal.timeout(getSeedTimeoutMs()));
|
|
2559
|
+
} catch (error) {
|
|
2560
|
+
if (isVerboseDiscovery()) {
|
|
2561
|
+
process.stderr.write(
|
|
2562
|
+
`LiteLLM (${definition.name}): MCP seeding skipped (${error instanceof Error ? error.message : String(error)}).\n`,
|
|
2563
|
+
);
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
pi.on("before_agent_start", async () => {
|
|
2569
|
+
if (!mcpEnabled || mcpSeeded || discoveryDisabledReason() || isHostOffline()) return;
|
|
2570
|
+
mcpSeeded = true;
|
|
2571
|
+
// Catalogs are independent per provider, so every configured provider seeds in parallel.
|
|
2572
|
+
await Promise.all(definitions.map(seedMcpTools));
|
|
2573
|
+
});
|
|
2574
|
+
|
|
2051
2575
|
pi.on("message_end", (event, ctx) => {
|
|
2052
2576
|
if (event.message.role !== "assistant") return;
|
|
2053
2577
|
// Resolve the discovered model that produced this message so the display
|