pi-provider-litellm 3.4.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
3
- import { existsSync, linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
4
3
  import { readFile } from "node:fs/promises";
5
4
  import { createServer } from "node:http";
6
5
  import { join } from "node:path";
@@ -15,24 +14,15 @@ import type {
15
14
  OAuthCredentials,
16
15
  ProviderAuth,
17
16
  } from "@earendil-works/pi-ai";
18
- import { Type } from "@earendil-works/pi-ai";
19
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
+ import type { ExtensionAPI, ExtensionContext, McpServerConfig } from "@earendil-works/pi-coding-agent";
18
+ // A namespace import, so a Pi without `VERSION` reaches the version check instead of failing to link.
19
+ import * as piCodingAgent from "@earendil-works/pi-coding-agent";
20
20
  import { getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
21
21
  import { setupLiteLLMCostTracking } from "./cost.js";
22
22
  import { discoverModels, isGpt6OrNewerModel, isGpt55OrNewerModel, normalizeBaseUrl } from "./discover.js";
23
23
  import { getGcloudToken, hasGcloudAdcCredentials, isGcloudTokenAuthEnabled } from "./gcloud-token.js";
24
- import {
25
- createMcpToolDefinitions,
26
- credentialFingerprint,
27
- McpAccessDeniedError,
28
- type McpExposure,
29
- parseMcpExposurePolicy,
30
- reportMcpCatalogOutcome,
31
- reportMcpPartialDiscovery,
32
- reportMcpRegistrationFatal,
33
- reportMcpRegistrationSuccess,
34
- } from "./mcp-tools.js";
35
24
  import { createLiteLLMProvider, DEFAULT_LITELLM_BASE_URL, isPlaceholderHost, toNativeModels } from "./provider.js";
25
+ import { parseProxyVersion, proxyVersionAtLeast } from "./proxy-version.js";
36
26
  import { createSkillsPromptSection, createSkillToolDefinitions, listSkills } from "./skills.js";
37
27
  import type {
38
28
  DiscoveryOptions,
@@ -83,8 +73,6 @@ type RawProviderSettings = {
83
73
  oidc?: unknown;
84
74
  };
85
75
 
86
- type McpRuntimeAuth = LiteLLMRuntimeAuth & { mcpPauseSource?: string };
87
-
88
76
  type ProviderDefinition = {
89
77
  name: string;
90
78
  displayName: string;
@@ -461,6 +449,13 @@ function isHostOffline(): boolean {
461
449
  return process.env.PI_OFFLINE !== undefined;
462
450
  }
463
451
 
452
+ // Pi resolves `$NAME`, `${NAME}`, and a leading `!command` in MCP header values. Header values here
453
+ // are already resolved, so they are escaped back to literals: `$$` is a `$`, and `$!` a leading `!`.
454
+ function piLiteral(value: string): string {
455
+ const escaped = value.replaceAll("$", () => "$$");
456
+ return escaped.startsWith("!") ? `$${escaped}` : escaped;
457
+ }
458
+
464
459
  /**
465
460
  * LiteLLM's /model/info is the metadata authority, so models.dev is an opt-in escape hatch.
466
461
  * Without a cache path and with refresh suppressed, discovery reads neither the models.dev
@@ -1530,12 +1525,12 @@ async function resolveApiKeyAuth(
1530
1525
  function createProviderAuth(
1531
1526
  definition: ProviderDefinition,
1532
1527
  clearOAuthRuntimeRoot?: () => void,
1533
- onLogin?: () => void,
1528
+ loginHooks?: { start: () => void; complete: () => void },
1534
1529
  oauthRuntimeRoot?: () => { apiKey: string; root: string } | undefined,
1535
1530
  ): ProviderAuth {
1536
1531
  function completeLogin<T extends Credential>(credential: T): T {
1537
- onLogin?.();
1538
- return onLogin ? { ...credential, litellmMcpSession: randomBytes(16).toString("hex") } : credential;
1532
+ loginHooks?.complete();
1533
+ return credential;
1539
1534
  }
1540
1535
  return {
1541
1536
  apiKey: {
@@ -1543,6 +1538,7 @@ function createProviderAuth(
1543
1538
  login:
1544
1539
  definition.name === PROVIDER_NAME
1545
1540
  ? async (interaction) => {
1541
+ loginHooks?.start();
1546
1542
  const credential = await loginApiKey(interaction, definition);
1547
1543
  return completeLogin(credential);
1548
1544
  }
@@ -1615,6 +1611,7 @@ function createProviderAuth(
1615
1611
  name: "LiteLLM SSO",
1616
1612
  loginLabel: "Sign in with LiteLLM SSO",
1617
1613
  login: async (interaction) => {
1614
+ loginHooks?.start();
1618
1615
  const credential = await loginOAuth(interaction, definition);
1619
1616
  return completeLogin(credential);
1620
1617
  },
@@ -1871,11 +1868,19 @@ function normalizeThinkTags(
1871
1868
  }
1872
1869
 
1873
1870
  export default async function (pi: ExtensionAPI): Promise<void> {
1871
+ // Pi installs packages without resolving peerDependencies, so only this check keeps an older Pi from
1872
+ // loading the extension. It runs before anything registers; Pi reports the error and starts without it.
1873
+ // Pi's version parses like the proxy's: a pre-release orders before the release it names.
1874
+ if (!proxyVersionAtLeast(parseProxyVersion(piCodingAgent.VERSION), [0, 99, 2])) {
1875
+ throw new Error(
1876
+ `pi-provider-litellm needs Pi 0.99.2 or newer; this is Pi ${piCodingAgent.VERSION}. ` +
1877
+ "Update Pi, or install the last release for older Pi: pi install npm:pi-provider-litellm@3.4.0",
1878
+ );
1879
+ }
1874
1880
  const settings = await readGlobalLiteLLMSettings();
1875
1881
  const definitions = getProviderDefinitions(settings);
1876
1882
  const skillsEnabled = isFeatureEnabled(settings, "skills");
1877
1883
  const mcpEnabled = isFeatureEnabled(settings, "mcp");
1878
- const mcpExposure = parseMcpExposurePolicy(settings?.mcp);
1879
1884
  const providerNames = new Set(definitions.map((definition) => definition.name));
1880
1885
  const oauthRuntimeRoots = new Map<string, { apiKey: string; root: string }>();
1881
1886
  let mcpUI: ExtensionContext["ui"] | undefined;
@@ -1901,7 +1906,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1901
1906
  sessionStarted = false;
1902
1907
  pendingMcpMessages.clear();
1903
1908
  });
1904
- for (const warning of mcpExposure.warnings) notifyMcp(`LiteLLM MCP: ${warning}`);
1905
1909
 
1906
1910
  function discoveryDisabledReason(): string | null {
1907
1911
  if (isOffline()) return `${ENV_OFFLINE}=1`;
@@ -1909,6 +1913,62 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1909
1913
  return null;
1910
1914
  }
1911
1915
 
1916
+ // LiteLLM serves MCP over streamable HTTP at `/mcp`, so each provider registers it as a Pi MCP
1917
+ // server and Pi's client does the rest. `auth.provider` makes Pi send the provider's current
1918
+ // credential on every request, so token refreshes and key helpers apply without registering
1919
+ // again; only a new proxy root or new headers change the registration.
1920
+ const mcpSettings = isPlainObject(settings?.mcp) ? settings.mcp : undefined;
1921
+ // The last config attempted for each provider, and whether Pi accepted it. Headers can carry
1922
+ // credentials, so the config is remembered as a keyed digest.
1923
+ const mcpAttempts = new Map<string, { identity: string; registered: boolean }>();
1924
+ const mcpIdentitySalt = randomBytes(32);
1925
+
1926
+ function mcpServerConfig(definition: ProviderDefinition): McpServerConfig | undefined {
1927
+ if (discoveryDisabledReason() || isHostOffline()) return undefined;
1928
+ let root: string;
1929
+ try {
1930
+ const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
1931
+ root = requireCredentialRoot(resolveCredentialRoot(definition, stored), definition.name);
1932
+ } catch {
1933
+ return undefined;
1934
+ }
1935
+ const headers = resolveHeaders(definition);
1936
+ // Pi validates exposure and toolExposure; an unusable value is reported when registering.
1937
+ return {
1938
+ url: `${root}/mcp`,
1939
+ auth: { provider: definition.name },
1940
+ ...(headers ? { headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k, piLiteral(v)])) } : {}),
1941
+ ...(mcpSettings?.exposure !== undefined ? { exposure: mcpSettings.exposure } : {}),
1942
+ ...(mcpSettings?.toolExposure !== undefined ? { toolExposure: mcpSettings.toolExposure } : {}),
1943
+ } as McpServerConfig;
1944
+ }
1945
+
1946
+ function dropMcpServer(definition: ProviderDefinition): void {
1947
+ const attempt = mcpAttempts.get(definition.name);
1948
+ mcpAttempts.delete(definition.name);
1949
+ if (attempt?.registered) pi.unregisterMcpServer(definition.name);
1950
+ }
1951
+
1952
+ function syncMcpServer(definition: ProviderDefinition): void {
1953
+ const config = mcpServerConfig(definition);
1954
+ const identity = config && createHmac("sha256", mcpIdentitySalt).update(JSON.stringify(config)).digest("hex");
1955
+ if (identity === mcpAttempts.get(definition.name)?.identity) return;
1956
+ // Close the old connection before opening one to a new root, and leave nothing behind when
1957
+ // Pi refuses the new config.
1958
+ dropMcpServer(definition);
1959
+ if (!config || !identity) return;
1960
+ try {
1961
+ pi.registerMcpServer(definition.name, config);
1962
+ mcpAttempts.set(definition.name, { identity, registered: true });
1963
+ } catch (error) {
1964
+ // Remembered so the refusal is reported once rather than on every turn.
1965
+ mcpAttempts.set(definition.name, { identity, registered: false });
1966
+ notifyMcp(
1967
+ `LiteLLM MCP (${JSON.stringify(definition.name)}): ${error instanceof Error ? error.message : String(error)}`,
1968
+ );
1969
+ }
1970
+ }
1971
+
1912
1972
  function requestBaseUrl(definition: ProviderDefinition): string {
1913
1973
  try {
1914
1974
  return `${resolveCredentialRoot(definition) ?? DEFAULT_LITELLM_BASE_URL}/v1`;
@@ -1941,127 +2001,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1941
2001
  apiKey: resolved.auth.apiKey,
1942
2002
  headers: resolved.auth.headers,
1943
2003
  allowInsecureHttp: definition.allowInsecureHttp,
1944
- mcpPauseSource:
1945
- resolved.source === GCLOUD_ADC_SOURCE
1946
- ? GCLOUD_ADC_SOURCE
1947
- : resolved.source === ENV_API_KEY_HELPER
1948
- ? normalizeCommand(process.env[ENV_API_KEY_HELPER])
1949
- : resolved.source?.startsWith("!")
1950
- ? resolved.source
1951
- : undefined,
1952
2004
  };
1953
2005
  }
1954
2006
 
1955
- // Each provider owns its own MCP catalog, keyed by provider name.
1956
- const registeredMcpIdentities = new Map<string, string>();
1957
- // Generated tool names each provider registered in its last successful pass; Pi cannot unregister,
1958
- // so names that vanish from the catalog are withdrawn by re-registering them hidden.
1959
- const registeredMcpToolNames = new Map<string, Set<string>>();
1960
- let mcpSeeded = false;
1961
- const mcpRegistrations = new Map<string, Promise<void>>();
1962
- // Pi's registerTool throws only from assertActive(), whose staleness flag is set with `??=` and
1963
- // never cleared, so a refusal is fatal for this extension instance rather than a per-tool or
1964
- // retryable condition. Once seen, stop attempting registration here; a reload creates a fresh
1965
- // instance with its own state, which starts clean on its own.
1966
- let mcpRegistrationFatal = false;
1967
2007
  let defaultRuntimeAuth: LiteLLMRuntimeAuth | undefined;
1968
- const mcpPauseDir = join(getAgentDir(), "litellm-mcp-pauses");
1969
- const mcpPauseInMemory = new Set<string>();
1970
- let mcpPauseSalt: Buffer | undefined;
1971
- let mcpPausePersistent = true;
1972
- // Keyed by provider: only the default provider has /login, and its login must not cancel an
1973
- // alias's in-flight registration, which login does not retry.
1974
- const mcpLoginGenerations = new Map<string, number>();
1975
- const mcpLoginGeneration = (name: string) => mcpLoginGenerations.get(name) ?? 0;
1976
-
1977
- function getMcpPauseSalt(): Buffer {
1978
- if (!mcpPauseSalt) {
1979
- try {
1980
- mkdirSync(mcpPauseDir, { recursive: true, mode: 0o700 });
1981
- const saltPath = join(mcpPauseDir, "identity-key");
1982
- if (!existsSync(saltPath)) {
1983
- const temporary = join(mcpPauseDir, `.identity-key-${randomBytes(16).toString("hex")}`);
1984
- try {
1985
- writeFileSync(temporary, randomBytes(32), { mode: 0o600, flag: "wx" });
1986
- try {
1987
- // Publish a complete key without replacing one created by another Pi process.
1988
- linkSync(temporary, saltPath);
1989
- } catch (error) {
1990
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
1991
- }
1992
- } finally {
1993
- rmSync(temporary, { force: true });
1994
- }
1995
- }
1996
- const salt = readFileSync(saltPath);
1997
- if (salt.length !== 32) throw new Error("Invalid MCP pause identity key");
1998
- mcpPauseSalt = salt;
1999
- } catch {
2000
- mcpPauseSalt = randomBytes(32);
2001
- mcpPausePersistent = false;
2002
- notifyMcp("LiteLLM MCP: could not read or initialize persisted discovery pause state.");
2003
- }
2004
- }
2005
- return mcpPauseSalt;
2006
- }
2007
-
2008
- function mcpSession(credential: Credential | OAuthCredentials | undefined): string | undefined {
2009
- const session = (credential as { litellmMcpSession?: unknown } | undefined)?.litellmMcpSession;
2010
- return typeof session === "string" ? session : undefined;
2011
- }
2012
-
2013
- function mcpPausePath(auth: McpRuntimeAuth, credential: Credential): string {
2014
- const salt = getMcpPauseSalt();
2015
- const source =
2016
- auth.mcpPauseSource ??
2017
- (credential.type === "api_key" && credential.key?.startsWith("!") ? credential.key : undefined);
2018
- const key = source ?? auth.apiKey;
2019
- const session = mcpSession(credential) ?? credentialFingerprint(key, undefined, salt);
2020
- const identity = credentialFingerprint(
2021
- JSON.stringify([
2022
- normalizeBaseUrl(auth.baseUrl, auth.allowInsecureHttp),
2023
- session,
2024
- credential.type === "oauth" ? null : [source ? "source" : "key", key],
2025
- ]),
2026
- auth.headers,
2027
- salt,
2028
- );
2029
- return join(mcpPauseDir, `paused-${identity}`);
2030
- }
2031
-
2032
- function pauseMcpDiscovery(auth: McpRuntimeAuth, credential: Credential): void {
2033
- const path = mcpPausePath(auth, credential);
2034
- mcpPauseInMemory.add(path);
2035
- try {
2036
- if (!mcpPausePersistent) throw new Error("MCP pause persistence unavailable");
2037
- writeFileSync(path, "", { mode: 0o600 });
2038
- } catch {
2039
- notifyMcp("LiteLLM MCP: could not persist the discovery pause across restarts.");
2040
- }
2041
- }
2042
-
2043
- function resumeMcpDiscovery(): void {
2044
- mcpLoginGenerations.set(PROVIDER_NAME, mcpLoginGeneration(PROVIDER_NAME) + 1);
2045
- registeredMcpIdentities.delete(PROVIDER_NAME);
2046
- // The new login ID selects a fresh scope; other processes may still use the old scopes.
2047
- }
2048
-
2049
- function isMcpPaused(auth: McpRuntimeAuth, credential: Credential): boolean {
2050
- if (!mcpPauseInMemory.size && !existsSync(mcpPauseDir)) return false;
2051
- const path = mcpPausePath(auth, credential);
2052
- return mcpPauseInMemory.has(path) || (mcpPausePersistent && existsSync(path));
2053
- }
2054
-
2055
- // Identifies the catalog a set of credentials points at, so a credential change forces
2056
- // re-registration. The base URL stays readable because it is not secret and is useful when
2057
- // reasoning about a refresh; everything credential-bearing is reduced to a non-reversible
2058
- // fingerprint so no key or header value is held in the identity string.
2059
- function mcpCatalogIdentity(auth: LiteLLMRuntimeAuth, credential: Credential): string {
2060
- return JSON.stringify({
2061
- baseUrl: normalizeBaseUrl(auth.baseUrl, auth.allowInsecureHttp),
2062
- credential: credentialFingerprint(JSON.stringify([auth.apiKey, mcpSession(credential)]), auth.headers),
2063
- });
2064
- }
2008
+ // Bumped by /login litellm so a model refresh that started before the login cannot cache the old
2009
+ // credentials as the default auth.
2010
+ let loginGeneration = 0;
2065
2011
 
2066
2012
  async function getRuntimeAuth(
2067
2013
  ctx: ExtensionContext,
@@ -2104,203 +2050,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
2104
2050
  return defaultRuntimeAuth;
2105
2051
  }
2106
2052
 
2107
- function waitForMcpRegistration(registration: Promise<void>, signal?: AbortSignal): Promise<void> {
2108
- if (!signal) return registration.catch(() => undefined);
2109
- signal.throwIfAborted();
2110
- return new Promise((resolve, reject) => {
2111
- const onAbort = () => {
2112
- signal.removeEventListener("abort", onAbort);
2113
- reject(signal.reason);
2114
- };
2115
- const onComplete = () => {
2116
- signal.removeEventListener("abort", onAbort);
2117
- resolve();
2118
- };
2119
- signal.addEventListener("abort", onAbort, { once: true });
2120
- if (signal.aborted) onAbort();
2121
- else registration.then(onComplete, onComplete);
2122
- });
2123
- }
2124
-
2125
- // Pi before 0.99 reports no `exposure` and declares every registered tool.
2126
- function piHonorsToolExposure(): boolean {
2127
- return pi.getAllTools().some((tool) => "exposure" in tool);
2128
- }
2129
-
2130
- const WITHDRAWN_MCP_MESSAGE = "This LiteLLM MCP tool is no longer offered by the proxy.";
2131
- function withdrawMcpTools(names: ReadonlySet<string>): void {
2132
- for (const name of names) {
2133
- // Older Pi ignores `exposure`, so the stub stays callable and must fail without calling the proxy.
2134
- pi.registerTool({
2135
- name,
2136
- label: "Withdrawn LiteLLM MCP tool",
2137
- description: WITHDRAWN_MCP_MESSAGE,
2138
- parameters: Type.Object({}),
2139
- exposure: "hidden",
2140
- async execute() {
2141
- throw new Error(WITHDRAWN_MCP_MESSAGE);
2142
- },
2143
- });
2144
- }
2145
- if (piHonorsToolExposure()) pi.setActiveTools(pi.getActiveTools().filter((name) => !names.has(name)));
2146
- }
2147
-
2148
- let warnedMcpExposureIgnored = false;
2149
- let warnedMcpUnreachable = false;
2150
- // As in Pi's own MCP extension: tools that are not declared to the model are reached through
2151
- // codemode (`codemode`, `codemode-deferred`) or tool_search (`deferred`). Activate the one the
2152
- // configured exposure needs, and say so once when neither is available.
2153
- function activateMcpDiscoveryTools(exposures: readonly McpExposure[]): void {
2154
- const needsCodemode = exposures.includes("codemode") || exposures.includes("codemode-deferred");
2155
- const needsToolSearch = exposures.includes("deferred");
2156
- if (!needsCodemode && !needsToolSearch) return;
2157
- const tools = pi.getAllTools();
2158
- // Without exposure support every registered tool is declared, so nothing needs activating.
2159
- if (!piHonorsToolExposure()) {
2160
- if (warnedMcpExposureIgnored) return;
2161
- warnedMcpExposureIgnored = true;
2162
- notifyMcp(
2163
- "LiteLLM MCP: this Pi version ignores litellm.mcp.exposure; MCP tools are declared directly to the model.",
2164
- );
2165
- return;
2166
- }
2167
- const hasCodemode = tools.some((tool) => tool.name === "codemode");
2168
- const hasToolSearch = tools.some((tool) => tool.name === "tool_search");
2169
- const active = pi.getActiveTools();
2170
- const activate = [
2171
- ...(needsCodemode && hasCodemode && !active.includes("codemode") ? ["codemode"] : []),
2172
- ...(needsToolSearch && hasToolSearch && !active.includes("tool_search") ? ["tool_search"] : []),
2173
- ];
2174
- if (activate.length > 0) pi.setActiveTools([...active, ...activate]);
2175
- const reachable = [...active, ...activate];
2176
- if ((hasCodemode && reachable.includes("codemode")) || (hasToolSearch && reachable.includes("tool_search"))) return;
2177
- if (warnedMcpUnreachable) return;
2178
- warnedMcpUnreachable = true;
2179
- notifyMcp(
2180
- "LiteLLM MCP: tools with codemode or deferred exposure are reachable only through Pi's codemode or tool_search tool, and neither is available; they cannot be called.",
2181
- );
2182
- }
2183
-
2184
- async function registerMcpTools(
2185
- definition: ProviderDefinition,
2186
- auth: McpRuntimeAuth,
2187
- credential: Credential,
2188
- signal?: AbortSignal,
2189
- ): Promise<void> {
2190
- if (!mcpEnabled || discoveryDisabledReason() || mcpRegistrationFatal || isMcpPaused(auth, credential)) return;
2191
- const loginGeneration = mcpLoginGeneration(definition.name);
2192
- const identity = mcpCatalogIdentity(auth, credential);
2193
- const isDefault = definition.name === PROVIDER_NAME;
2194
- // The default provider keeps its unprefixed tool names and diagnostics; an alias is scoped by name.
2195
- const namespace = isDefault ? undefined : definition.name;
2196
- const notify = isDefault
2197
- ? notifyMcp
2198
- : Object.assign((message: string) => notifyMcp(message), { incidentScope: definition.name });
2199
- const label = isDefault ? "LiteLLM MCP" : `LiteLLM MCP (${JSON.stringify(definition.name)})`;
2200
- let pending = mcpRegistrations.get(definition.name);
2201
- while (pending) {
2202
- await waitForMcpRegistration(pending, signal);
2203
- signal?.throwIfAborted();
2204
- if (
2205
- mcpRegistrationFatal ||
2206
- isMcpPaused(auth, credential) ||
2207
- loginGeneration !== mcpLoginGeneration(definition.name) ||
2208
- registeredMcpIdentities.get(definition.name) === identity
2209
- )
2210
- return;
2211
- pending = mcpRegistrations.get(definition.name);
2212
- }
2213
- if (registeredMcpIdentities.get(definition.name) === identity) return;
2214
-
2215
- const registration = (async () => {
2216
- try {
2217
- signal?.throwIfAborted();
2218
- const { definitions, report } = await createMcpToolDefinitions(
2219
- (ctx) => (ctx?.modelRegistry ? requireRuntimeAuth(ctx, definition) : Promise.resolve(auth)),
2220
- isVerboseDiscovery() ? (message) => notifyMcp(`${label}: ${message}`, "info") : undefined,
2221
- signal,
2222
- notify,
2223
- namespace,
2224
- mcpExposure.policy,
2225
- );
2226
- signal?.throwIfAborted();
2227
- if (loginGeneration !== mcpLoginGeneration(definition.name)) return;
2228
- const registeredNames: string[] = [];
2229
- try {
2230
- for (const definition of definitions) {
2231
- // Checked per tool so a cancelled refresh stops promptly instead of driving a full
2232
- // registry rebuild for every remaining tool.
2233
- signal?.throwIfAborted();
2234
- pi.registerTool(definition);
2235
- registeredNames.push(definition.name);
2236
- }
2237
- } catch (error) {
2238
- if (signal?.aborted) throw signal.reason;
2239
- // Fatal for this instance: report once, with a bounded Pi-authored cause and no proxy text,
2240
- // then stop retrying so a stale instance cannot churn discovery on every later refresh.
2241
- mcpRegistrationFatal = true;
2242
- reportMcpRegistrationFatal(registeredNames.length, definitions.length, error, notify);
2243
- return;
2244
- }
2245
- reportMcpRegistrationSuccess(notify);
2246
- reportMcpPartialDiscovery(report.partialFailure, registeredNames, notify);
2247
- const previousNames = registeredMcpToolNames.get(definition.name) ?? new Set<string>();
2248
- const currentNames = new Set(registeredNames);
2249
- // A partial or empty catalog may omit live tools, so it withdraws nothing and keeps the old names.
2250
- const settled = definitions.length > 0 && !report.partialFailure;
2251
- const withdrawn = settled ? new Set([...previousNames].filter((n) => !currentNames.has(n))) : new Set<string>();
2252
- try {
2253
- if (withdrawn.size > 0) withdrawMcpTools(withdrawn);
2254
- } catch (error) {
2255
- if (signal?.aborted) throw signal.reason;
2256
- mcpRegistrationFatal = true;
2257
- reportMcpRegistrationFatal(registeredNames.length, definitions.length, error, notify);
2258
- return;
2259
- }
2260
- if (definitions.length > 0) {
2261
- if (!settled) for (const name of previousNames) currentNames.add(name);
2262
- registeredMcpToolNames.set(definition.name, currentNames);
2263
- }
2264
- if (withdrawn.size > 0 && isVerboseDiscovery()) {
2265
- notifyMcp(`${label}: withdrew ${withdrawn.size} MCP tools no longer offered by the proxy.`, "info");
2266
- }
2267
- activateMcpDiscoveryTools(report.exposures);
2268
- if (isVerboseDiscovery()) {
2269
- notifyMcp(
2270
- `${label}: registered ${registeredNames.length} of ${definitions.length} prepared MCP tools ` +
2271
- `(${report.discovered} raw, ${report.enveloped} enveloped).`,
2272
- "info",
2273
- );
2274
- }
2275
- // A catalog that produced nothing or came from a partial-failure response is not settled:
2276
- // leaving the identity unset lets a later refresh retry discovery, which is network-only and
2277
- // non-blocking. Re-registering surviving tools is safe because Pi replaces tools by name.
2278
- reportMcpCatalogOutcome(report.discovered, definitions.length, notify);
2279
- if (definitions.length > 0 && !report.partialFailure) registeredMcpIdentities.set(definition.name, identity);
2280
- } catch (error) {
2281
- if (signal?.aborted) throw signal.reason;
2282
- if (loginGeneration !== mcpLoginGeneration(definition.name)) return;
2283
- if (error instanceof McpAccessDeniedError) {
2284
- pauseMcpDiscovery(auth, credential);
2285
- // Aliases have no /login; their pause scope is their URL, key, and headers, so a change lifts it.
2286
- notifyMcp(
2287
- `${label}: access denied; discovery paused until ${isDefault ? "/login litellm succeeds" : "its credentials change"}.`,
2288
- );
2289
- return;
2290
- }
2291
- notifyMcp(
2292
- `LiteLLM (${definition.name}): MCP tool discovery failed (${error instanceof Error ? error.message : String(error)}).`,
2293
- );
2294
- }
2295
- })();
2296
- mcpRegistrations.set(definition.name, registration);
2297
- try {
2298
- await registration;
2299
- } finally {
2300
- if (mcpRegistrations.get(definition.name) === registration) mcpRegistrations.delete(definition.name);
2301
- }
2302
- }
2303
-
2304
2053
  async function runDiscovery(auth: LiteLLMRuntimeAuth, signal?: AbortSignal) {
2305
2054
  const result = await discoverModels(auth.baseUrl, auth.apiKey, {
2306
2055
  timeoutMs: getDiscoveryTimeoutMs(),
@@ -2353,26 +2102,20 @@ export default async function (pi: ExtensionAPI): Promise<void> {
2353
2102
  const auth = createProviderAuth(
2354
2103
  definition,
2355
2104
  () => oauthRuntimeRoots.delete(definition.name),
2356
- definition.name === PROVIDER_NAME ? resumeMcpDiscovery : undefined,
2105
+ definition.name === PROVIDER_NAME
2106
+ ? {
2107
+ // Disconnect before the login can store a new credential: Pi's MCP client reads the
2108
+ // provider's current token on every request, including the session teardown, so a
2109
+ // connection still open to the old proxy root would send it the new credential.
2110
+ start: () => dropMcpServer(definition),
2111
+ complete: () => {
2112
+ loginGeneration++;
2113
+ },
2114
+ }
2115
+ : undefined,
2357
2116
  () => oauthRuntimeRoots.get(definition.name),
2358
2117
  );
2359
2118
  if (auth.oauth) {
2360
- if (definition.name === PROVIDER_NAME) {
2361
- const refresh = auth.oauth.refresh;
2362
- auth.oauth.refresh = async (credential, signal) => {
2363
- // Resolve legacy credentials' stable scope before an in-flight denial or token rotation.
2364
- const session =
2365
- mcpSession(credential) ??
2366
- (mcpEnabled ? credentialFingerprint(credential.access, undefined, getMcpPauseSalt()) : undefined);
2367
- const refreshed = await refresh(credential, signal);
2368
- return session &&
2369
- (mcpSession(credential) ||
2370
- refreshed.access !== credential.access ||
2371
- refreshed.refresh !== credential.refresh)
2372
- ? { ...refreshed, litellmMcpSession: session }
2373
- : refreshed;
2374
- };
2375
- }
2376
2119
  const toAuth = auth.oauth.toAuth;
2377
2120
  auth.oauth.toAuth = async (credential) => {
2378
2121
  const resolved = await toAuth(credential);
@@ -2407,7 +2150,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
2407
2150
  Object.assign(provider, { headers: resolveHeaders(definition) });
2408
2151
  const refreshModels = provider.refreshModels!;
2409
2152
  provider.refreshModels = async (context) => {
2410
- const loginGeneration = mcpLoginGeneration(definition.name);
2153
+ const refreshGeneration = loginGeneration;
2411
2154
  if (context.allowNetwork) {
2412
2155
  networkRefreshAttempts.set(definition.name, (networkRefreshAttempts.get(definition.name) ?? 0) + 1);
2413
2156
  }
@@ -2415,19 +2158,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
2415
2158
  await refreshModels(context);
2416
2159
  } finally {
2417
2160
  if (
2161
+ definition.name === PROVIDER_NAME &&
2418
2162
  context.allowNetwork &&
2419
2163
  !discoveryDisabledReason() &&
2420
- loginGeneration === mcpLoginGeneration(definition.name) &&
2164
+ refreshGeneration === loginGeneration &&
2421
2165
  context.credential
2422
2166
  ) {
2423
- // Best-effort: refreshing the cached default auth / MCP catalog must not let a bad or
2424
- // placeholder credential override refreshModels' own try/throw outcome via `finally`.
2167
+ // Best-effort: refreshing the cached default auth must not let a bad or placeholder
2168
+ // credential override refreshModels' own try/throw outcome via `finally`.
2425
2169
  try {
2426
2170
  const auth = await authForCredential(definition, context.credential);
2427
- if (loginGeneration === mcpLoginGeneration(definition.name)) {
2428
- if (definition.name === PROVIDER_NAME) defaultRuntimeAuth = auth;
2429
- void registerMcpTools(definition, auth, context.credential, context.signal).catch(() => undefined);
2430
- }
2171
+ if (refreshGeneration === loginGeneration) defaultRuntimeAuth = auth;
2431
2172
  } catch {
2432
2173
  // ignored — authForCredential already reported/will report this via the paths that use it directly.
2433
2174
  }
@@ -2522,8 +2263,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
2522
2263
  warnedFallbackRoutes.add(key);
2523
2264
  const route = JSON.stringify(model.id);
2524
2265
  const message =
2525
- `LiteLLM (${JSON.stringify(model.provider)}): a fallback served ${route}, but protocol and model handling were chosen ` +
2526
- `for ${route}'s own deployments. If its fallbacks cross model families, pin the protocol with ` +
2266
+ `LiteLLM (${JSON.stringify(model.provider)}): the request for ${route} was answered by a fallback, but protocol and model handling were chosen ` +
2267
+ `for ${route}'s own deployments, not the fallback's. If its fallbacks cross model families, pin the protocol with ` +
2527
2268
  "`model_info.supported_endpoints`.";
2528
2269
  if (ctx.hasUI) ctx.ui.notify(message, "warning");
2529
2270
  else process.stderr.write(`${message}\n`);
@@ -2551,40 +2292,15 @@ export default async function (pi: ExtensionAPI): Promise<void> {
2551
2292
  return { systemPrompt: `${event.systemPrompt}\n\n${section}` };
2552
2293
  });
2553
2294
 
2554
- // MCP tools otherwise register only from refreshModels' `finally`, which needs
2555
- // context.allowNetwork. Pi core sets that solely for the interactive TUI and the RPC
2556
- // background refresh, so `-p` and `--list-models` register no MCP tool at all, even when
2557
- // credentials resolve and the proxy is reachable — the defect #136 fixed for models.
2558
- //
2559
- // Seeding here rather than at activation is deliberate: registerMcpTools serialises callers
2560
- // per provider, so an activation-time call would become an in-flight registration
2561
- // that Pi's own refresh then waits on. Running once before the first turn keeps that ordering
2562
- // intact, still precedes any tool use, and leaves startup latency untouched.
2563
- async function seedMcpTools(definition: ProviderDefinition): Promise<void> {
2564
- try {
2565
- const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
2566
- // executeHelpers:false — seeding must never run the user's key helper as a side effect.
2567
- const auth = await authForCredential(definition, stored, false);
2568
- // An env-configured provider has no stored credential and must still seed. The credential
2569
- // only derives the pause identity, so the resolved key reproduces the stored api_key scope.
2570
- const credential: Credential = stored ?? { type: "api_key", key: auth.apiKey };
2571
- await registerMcpTools(definition, auth, credential, AbortSignal.timeout(getSeedTimeoutMs()));
2572
- } catch (error) {
2573
- if (isVerboseDiscovery()) {
2574
- process.stderr.write(
2575
- `LiteLLM (${definition.name}): MCP seeding skipped (${error instanceof Error ? error.message : String(error)}).\n`,
2576
- );
2577
- }
2578
- }
2295
+ if (mcpEnabled) {
2296
+ // Servers registered while the extension loads connect when the session starts. Each turn
2297
+ // picks up a login that changed the proxy root, and a logout that removed it.
2298
+ for (const definition of definitions) syncMcpServer(definition);
2299
+ pi.on("before_agent_start", () => {
2300
+ for (const definition of definitions) syncMcpServer(definition);
2301
+ });
2579
2302
  }
2580
2303
 
2581
- pi.on("before_agent_start", async () => {
2582
- if (!mcpEnabled || mcpSeeded || discoveryDisabledReason() || isHostOffline()) return;
2583
- mcpSeeded = true;
2584
- // Catalogs are independent per provider, so every configured provider seeds in parallel.
2585
- await Promise.all(definitions.map(seedMcpTools));
2586
- });
2587
-
2588
2304
  pi.on("message_end", (event, ctx) => {
2589
2305
  if (event.message.role !== "assistant") return;
2590
2306
  // Resolve the discovered model that produced this message so the display
package/src/provider.ts CHANGED
@@ -29,7 +29,7 @@ export type LiteLLMProviderOptions = {
29
29
  discover(credential: Credential, signal?: AbortSignal): Promise<DiscoveryResult & { baseUrl?: string }>;
30
30
  };
31
31
 
32
- // Chat models may omit `type`; Pi's `isModelType()` is newer than the peer floor, so keep the check local.
32
+ // Chat models may omit `type`.
33
33
  function isChatModel(model: AnyModel): model is Model<Api> {
34
34
  return (model.type ?? "chat") === "chat";
35
35
  }
package/src/skills.ts CHANGED
@@ -169,8 +169,7 @@ function parseJsonObject(value: string, fieldName: string): Record<string, unkno
169
169
  return parsed as Record<string, unknown>;
170
170
  }
171
171
 
172
- // Pi 0.99 groups tools by namespace and lets permission extensions read annotation hints; older
173
- // Pi ignores both fields.
172
+ // Pi groups tools by namespace and lets permission extensions read annotation hints.
174
173
  const SKILLS_NAMESPACE = { name: "litellm_skills", description: "LiteLLM Skills Gateway" };
175
174
 
176
175
  export function createSkillToolDefinitions(