pi-provider-litellm 3.0.1 → 3.2.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 +25 -10
- package/package.json +1 -1
- package/src/backend-identity.ts +14 -4
- package/src/discover.ts +42 -17
- package/src/index.ts +211 -50
- package/src/mcp-tools.ts +35 -22
- package/src/model-groups.ts +78 -34
- package/src/public-catalog.ts +24 -15
- package/src/skills.ts +23 -76
- package/src/thinking-levels.ts +7 -2
- package/src/types.ts +3 -0
package/src/index.ts
CHANGED
|
@@ -31,7 +31,14 @@ import {
|
|
|
31
31
|
} from "./mcp-tools.js";
|
|
32
32
|
import { createLiteLLMProvider, DEFAULT_LITELLM_BASE_URL, isPlaceholderHost, toNativeModels } from "./provider.js";
|
|
33
33
|
import { createSkillsPromptSection, createSkillToolDefinitions, listSkills } from "./skills.js";
|
|
34
|
-
import type {
|
|
34
|
+
import type {
|
|
35
|
+
DiscoveryOptions,
|
|
36
|
+
LiteLLMApi,
|
|
37
|
+
LiteLLMModel,
|
|
38
|
+
LiteLLMModelPolicy,
|
|
39
|
+
LiteLLMRuntimeAuth,
|
|
40
|
+
ResolvedCredentials,
|
|
41
|
+
} from "./types.js";
|
|
35
42
|
|
|
36
43
|
const PROVIDER_NAME = "litellm";
|
|
37
44
|
const SETTINGS_KEY = "litellm";
|
|
@@ -44,6 +51,7 @@ const ENV_TIMEOUT = "LITELLM_DISCOVERY_TIMEOUT_MS";
|
|
|
44
51
|
const ENV_CLI_JWT_EXPIRATION_HOURS = "LITELLM_CLI_JWT_EXPIRATION_HOURS";
|
|
45
52
|
const ENV_OFFLINE = "LITELLM_OFFLINE";
|
|
46
53
|
const ENV_VERBOSE_DISCOVERY = "LITELLM_VERBOSE_DISCOVERY";
|
|
54
|
+
const ENV_MODELS_DEV = "LITELLM_MODELS_DEV";
|
|
47
55
|
const MODELS_DEV_CACHE_FILENAME = "litellm-models-dev.json";
|
|
48
56
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
49
57
|
const SEED_TIMEOUT_MS = 3000;
|
|
@@ -444,6 +452,16 @@ function isHostOffline(): boolean {
|
|
|
444
452
|
return process.env.PI_OFFLINE !== undefined;
|
|
445
453
|
}
|
|
446
454
|
|
|
455
|
+
/**
|
|
456
|
+
* LiteLLM's /model/info is the metadata authority, so models.dev is an opt-in escape hatch.
|
|
457
|
+
* Without a cache path and with refresh suppressed, discovery reads neither the models.dev
|
|
458
|
+
* network nor a cache left by an earlier release; Pi's own catalog still applies.
|
|
459
|
+
*/
|
|
460
|
+
function modelsDevDiscoveryOptions(): Pick<DiscoveryOptions, "modelsDev" | "modelsDevCachePath"> {
|
|
461
|
+
if (process.env[ENV_MODELS_DEV] !== "1") return { modelsDev: false };
|
|
462
|
+
return { modelsDev: !isHostOffline(), modelsDevCachePath: join(getAgentDir(), MODELS_DEV_CACHE_FILENAME) };
|
|
463
|
+
}
|
|
464
|
+
|
|
447
465
|
/**
|
|
448
466
|
* Absolute budget for activation-time discovery. Pi cannot paint its UI until every extension has
|
|
449
467
|
* activated, so this is deliberately capped rather than following LITELLM_DISCOVERY_TIMEOUT_MS,
|
|
@@ -1625,8 +1643,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1625
1643
|
};
|
|
1626
1644
|
}
|
|
1627
1645
|
|
|
1628
|
-
|
|
1629
|
-
|
|
1646
|
+
// Each provider owns its own MCP catalog, keyed by provider name.
|
|
1647
|
+
const registeredMcpIdentities = new Map<string, string>();
|
|
1648
|
+
let mcpSeeded = false;
|
|
1649
|
+
const mcpRegistrations = new Map<string, Promise<void>>();
|
|
1630
1650
|
// Pi's registerTool throws only from assertActive(), whose staleness flag is set with `??=` and
|
|
1631
1651
|
// never cleared, so a refusal is fatal for this extension instance rather than a per-tool or
|
|
1632
1652
|
// retryable condition. Once seen, stop attempting registration here; a reload creates a fresh
|
|
@@ -1637,7 +1657,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1637
1657
|
const mcpPauseInMemory = new Set<string>();
|
|
1638
1658
|
let mcpPauseSalt: Buffer | undefined;
|
|
1639
1659
|
let mcpPausePersistent = true;
|
|
1640
|
-
|
|
1660
|
+
// Keyed by provider: only the default provider has /login, and its login must not cancel an
|
|
1661
|
+
// alias's in-flight registration, which login does not retry.
|
|
1662
|
+
const mcpLoginGenerations = new Map<string, number>();
|
|
1663
|
+
const mcpLoginGeneration = (name: string) => mcpLoginGenerations.get(name) ?? 0;
|
|
1641
1664
|
|
|
1642
1665
|
function getMcpPauseSalt(): Buffer {
|
|
1643
1666
|
if (!mcpPauseSalt) {
|
|
@@ -1706,8 +1729,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1706
1729
|
}
|
|
1707
1730
|
|
|
1708
1731
|
function resumeMcpDiscovery(): void {
|
|
1709
|
-
mcpLoginGeneration
|
|
1710
|
-
|
|
1732
|
+
mcpLoginGenerations.set(PROVIDER_NAME, mcpLoginGeneration(PROVIDER_NAME) + 1);
|
|
1733
|
+
registeredMcpIdentities.delete(PROVIDER_NAME);
|
|
1711
1734
|
// The new login ID selects a fresh scope; other processes may still use the old scopes.
|
|
1712
1735
|
}
|
|
1713
1736
|
|
|
@@ -1728,17 +1751,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1728
1751
|
});
|
|
1729
1752
|
}
|
|
1730
1753
|
|
|
1731
|
-
async function getRuntimeAuth(
|
|
1732
|
-
|
|
1754
|
+
async function getRuntimeAuth(
|
|
1755
|
+
ctx: ExtensionContext,
|
|
1756
|
+
definition = definitions[0]!,
|
|
1757
|
+
): Promise<LiteLLMRuntimeAuth | undefined> {
|
|
1758
|
+
const auth = await ctx.modelRegistry.getProviderAuth(definition.name);
|
|
1733
1759
|
if (!auth) return undefined;
|
|
1734
|
-
const provider = ctx.modelRegistry.getProvider(
|
|
1760
|
+
const provider = ctx.modelRegistry.getProvider(definition.name);
|
|
1735
1761
|
const apiKey = auth.auth.apiKey;
|
|
1736
1762
|
const baseUrl = cleanConfig(auth.auth.baseUrl) ?? cleanConfig(auth.env?.[ENV_BASE_URL]) ?? provider?.baseUrl;
|
|
1737
1763
|
if (!baseUrl || !apiKey) return undefined;
|
|
1738
|
-
const runtimeRoot = requireCredentialRoot(
|
|
1739
|
-
normalizeBaseUrl(baseUrl, definitions[0]?.allowInsecureHttp),
|
|
1740
|
-
PROVIDER_NAME,
|
|
1741
|
-
);
|
|
1764
|
+
const runtimeRoot = requireCredentialRoot(normalizeBaseUrl(baseUrl, definition.allowInsecureHttp), definition.name);
|
|
1742
1765
|
const headers = Object.fromEntries(
|
|
1743
1766
|
Object.entries(auth.auth.headers ?? provider?.headers ?? {}).filter(
|
|
1744
1767
|
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
@@ -1748,16 +1771,23 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1748
1771
|
baseUrl: runtimeRoot,
|
|
1749
1772
|
apiKey,
|
|
1750
1773
|
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
1751
|
-
allowInsecureHttp:
|
|
1774
|
+
allowInsecureHttp: definition.allowInsecureHttp,
|
|
1752
1775
|
};
|
|
1753
1776
|
}
|
|
1754
1777
|
|
|
1778
|
+
function missingCredentials(definition: ProviderDefinition): string {
|
|
1779
|
+
const fix = definition.name === PROVIDER_NAME ? "Run /login litellm or set env vars" : "Set its baseUrl and apiKey";
|
|
1780
|
+
return `no credentials for ${definition.name}. ${fix}.`;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
async function requireRuntimeAuth(ctx: ExtensionContext, definition = definitions[0]!): Promise<LiteLLMRuntimeAuth> {
|
|
1784
|
+
const auth = await getRuntimeAuth(ctx, definition);
|
|
1785
|
+
if (auth) return auth;
|
|
1786
|
+
throw new Error(missingCredentials(definition));
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1755
1789
|
async function resolveDefaultRuntimeAuth(ctx?: ExtensionContext): Promise<LiteLLMRuntimeAuth> {
|
|
1756
|
-
if (ctx?.modelRegistry)
|
|
1757
|
-
const auth = await getRuntimeAuth(ctx);
|
|
1758
|
-
if (auth) return auth;
|
|
1759
|
-
throw new Error("no credentials for litellm. Run /login litellm or set env vars.");
|
|
1760
|
-
}
|
|
1790
|
+
if (ctx?.modelRegistry) return requireRuntimeAuth(ctx);
|
|
1761
1791
|
if (!defaultRuntimeAuth) throw new Error("no credentials for litellm. Run /login litellm or set env vars.");
|
|
1762
1792
|
return defaultRuntimeAuth;
|
|
1763
1793
|
}
|
|
@@ -1780,34 +1810,49 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1780
1810
|
});
|
|
1781
1811
|
}
|
|
1782
1812
|
|
|
1783
|
-
async function registerMcpTools(
|
|
1813
|
+
async function registerMcpTools(
|
|
1814
|
+
definition: ProviderDefinition,
|
|
1815
|
+
auth: McpRuntimeAuth,
|
|
1816
|
+
credential: Credential,
|
|
1817
|
+
signal?: AbortSignal,
|
|
1818
|
+
): Promise<void> {
|
|
1784
1819
|
if (!mcpEnabled || discoveryDisabledReason() || mcpRegistrationFatal || isMcpPaused(auth, credential)) return;
|
|
1785
|
-
const loginGeneration = mcpLoginGeneration;
|
|
1820
|
+
const loginGeneration = mcpLoginGeneration(definition.name);
|
|
1786
1821
|
const identity = mcpCatalogIdentity(auth, credential);
|
|
1787
|
-
|
|
1788
|
-
|
|
1822
|
+
const isDefault = definition.name === PROVIDER_NAME;
|
|
1823
|
+
// The default provider keeps its unprefixed tool names and diagnostics; an alias is scoped by name.
|
|
1824
|
+
const namespace = isDefault ? undefined : definition.name;
|
|
1825
|
+
const notify = isDefault
|
|
1826
|
+
? notifyMcp
|
|
1827
|
+
: Object.assign((message: string) => notifyMcp(message), { incidentScope: definition.name });
|
|
1828
|
+
const label = isDefault ? "LiteLLM MCP" : `LiteLLM MCP (${JSON.stringify(definition.name)})`;
|
|
1829
|
+
let pending = mcpRegistrations.get(definition.name);
|
|
1830
|
+
while (pending) {
|
|
1831
|
+
await waitForMcpRegistration(pending, signal);
|
|
1789
1832
|
signal?.throwIfAborted();
|
|
1790
1833
|
if (
|
|
1791
1834
|
mcpRegistrationFatal ||
|
|
1792
1835
|
isMcpPaused(auth, credential) ||
|
|
1793
|
-
loginGeneration !== mcpLoginGeneration ||
|
|
1794
|
-
|
|
1836
|
+
loginGeneration !== mcpLoginGeneration(definition.name) ||
|
|
1837
|
+
registeredMcpIdentities.get(definition.name) === identity
|
|
1795
1838
|
)
|
|
1796
1839
|
return;
|
|
1840
|
+
pending = mcpRegistrations.get(definition.name);
|
|
1797
1841
|
}
|
|
1798
|
-
if (
|
|
1842
|
+
if (registeredMcpIdentities.get(definition.name) === identity) return;
|
|
1799
1843
|
|
|
1800
1844
|
const registration = (async () => {
|
|
1801
1845
|
try {
|
|
1802
1846
|
signal?.throwIfAborted();
|
|
1803
1847
|
const { definitions, report } = await createMcpToolDefinitions(
|
|
1804
|
-
(ctx) => (ctx?.modelRegistry ?
|
|
1805
|
-
isVerboseDiscovery() ? (message) => notifyMcp(
|
|
1848
|
+
(ctx) => (ctx?.modelRegistry ? requireRuntimeAuth(ctx, definition) : Promise.resolve(auth)),
|
|
1849
|
+
isVerboseDiscovery() ? (message) => notifyMcp(`${label}: ${message}`, "info") : undefined,
|
|
1806
1850
|
signal,
|
|
1807
|
-
|
|
1851
|
+
notify,
|
|
1852
|
+
namespace,
|
|
1808
1853
|
);
|
|
1809
1854
|
signal?.throwIfAborted();
|
|
1810
|
-
if (loginGeneration !== mcpLoginGeneration) return;
|
|
1855
|
+
if (loginGeneration !== mcpLoginGeneration(definition.name)) return;
|
|
1811
1856
|
const registeredNames: string[] = [];
|
|
1812
1857
|
try {
|
|
1813
1858
|
for (const definition of definitions) {
|
|
@@ -1822,14 +1867,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1822
1867
|
// Fatal for this instance: report once, with a bounded Pi-authored cause and no proxy text,
|
|
1823
1868
|
// then stop retrying so a stale instance cannot churn discovery on every later refresh.
|
|
1824
1869
|
mcpRegistrationFatal = true;
|
|
1825
|
-
reportMcpRegistrationFatal(registeredNames.length, definitions.length, error,
|
|
1870
|
+
reportMcpRegistrationFatal(registeredNames.length, definitions.length, error, notify);
|
|
1826
1871
|
return;
|
|
1827
1872
|
}
|
|
1828
|
-
reportMcpRegistrationSuccess();
|
|
1829
|
-
reportMcpPartialDiscovery(report.partialFailure, registeredNames,
|
|
1873
|
+
reportMcpRegistrationSuccess(notify);
|
|
1874
|
+
reportMcpPartialDiscovery(report.partialFailure, registeredNames, notify);
|
|
1830
1875
|
if (isVerboseDiscovery()) {
|
|
1831
1876
|
notifyMcp(
|
|
1832
|
-
|
|
1877
|
+
`${label}: registered ${registeredNames.length} of ${definitions.length} prepared MCP tools ` +
|
|
1833
1878
|
`(${report.discovered} raw, ${report.enveloped} enveloped).`,
|
|
1834
1879
|
"info",
|
|
1835
1880
|
);
|
|
@@ -1837,26 +1882,29 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1837
1882
|
// A catalog that produced nothing or came from a partial-failure response is not settled:
|
|
1838
1883
|
// leaving the identity unset lets a later refresh retry discovery, which is network-only and
|
|
1839
1884
|
// non-blocking. Re-registering surviving tools is safe because Pi replaces tools by name.
|
|
1840
|
-
reportMcpCatalogOutcome(report.discovered, definitions.length,
|
|
1841
|
-
if (definitions.length > 0 && !report.partialFailure)
|
|
1885
|
+
reportMcpCatalogOutcome(report.discovered, definitions.length, notify);
|
|
1886
|
+
if (definitions.length > 0 && !report.partialFailure) registeredMcpIdentities.set(definition.name, identity);
|
|
1842
1887
|
} catch (error) {
|
|
1843
1888
|
if (signal?.aborted) throw signal.reason;
|
|
1844
|
-
if (loginGeneration !== mcpLoginGeneration) return;
|
|
1889
|
+
if (loginGeneration !== mcpLoginGeneration(definition.name)) return;
|
|
1845
1890
|
if (error instanceof McpAccessDeniedError) {
|
|
1846
1891
|
pauseMcpDiscovery(auth, credential);
|
|
1847
|
-
|
|
1892
|
+
// Aliases have no /login; their pause scope is their URL, key, and headers, so a change lifts it.
|
|
1893
|
+
notifyMcp(
|
|
1894
|
+
`${label}: access denied; discovery paused until ${isDefault ? "/login litellm succeeds" : "its credentials change"}.`,
|
|
1895
|
+
);
|
|
1848
1896
|
return;
|
|
1849
1897
|
}
|
|
1850
1898
|
notifyMcp(
|
|
1851
|
-
`LiteLLM (${
|
|
1899
|
+
`LiteLLM (${definition.name}): MCP tool discovery failed (${error instanceof Error ? error.message : String(error)}).`,
|
|
1852
1900
|
);
|
|
1853
1901
|
}
|
|
1854
1902
|
})();
|
|
1855
|
-
|
|
1903
|
+
mcpRegistrations.set(definition.name, registration);
|
|
1856
1904
|
try {
|
|
1857
1905
|
await registration;
|
|
1858
1906
|
} finally {
|
|
1859
|
-
if (
|
|
1907
|
+
if (mcpRegistrations.get(definition.name) === registration) mcpRegistrations.delete(definition.name);
|
|
1860
1908
|
}
|
|
1861
1909
|
}
|
|
1862
1910
|
|
|
@@ -1866,8 +1914,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1866
1914
|
signal,
|
|
1867
1915
|
headers: auth.headers,
|
|
1868
1916
|
allowInsecureHttp: auth.allowInsecureHttp,
|
|
1869
|
-
|
|
1870
|
-
modelsDevCachePath: join(getAgentDir(), MODELS_DEV_CACHE_FILENAME),
|
|
1917
|
+
...modelsDevDiscoveryOptions(),
|
|
1871
1918
|
silent: !isVerboseDiscovery(),
|
|
1872
1919
|
onProgress: isVerboseDiscovery() ? (message) => process.stderr.write(`LiteLLM: ${message}\n`) : undefined,
|
|
1873
1920
|
});
|
|
@@ -1879,7 +1926,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1879
1926
|
// network phase, so the provider would stay empty until the user opens /model. Discover here
|
|
1880
1927
|
// instead, the way 1.x did, and hand the catalog over as the provider's baseline models.
|
|
1881
1928
|
async function seedModels(definition: ProviderDefinition): Promise<Model<LiteLLMApi>[]> {
|
|
1882
|
-
|
|
1929
|
+
const disabledReason = discoveryDisabledReason() ?? (isHostOffline() ? "PI_OFFLINE" : null);
|
|
1930
|
+
if (disabledReason) {
|
|
1931
|
+
if (isVerboseDiscovery()) {
|
|
1932
|
+
process.stderr.write(`LiteLLM (${definition.name}): startup discovery skipped (${disabledReason}).\n`);
|
|
1933
|
+
}
|
|
1934
|
+
return [];
|
|
1935
|
+
}
|
|
1883
1936
|
try {
|
|
1884
1937
|
const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
|
|
1885
1938
|
// executeHelpers:false — activation must never run the user's key helper as a side effect,
|
|
@@ -1899,6 +1952,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1899
1952
|
}
|
|
1900
1953
|
|
|
1901
1954
|
const seeded = await Promise.all(definitions.map(seedModels));
|
|
1955
|
+
// Pi runs a provider's network phase only once a credential resolves, so /litellm-refresh reads an
|
|
1956
|
+
// unchanged count as "no credentials" rather than as a successful refresh.
|
|
1957
|
+
const networkRefreshAttempts = new Map<string, number>();
|
|
1902
1958
|
|
|
1903
1959
|
for (const [index, definition] of definitions.entries()) {
|
|
1904
1960
|
const auth = createProviderAuth(
|
|
@@ -1958,24 +2014,26 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1958
2014
|
Object.assign(provider, { headers: resolveHeaders(definition) });
|
|
1959
2015
|
const refreshModels = provider.refreshModels!;
|
|
1960
2016
|
provider.refreshModels = async (context) => {
|
|
1961
|
-
const loginGeneration = mcpLoginGeneration;
|
|
2017
|
+
const loginGeneration = mcpLoginGeneration(definition.name);
|
|
2018
|
+
if (context.allowNetwork) {
|
|
2019
|
+
networkRefreshAttempts.set(definition.name, (networkRefreshAttempts.get(definition.name) ?? 0) + 1);
|
|
2020
|
+
}
|
|
1962
2021
|
try {
|
|
1963
2022
|
await refreshModels(context);
|
|
1964
2023
|
} finally {
|
|
1965
2024
|
if (
|
|
1966
|
-
definition.name === PROVIDER_NAME &&
|
|
1967
2025
|
context.allowNetwork &&
|
|
1968
2026
|
!discoveryDisabledReason() &&
|
|
1969
|
-
loginGeneration === mcpLoginGeneration &&
|
|
2027
|
+
loginGeneration === mcpLoginGeneration(definition.name) &&
|
|
1970
2028
|
context.credential
|
|
1971
2029
|
) {
|
|
1972
2030
|
// Best-effort: refreshing the cached default auth / MCP catalog must not let a bad or
|
|
1973
2031
|
// placeholder credential override refreshModels' own try/throw outcome via `finally`.
|
|
1974
2032
|
try {
|
|
1975
2033
|
const auth = await authForCredential(definition, context.credential);
|
|
1976
|
-
if (loginGeneration === mcpLoginGeneration) {
|
|
1977
|
-
defaultRuntimeAuth = auth;
|
|
1978
|
-
void registerMcpTools(auth, context.credential, context.signal).catch(() => undefined);
|
|
2034
|
+
if (loginGeneration === mcpLoginGeneration(definition.name)) {
|
|
2035
|
+
if (definition.name === PROVIDER_NAME) defaultRuntimeAuth = auth;
|
|
2036
|
+
void registerMcpTools(definition, auth, context.credential, context.signal).catch(() => undefined);
|
|
1979
2037
|
}
|
|
1980
2038
|
} catch {
|
|
1981
2039
|
// ignored — authForCredential already reported/will report this via the paths that use it directly.
|
|
@@ -1986,6 +2044,54 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1986
2044
|
pi.registerProvider(provider);
|
|
1987
2045
|
}
|
|
1988
2046
|
|
|
2047
|
+
// Pi documents PI_OFFLINE as disabling model catalog refreshes, including the one /model starts, and a
|
|
2048
|
+
// provider cannot tell that refresh from an automatic one. This is the explicit refresh: it reaches only
|
|
2049
|
+
// the configured proxies, so LITELLM_OFFLINE=1 and a zero timeout still disable it and models.dev stays off.
|
|
2050
|
+
pi.registerCommand("litellm-refresh", {
|
|
2051
|
+
description: "Refresh LiteLLM model catalogs from the proxy, even with PI_OFFLINE set",
|
|
2052
|
+
getArgumentCompletions: (prefix) => {
|
|
2053
|
+
const matches = [...providerNames].filter((name) => name.startsWith(prefix));
|
|
2054
|
+
return matches.length > 0 ? matches.map((name) => ({ value: name, label: name })) : null;
|
|
2055
|
+
},
|
|
2056
|
+
handler: async (args, ctx) => {
|
|
2057
|
+
const notify = (message: string, level: "info" | "warning"): void => {
|
|
2058
|
+
if (ctx.hasUI) ctx.ui.notify(message, level);
|
|
2059
|
+
else process.stderr.write(`${message}\n`);
|
|
2060
|
+
};
|
|
2061
|
+
const disabledReason = discoveryDisabledReason();
|
|
2062
|
+
if (disabledReason) {
|
|
2063
|
+
notify(`LiteLLM: model refresh skipped (${disabledReason}).`, "warning");
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
const requested = args.trim();
|
|
2067
|
+
const selected = requested ? definitions.filter(({ name }) => name === requested) : definitions;
|
|
2068
|
+
if (selected.length === 0) {
|
|
2069
|
+
const configured = [...providerNames].map((name) => JSON.stringify(name)).join(", ");
|
|
2070
|
+
notify(`LiteLLM: unknown provider ${JSON.stringify(requested)}; configured: ${configured}.`, "warning");
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
const attempts = new Map(selected.map(({ name }) => [name, networkRefreshAttempts.get(name) ?? 0]));
|
|
2074
|
+
const result = await ctx.modelRegistry.refresh({
|
|
2075
|
+
providers: selected.map(({ name }) => name),
|
|
2076
|
+
allowNetwork: true,
|
|
2077
|
+
force: true,
|
|
2078
|
+
});
|
|
2079
|
+
const models = ctx.modelRegistry.getAll();
|
|
2080
|
+
for (const definition of selected) {
|
|
2081
|
+
const label = `LiteLLM (${JSON.stringify(definition.name)})`;
|
|
2082
|
+
const error = result.errors.get(definition.name);
|
|
2083
|
+
if (error) {
|
|
2084
|
+
notify(`${label}: model refresh failed (${error.message}).`, "warning");
|
|
2085
|
+
} else if ((networkRefreshAttempts.get(definition.name) ?? 0) === attempts.get(definition.name)) {
|
|
2086
|
+
notify(`${label}: model refresh skipped; ${missingCredentials(definition)}`, "warning");
|
|
2087
|
+
} else {
|
|
2088
|
+
const count = models.filter((model) => model.provider === definition.name).length;
|
|
2089
|
+
notify(`${label}: refreshed ${count} model${count === 1 ? "" : "s"}.`, "info");
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
},
|
|
2093
|
+
});
|
|
2094
|
+
|
|
1989
2095
|
setupLiteLLMCostTracking(pi, [...providerNames]);
|
|
1990
2096
|
|
|
1991
2097
|
if (skillsEnabled) {
|
|
@@ -2009,6 +2115,27 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
2009
2115
|
);
|
|
2010
2116
|
});
|
|
2011
2117
|
|
|
2118
|
+
// Router fallbacks are invisible to discovery, so protocol and model policy were chosen for the
|
|
2119
|
+
// requested route even when another group answered. Name the route only; header text is proxy-supplied.
|
|
2120
|
+
const warnedFallbackRoutes = new Set<string>();
|
|
2121
|
+
pi.on("after_provider_response", (event, ctx) => {
|
|
2122
|
+
const model = ctx.model;
|
|
2123
|
+
if (!model?.provider || !providerNames.has(model.provider)) return;
|
|
2124
|
+
if (event.status < 200 || event.status >= 300) return;
|
|
2125
|
+
const attemptedFallbacks = Number(event.headers["x-litellm-attempted-fallbacks"]);
|
|
2126
|
+
if (!Number.isSafeInteger(attemptedFallbacks) || attemptedFallbacks <= 0) return;
|
|
2127
|
+
const key = `${model.provider}\0${model.id}`;
|
|
2128
|
+
if (warnedFallbackRoutes.has(key)) return;
|
|
2129
|
+
warnedFallbackRoutes.add(key);
|
|
2130
|
+
const route = JSON.stringify(model.id);
|
|
2131
|
+
const message =
|
|
2132
|
+
`LiteLLM (${JSON.stringify(model.provider)}): a fallback served ${route}, but protocol and model handling were chosen ` +
|
|
2133
|
+
`for ${route}'s own deployments. If its fallbacks cross model families, pin the protocol with ` +
|
|
2134
|
+
"`model_info.supported_endpoints`.";
|
|
2135
|
+
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
2136
|
+
else process.stderr.write(`${message}\n`);
|
|
2137
|
+
});
|
|
2138
|
+
|
|
2012
2139
|
// Skills enrichment is best-effort: an expired credential or an unreachable proxy must not
|
|
2013
2140
|
// report an extension error on every turn. The failing model request states the real problem.
|
|
2014
2141
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
@@ -2031,6 +2158,40 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
2031
2158
|
return { systemPrompt: `${event.systemPrompt}\n\n${section}` };
|
|
2032
2159
|
});
|
|
2033
2160
|
|
|
2161
|
+
// MCP tools otherwise register only from refreshModels' `finally`, which needs
|
|
2162
|
+
// context.allowNetwork. Pi core sets that solely for the interactive TUI and the RPC
|
|
2163
|
+
// background refresh, so `-p` and `--list-models` register no MCP tool at all, even when
|
|
2164
|
+
// credentials resolve and the proxy is reachable — the defect #136 fixed for models.
|
|
2165
|
+
//
|
|
2166
|
+
// Seeding here rather than at activation is deliberate: registerMcpTools serialises callers
|
|
2167
|
+
// per provider, so an activation-time call would become an in-flight registration
|
|
2168
|
+
// that Pi's own refresh then waits on. Running once before the first turn keeps that ordering
|
|
2169
|
+
// intact, still precedes any tool use, and leaves startup latency untouched.
|
|
2170
|
+
async function seedMcpTools(definition: ProviderDefinition): Promise<void> {
|
|
2171
|
+
try {
|
|
2172
|
+
const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
|
|
2173
|
+
// executeHelpers:false — seeding must never run the user's key helper as a side effect.
|
|
2174
|
+
const auth = await authForCredential(definition, stored, false);
|
|
2175
|
+
// An env-configured provider has no stored credential and must still seed. The credential
|
|
2176
|
+
// only derives the pause identity, so the resolved key reproduces the stored api_key scope.
|
|
2177
|
+
const credential: Credential = stored ?? { type: "api_key", key: auth.apiKey };
|
|
2178
|
+
await registerMcpTools(definition, auth, credential, AbortSignal.timeout(getSeedTimeoutMs()));
|
|
2179
|
+
} catch (error) {
|
|
2180
|
+
if (isVerboseDiscovery()) {
|
|
2181
|
+
process.stderr.write(
|
|
2182
|
+
`LiteLLM (${definition.name}): MCP seeding skipped (${error instanceof Error ? error.message : String(error)}).\n`,
|
|
2183
|
+
);
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
pi.on("before_agent_start", async () => {
|
|
2189
|
+
if (!mcpEnabled || mcpSeeded || discoveryDisabledReason() || isHostOffline()) return;
|
|
2190
|
+
mcpSeeded = true;
|
|
2191
|
+
// Catalogs are independent per provider, so every configured provider seeds in parallel.
|
|
2192
|
+
await Promise.all(definitions.map(seedMcpTools));
|
|
2193
|
+
});
|
|
2194
|
+
|
|
2034
2195
|
pi.on("message_end", (event, ctx) => {
|
|
2035
2196
|
if (event.message.role !== "assistant") return;
|
|
2036
2197
|
// Resolve the discovered model that produced this message so the display
|
package/src/mcp-tools.ts
CHANGED
|
@@ -27,7 +27,9 @@ const TRUNCATION_MARKER = "\n[truncated by pi-provider-litellm]";
|
|
|
27
27
|
const DESCRIPTION_TRUNCATION_MARKER = "… [truncated]";
|
|
28
28
|
const SHORT_TRUNCATION_MARKER = "…";
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
// `incidentScope` separates the suppression state of catalogs from different providers, so one
|
|
31
|
+
// provider's clean pass cannot clear, and its differing report cannot re-trigger, another's incident.
|
|
32
|
+
type DiagnosticSink = ((message: string) => void) & { readonly incidentScope?: string };
|
|
31
33
|
|
|
32
34
|
interface RawLiteLLMMcpTool {
|
|
33
35
|
name?: unknown;
|
|
@@ -130,13 +132,16 @@ function emitSafetyDiagnostic(
|
|
|
130
132
|
process.stderr.write(line);
|
|
131
133
|
},
|
|
132
134
|
): void {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
const scope = onDiagnostic.incidentScope;
|
|
136
|
+
const key = scope === undefined ? incident : `${scope}\0${incident}`;
|
|
137
|
+
if (lastEmittedIncident.get(key) === identity) return;
|
|
138
|
+
lastEmittedIncident.set(key, identity);
|
|
139
|
+
onDiagnostic(`LiteLLM MCP${scope === undefined ? "" : ` (${JSON.stringify(scope)})`}: ${message}\n`);
|
|
136
140
|
}
|
|
137
141
|
|
|
138
|
-
function clearIncident(incident: string): void {
|
|
139
|
-
|
|
142
|
+
function clearIncident(incident: string, onDiagnostic?: DiagnosticSink): void {
|
|
143
|
+
const scope = onDiagnostic?.incidentScope;
|
|
144
|
+
lastEmittedIncident.delete(scope === undefined ? incident : `${scope}\0${incident}`);
|
|
140
145
|
}
|
|
141
146
|
|
|
142
147
|
function membershipIdentity(tools: readonly string[]): string {
|
|
@@ -184,8 +189,8 @@ export function reportMcpRegistrationFatal(
|
|
|
184
189
|
|
|
185
190
|
// A successful pass clears any fatal incident left by an earlier extension instance, so the same
|
|
186
191
|
// failure is reported if it later recurs.
|
|
187
|
-
export function reportMcpRegistrationSuccess(): void {
|
|
188
|
-
clearIncident("registration-fatal");
|
|
192
|
+
export function reportMcpRegistrationSuccess(onDiagnostic?: DiagnosticSink): void {
|
|
193
|
+
clearIncident("registration-fatal", onDiagnostic);
|
|
189
194
|
}
|
|
190
195
|
|
|
191
196
|
// `registered` is the generated names of the tools that survived, so a failure that changes
|
|
@@ -196,7 +201,7 @@ export function reportMcpPartialDiscovery(
|
|
|
196
201
|
onDiagnostic?: DiagnosticSink,
|
|
197
202
|
): void {
|
|
198
203
|
if (!partialFailure) {
|
|
199
|
-
clearIncident("discovery-partial-failure");
|
|
204
|
+
clearIncident("discovery-partial-failure", onDiagnostic);
|
|
200
205
|
return;
|
|
201
206
|
}
|
|
202
207
|
emitSafetyDiagnostic(
|
|
@@ -212,7 +217,7 @@ export function reportMcpPartialDiscovery(
|
|
|
212
217
|
// an unchanged message.
|
|
213
218
|
export function reportMcpCatalogOutcome(raw: number, registered: number, onDiagnostic?: DiagnosticSink): void {
|
|
214
219
|
if (registered > 0) {
|
|
215
|
-
clearIncident("empty-catalog");
|
|
220
|
+
clearIncident("empty-catalog", onDiagnostic);
|
|
216
221
|
return;
|
|
217
222
|
}
|
|
218
223
|
emitSafetyDiagnostic(
|
|
@@ -281,7 +286,7 @@ async function readBoundedText(
|
|
|
281
286
|
onDiagnostic?: DiagnosticSink,
|
|
282
287
|
): Promise<string> {
|
|
283
288
|
if (!response.body) {
|
|
284
|
-
clearIncident(`${surface}-body-cap
|
|
289
|
+
clearIncident(`${surface}-body-cap`, onDiagnostic);
|
|
285
290
|
return "";
|
|
286
291
|
}
|
|
287
292
|
const reader = response.body.getReader();
|
|
@@ -312,7 +317,7 @@ async function readBoundedText(
|
|
|
312
317
|
}
|
|
313
318
|
// A response that stayed within the cap clears the incident, so a later breach on this surface
|
|
314
319
|
// is reported again instead of being suppressed as an unchanged message.
|
|
315
|
-
clearIncident(`${surface}-body-cap
|
|
320
|
+
clearIncident(`${surface}-body-cap`, onDiagnostic);
|
|
316
321
|
return Buffer.concat(
|
|
317
322
|
chunks.map((chunk) => Buffer.from(chunk)),
|
|
318
323
|
size,
|
|
@@ -743,9 +748,13 @@ function toolIdentity(tool: LiteLLMMcpTool): string {
|
|
|
743
748
|
// A conditional hash would make a survivor's name depend on which *other* tools happened to be in
|
|
744
749
|
// the same catalog, so adding or removing a sibling would rename it — and because Pi cannot
|
|
745
750
|
// unregister, the old name would linger and the model would see one tool twice.
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
751
|
+
// An alias provider's catalog is namespaced by the provider name, in both the prefix and the hash,
|
|
752
|
+
// so two providers exposing the same server never replace each other's tools.
|
|
753
|
+
function buildPiToolName(tool: LiteLLMMcpTool, namespace?: string): string {
|
|
754
|
+
const identity = namespace === undefined ? toolIdentity(tool) : JSON.stringify([namespace, toolIdentity(tool)]);
|
|
755
|
+
const hash = createHash("sha256").update(identity).digest("hex").slice(0, TOOL_NAME_HASH_LENGTH);
|
|
756
|
+
const prefix = namespace === undefined ? "mcp" : `mcp_${sanitizeName(namespace)}`;
|
|
757
|
+
const base = `${prefix}_${sanitizeName(tool.server_name)}_${sanitizeName(tool.name)}`;
|
|
749
758
|
return `${base.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;
|
|
750
759
|
}
|
|
751
760
|
|
|
@@ -1135,7 +1144,10 @@ function buildParameters(tool: LiteLLMMcpTool): BuiltParameters | McpDropReason
|
|
|
1135
1144
|
return { parameters: Type.Unsafe(inputSchema), syntheticArgsEnvelope: false };
|
|
1136
1145
|
}
|
|
1137
1146
|
|
|
1138
|
-
export function prepareTools(
|
|
1147
|
+
export function prepareTools(
|
|
1148
|
+
discovery: McpDiscovery,
|
|
1149
|
+
namespace?: string,
|
|
1150
|
+
): {
|
|
1139
1151
|
prepared: PreparedTool[];
|
|
1140
1152
|
report: McpPreparationReport;
|
|
1141
1153
|
} {
|
|
@@ -1160,12 +1172,12 @@ export function prepareTools(discovery: McpDiscovery): {
|
|
|
1160
1172
|
for (const tool of discovery.tools) {
|
|
1161
1173
|
const built = buildParameters(tool);
|
|
1162
1174
|
if (typeof built === "string") {
|
|
1163
|
-
recordDrop(built, buildPiToolName(tool));
|
|
1175
|
+
recordDrop(built, buildPiToolName(tool, namespace));
|
|
1164
1176
|
continue;
|
|
1165
1177
|
}
|
|
1166
1178
|
const identity = toolIdentity(tool);
|
|
1167
1179
|
if (acceptedIdentities.has(identity)) {
|
|
1168
|
-
recordDrop("duplicate-identity", buildPiToolName(tool));
|
|
1180
|
+
recordDrop("duplicate-identity", buildPiToolName(tool, namespace));
|
|
1169
1181
|
continue;
|
|
1170
1182
|
}
|
|
1171
1183
|
acceptedIdentities.add(identity);
|
|
@@ -1178,12 +1190,12 @@ export function prepareTools(discovery: McpDiscovery): {
|
|
|
1178
1190
|
}
|
|
1179
1191
|
|
|
1180
1192
|
const candidates = accepted.slice(0, MAX_REGISTERED_TOOLS);
|
|
1181
|
-
const overflowTools = accepted.slice(MAX_REGISTERED_TOOLS).map(({ tool }) => buildPiToolName(tool));
|
|
1193
|
+
const overflowTools = accepted.slice(MAX_REGISTERED_TOOLS).map(({ tool }) => buildPiToolName(tool, namespace));
|
|
1182
1194
|
const finalNames = new Set<string>();
|
|
1183
1195
|
const prepared: PreparedTool[] = [];
|
|
1184
1196
|
for (const candidate of candidates) {
|
|
1185
1197
|
// Membership-independent: the name depends only on this tool's identity.
|
|
1186
|
-
const name = buildPiToolName(candidate.tool);
|
|
1198
|
+
const name = buildPiToolName(candidate.tool, namespace);
|
|
1187
1199
|
if (finalNames.has(name)) {
|
|
1188
1200
|
recordDrop("name-collision", name);
|
|
1189
1201
|
continue;
|
|
@@ -1251,7 +1263,7 @@ function emitPreparationDiagnostics(report: McpPreparationReport, onDiagnostic?:
|
|
|
1251
1263
|
// Clear classes that did not recur, so the same incident is reported again if it comes back
|
|
1252
1264
|
// after a clean refresh instead of being suppressed as an unchanged identity.
|
|
1253
1265
|
for (const reason of [...Object.keys(INCIDENT_REASON_TEXT), "tool-cap"]) {
|
|
1254
|
-
if (!seen.has(reason)) clearIncident(reason);
|
|
1266
|
+
if (!seen.has(reason)) clearIncident(reason, onDiagnostic);
|
|
1255
1267
|
}
|
|
1256
1268
|
}
|
|
1257
1269
|
|
|
@@ -1260,6 +1272,7 @@ export async function createMcpToolDefinitions(
|
|
|
1260
1272
|
onProgress?: (message: string) => void,
|
|
1261
1273
|
signal?: AbortSignal,
|
|
1262
1274
|
onDiagnostic?: DiagnosticSink,
|
|
1275
|
+
namespace?: string,
|
|
1263
1276
|
): Promise<{ definitions: ToolDefinition[]; report: McpPreparationReport }> {
|
|
1264
1277
|
const discoveryAuth = await getAuth();
|
|
1265
1278
|
const discovery = await discoverMcpTools(
|
|
@@ -1271,7 +1284,7 @@ export async function createMcpToolDefinitions(
|
|
|
1271
1284
|
discoveryAuth.allowInsecureHttp,
|
|
1272
1285
|
onDiagnostic,
|
|
1273
1286
|
);
|
|
1274
|
-
const { prepared, report } = prepareTools(discovery);
|
|
1287
|
+
const { prepared, report } = prepareTools(discovery, namespace);
|
|
1275
1288
|
emitPreparationDiagnostics(report, onDiagnostic);
|
|
1276
1289
|
const lostTotal = report.dropped.reduce((total, entry) => total + entry.tools.length, 0) + report.overflow;
|
|
1277
1290
|
const lostDetail = [
|