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/mcp-tools.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
2
|
-
import type { Static, TSchema } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { JsonValue, Static, TSchema } from "@earendil-works/pi-ai";
|
|
3
3
|
import { Type } from "@earendil-works/pi-ai";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
defineTool,
|
|
6
|
+
type ExtensionContext,
|
|
7
|
+
type ToolDefinition,
|
|
8
|
+
type ToolExposure,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
10
|
import { normalizeBaseUrl } from "./discover.js";
|
|
6
|
-
import type { LiteLLMMcpTool, LiteLLMRuntimeAuth } from "./types.js";
|
|
11
|
+
import type { LiteLLMMcpTool, LiteLLMRuntimeAuth, McpToolAnnotations } from "./types.js";
|
|
7
12
|
|
|
8
13
|
const LIST_TIMEOUT_MS = 10_000;
|
|
9
14
|
const CALL_TIMEOUT_MS = 30_000;
|
|
@@ -15,6 +20,8 @@ const MAX_DESCRIPTION_BYTES = 4 * 1024;
|
|
|
15
20
|
const MAX_SCHEMA_BYTES = 64 * 1024;
|
|
16
21
|
const MAX_SCHEMA_DEPTH = 16;
|
|
17
22
|
const MAX_RESULT_BYTES = 64 * 1024;
|
|
23
|
+
// Pi 0.99 caps its own structured bash results at the same size.
|
|
24
|
+
const MAX_STRUCTURED_RESULT_BYTES = 1024 * 1024;
|
|
18
25
|
const MAX_TOOL_NAME_LENGTH = 64;
|
|
19
26
|
const TOOL_NAME_HASH_LENGTH = 10;
|
|
20
27
|
const MAX_LABEL_BYTES = 256;
|
|
@@ -27,26 +34,119 @@ const TRUNCATION_MARKER = "\n[truncated by pi-provider-litellm]";
|
|
|
27
34
|
const DESCRIPTION_TRUNCATION_MARKER = "… [truncated]";
|
|
28
35
|
const SHORT_TRUNCATION_MARKER = "…";
|
|
29
36
|
|
|
30
|
-
|
|
37
|
+
// `incidentScope` separates the suppression state of catalogs from different providers, so one
|
|
38
|
+
// provider's clean pass cannot clear, and its differing report cannot re-trigger, another's incident.
|
|
39
|
+
type DiagnosticSink = ((message: string) => void) & { readonly incidentScope?: string };
|
|
31
40
|
|
|
32
41
|
interface RawLiteLLMMcpTool {
|
|
33
42
|
name?: unknown;
|
|
34
43
|
description?: unknown;
|
|
35
44
|
inputSchema?: unknown;
|
|
36
45
|
input_schema?: unknown;
|
|
46
|
+
annotations?: unknown;
|
|
37
47
|
server_id?: unknown;
|
|
38
48
|
server_name?: unknown;
|
|
39
49
|
mcp_info?: {
|
|
40
50
|
server_id?: unknown;
|
|
41
51
|
server_name?: unknown;
|
|
52
|
+
description?: unknown;
|
|
42
53
|
};
|
|
43
54
|
}
|
|
44
55
|
|
|
56
|
+
// The exposure values of a Pi `mcp.json` server entry, so `litellm.mcp.exposure` and
|
|
57
|
+
// `litellm.mcp.toolExposure` mean what Pi's own MCP configuration means.
|
|
58
|
+
export type McpExposure = "direct" | "codemode" | "codemode-deferred" | "deferred" | "hidden";
|
|
59
|
+
const MCP_EXPOSURES: readonly McpExposure[] = ["direct", "codemode", "codemode-deferred", "deferred", "hidden"];
|
|
60
|
+
|
|
61
|
+
export interface McpExposurePolicy {
|
|
62
|
+
// Exposure of every tool without a `toolExposure` match.
|
|
63
|
+
exposure: McpExposure;
|
|
64
|
+
// `[key, exposure]` in settings order. A key without `/` matches the tool name as the server offers
|
|
65
|
+
// it; a key with `/` matches `<server>/<tool>`. `*` matches any characters.
|
|
66
|
+
toolExposure: ReadonlyArray<readonly [string, McpExposure]>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const DEFAULT_MCP_EXPOSURE_POLICY: McpExposurePolicy = { exposure: "direct", toolExposure: [] };
|
|
70
|
+
|
|
71
|
+
function isMcpExposure(value: unknown): value is McpExposure {
|
|
72
|
+
return typeof value === "string" && (MCP_EXPOSURES as readonly string[]).includes(value);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Reads `exposure` and `toolExposure` from the `litellm.mcp` settings block. Unusable values fall back
|
|
76
|
+
// to `direct`, today's behavior, and are reported in extension-owned text.
|
|
77
|
+
export function parseMcpExposurePolicy(raw: unknown): { policy: McpExposurePolicy; warnings: string[] } {
|
|
78
|
+
const settings = asRecord(raw);
|
|
79
|
+
const warnings: string[] = [];
|
|
80
|
+
const expected = MCP_EXPOSURES.map((value) => JSON.stringify(value)).join(", ");
|
|
81
|
+
let exposure: McpExposure = "direct";
|
|
82
|
+
if (settings?.exposure !== undefined) {
|
|
83
|
+
if (isMcpExposure(settings.exposure)) exposure = settings.exposure;
|
|
84
|
+
else warnings.push(`litellm.mcp.exposure must be one of ${expected}; using "direct".`);
|
|
85
|
+
}
|
|
86
|
+
const toolExposure: Array<readonly [string, McpExposure]> = [];
|
|
87
|
+
if (settings?.toolExposure !== undefined) {
|
|
88
|
+
const overrides = asRecord(settings.toolExposure);
|
|
89
|
+
if (!overrides)
|
|
90
|
+
warnings.push("litellm.mcp.toolExposure must be an object mapping tool name patterns to exposures; ignoring it.");
|
|
91
|
+
else
|
|
92
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
93
|
+
if (isMcpExposure(value)) toolExposure.push([key, value]);
|
|
94
|
+
else warnings.push(`litellm.mcp.toolExposure[${JSON.stringify(key)}] must be one of ${expected}; ignoring it.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { policy: { exposure, toolExposure }, warnings };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Glob match where `*` matches any characters; segments must appear in order without overlapping.
|
|
101
|
+
function wildcardMatches(pattern: string, subject: string): boolean {
|
|
102
|
+
const segments = pattern.split("*");
|
|
103
|
+
if (segments.length === 1) return pattern === subject;
|
|
104
|
+
const first = segments[0] ?? "";
|
|
105
|
+
const last = segments[segments.length - 1] ?? "";
|
|
106
|
+
if (!subject.startsWith(first) || !subject.endsWith(last)) return false;
|
|
107
|
+
const end = subject.length - last.length;
|
|
108
|
+
let position = first.length;
|
|
109
|
+
for (const segment of segments.slice(1, -1)) {
|
|
110
|
+
const index = subject.indexOf(segment, position);
|
|
111
|
+
if (index === -1 || index + segment.length > end) return false;
|
|
112
|
+
position = index + segment.length;
|
|
113
|
+
}
|
|
114
|
+
return position <= end;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// As in Pi's mcp.json: an exact key wins over patterns, and among patterns the first in settings
|
|
118
|
+
// order wins. A `<server>/<tool>` exact key is more specific than a bare tool name.
|
|
119
|
+
export function resolveMcpExposure(policy: McpExposurePolicy, serverName: string, toolName: string): McpExposure {
|
|
120
|
+
const qualified = `${serverName}/${toolName}`;
|
|
121
|
+
let qualifiedExact: McpExposure | undefined;
|
|
122
|
+
let bareExact: McpExposure | undefined;
|
|
123
|
+
let firstPattern: McpExposure | undefined;
|
|
124
|
+
for (const [key, exposure] of policy.toolExposure) {
|
|
125
|
+
const isQualified = key.includes("/");
|
|
126
|
+
const subject = isQualified ? qualified : toolName;
|
|
127
|
+
if (!key.includes("*")) {
|
|
128
|
+
if (key !== subject) continue;
|
|
129
|
+
if (isQualified) qualifiedExact ??= exposure;
|
|
130
|
+
else bareExact ??= exposure;
|
|
131
|
+
} else if (firstPattern === undefined && wildcardMatches(key, subject)) {
|
|
132
|
+
firstPattern = exposure;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return qualifiedExact ?? bareExact ?? firstPattern ?? policy.exposure;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Pi's tool API has one `deferred`; the two deferred MCP exposures differ only in which discovery
|
|
139
|
+
// tool (codemode or tool_search) is activated to reach them.
|
|
140
|
+
export function toToolExposure(exposure: McpExposure): ToolExposure {
|
|
141
|
+
return exposure === "codemode-deferred" ? "deferred" : exposure;
|
|
142
|
+
}
|
|
143
|
+
|
|
45
144
|
interface PreparedTool {
|
|
46
145
|
tool: LiteLLMMcpTool;
|
|
47
146
|
name: string;
|
|
48
147
|
parameters: TSchema;
|
|
49
148
|
syntheticArgsEnvelope: boolean;
|
|
149
|
+
exposure: McpExposure;
|
|
50
150
|
}
|
|
51
151
|
|
|
52
152
|
interface InvalidMcpTool {
|
|
@@ -102,6 +202,8 @@ export interface McpPreparationReport {
|
|
|
102
202
|
// Non-printed identities paired with `dropped`, used to detect membership changes safely.
|
|
103
203
|
dropMemberships: Array<{ reason: McpDropReason; identities: string[] }>;
|
|
104
204
|
degraded: Array<{ reason: McpDegradeReason; tools: string[] }>;
|
|
205
|
+
// Distinct exposures of the prepared tools, so the caller can activate codemode or tool_search.
|
|
206
|
+
exposures: McpExposure[];
|
|
105
207
|
}
|
|
106
208
|
|
|
107
209
|
// Bounded discovery result: `raw` is what the proxy returned, so losses can be reconciled.
|
|
@@ -130,13 +232,16 @@ function emitSafetyDiagnostic(
|
|
|
130
232
|
process.stderr.write(line);
|
|
131
233
|
},
|
|
132
234
|
): void {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
235
|
+
const scope = onDiagnostic.incidentScope;
|
|
236
|
+
const key = scope === undefined ? incident : `${scope}\0${incident}`;
|
|
237
|
+
if (lastEmittedIncident.get(key) === identity) return;
|
|
238
|
+
lastEmittedIncident.set(key, identity);
|
|
239
|
+
onDiagnostic(`LiteLLM MCP${scope === undefined ? "" : ` (${JSON.stringify(scope)})`}: ${message}\n`);
|
|
136
240
|
}
|
|
137
241
|
|
|
138
|
-
function clearIncident(incident: string): void {
|
|
139
|
-
|
|
242
|
+
function clearIncident(incident: string, onDiagnostic?: DiagnosticSink): void {
|
|
243
|
+
const scope = onDiagnostic?.incidentScope;
|
|
244
|
+
lastEmittedIncident.delete(scope === undefined ? incident : `${scope}\0${incident}`);
|
|
140
245
|
}
|
|
141
246
|
|
|
142
247
|
function membershipIdentity(tools: readonly string[]): string {
|
|
@@ -184,8 +289,8 @@ export function reportMcpRegistrationFatal(
|
|
|
184
289
|
|
|
185
290
|
// A successful pass clears any fatal incident left by an earlier extension instance, so the same
|
|
186
291
|
// failure is reported if it later recurs.
|
|
187
|
-
export function reportMcpRegistrationSuccess(): void {
|
|
188
|
-
clearIncident("registration-fatal");
|
|
292
|
+
export function reportMcpRegistrationSuccess(onDiagnostic?: DiagnosticSink): void {
|
|
293
|
+
clearIncident("registration-fatal", onDiagnostic);
|
|
189
294
|
}
|
|
190
295
|
|
|
191
296
|
// `registered` is the generated names of the tools that survived, so a failure that changes
|
|
@@ -196,7 +301,7 @@ export function reportMcpPartialDiscovery(
|
|
|
196
301
|
onDiagnostic?: DiagnosticSink,
|
|
197
302
|
): void {
|
|
198
303
|
if (!partialFailure) {
|
|
199
|
-
clearIncident("discovery-partial-failure");
|
|
304
|
+
clearIncident("discovery-partial-failure", onDiagnostic);
|
|
200
305
|
return;
|
|
201
306
|
}
|
|
202
307
|
emitSafetyDiagnostic(
|
|
@@ -212,7 +317,7 @@ export function reportMcpPartialDiscovery(
|
|
|
212
317
|
// an unchanged message.
|
|
213
318
|
export function reportMcpCatalogOutcome(raw: number, registered: number, onDiagnostic?: DiagnosticSink): void {
|
|
214
319
|
if (registered > 0) {
|
|
215
|
-
clearIncident("empty-catalog");
|
|
320
|
+
clearIncident("empty-catalog", onDiagnostic);
|
|
216
321
|
return;
|
|
217
322
|
}
|
|
218
323
|
emitSafetyDiagnostic(
|
|
@@ -281,7 +386,7 @@ async function readBoundedText(
|
|
|
281
386
|
onDiagnostic?: DiagnosticSink,
|
|
282
387
|
): Promise<string> {
|
|
283
388
|
if (!response.body) {
|
|
284
|
-
clearIncident(`${surface}-body-cap
|
|
389
|
+
clearIncident(`${surface}-body-cap`, onDiagnostic);
|
|
285
390
|
return "";
|
|
286
391
|
}
|
|
287
392
|
const reader = response.body.getReader();
|
|
@@ -312,7 +417,7 @@ async function readBoundedText(
|
|
|
312
417
|
}
|
|
313
418
|
// A response that stayed within the cap clears the incident, so a later breach on this surface
|
|
314
419
|
// is reported again instead of being suppressed as an unchanged message.
|
|
315
|
-
clearIncident(`${surface}-body-cap
|
|
420
|
+
clearIncident(`${surface}-body-cap`, onDiagnostic);
|
|
316
421
|
return Buffer.concat(
|
|
317
422
|
chunks.map((chunk) => Buffer.from(chunk)),
|
|
318
423
|
size,
|
|
@@ -455,6 +560,21 @@ function parseDiscoveryJson(text: string): unknown {
|
|
|
455
560
|
return parseJson(text, "MCP discovery");
|
|
456
561
|
}
|
|
457
562
|
|
|
563
|
+
const ANNOTATION_HINTS = ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] as const;
|
|
564
|
+
|
|
565
|
+
// Only the boolean hints, as Pi's own MCP client keeps them; other proxy-supplied annotation fields
|
|
566
|
+
// (such as `title`) never reach Pi.
|
|
567
|
+
function toolAnnotations(value: unknown): McpToolAnnotations | undefined {
|
|
568
|
+
const raw = asRecord(value);
|
|
569
|
+
if (!raw) return undefined;
|
|
570
|
+
const hints: McpToolAnnotations = {};
|
|
571
|
+
for (const hint of ANNOTATION_HINTS) {
|
|
572
|
+
const flag = raw[hint];
|
|
573
|
+
if (typeof flag === "boolean") hints[hint] = flag;
|
|
574
|
+
}
|
|
575
|
+
return Object.keys(hints).length > 0 ? hints : undefined;
|
|
576
|
+
}
|
|
577
|
+
|
|
458
578
|
function normalizeMcpTool(value: unknown): LiteLLMMcpTool | undefined {
|
|
459
579
|
const raw = asRecord(value) as RawLiteLLMMcpTool | undefined;
|
|
460
580
|
if (!raw) return undefined;
|
|
@@ -469,6 +589,8 @@ function normalizeMcpTool(value: unknown): LiteLLMMcpTool | undefined {
|
|
|
469
589
|
const hasSnakeInputSchema = Object.hasOwn(raw, "input_schema");
|
|
470
590
|
const suppliedSchema = hasInputSchema ? raw.inputSchema : hasSnakeInputSchema ? raw.input_schema : undefined;
|
|
471
591
|
const inputSchema = asRecord(suppliedSchema);
|
|
592
|
+
const annotations = toolAnnotations(raw.annotations);
|
|
593
|
+
const serverDescription = stringValue(raw.mcp_info?.description);
|
|
472
594
|
return {
|
|
473
595
|
name,
|
|
474
596
|
server_name: serverName,
|
|
@@ -477,6 +599,8 @@ function normalizeMcpTool(value: unknown): LiteLLMMcpTool | undefined {
|
|
|
477
599
|
input_schema: inputSchema ?? {},
|
|
478
600
|
// Present but not an object: malformed, as distinct from absent.
|
|
479
601
|
...(inputSchema === undefined && (hasInputSchema || hasSnakeInputSchema) ? { input_schema_malformed: true } : {}),
|
|
602
|
+
...(annotations ? { annotations } : {}),
|
|
603
|
+
...(serverDescription ? { server_description: serverDescription } : {}),
|
|
480
604
|
};
|
|
481
605
|
}
|
|
482
606
|
|
|
@@ -677,6 +801,34 @@ function mcpCallError(serverId: string, toolName: string, detail: string): Error
|
|
|
677
801
|
);
|
|
678
802
|
}
|
|
679
803
|
|
|
804
|
+
export interface McpToolOutcome {
|
|
805
|
+
text: string;
|
|
806
|
+
structuredContent: Record<string, unknown>;
|
|
807
|
+
isError: boolean;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Same shape as Pi's own MCP `createMcpResultSchema()`, which codemode detects to render
|
|
811
|
+
// `CallToolResult`. Extension-owned: a proxy-supplied `outputSchema` is never forwarded.
|
|
812
|
+
const MCP_RESULT_SCHEMA = {
|
|
813
|
+
type: "object",
|
|
814
|
+
properties: {
|
|
815
|
+
content: { type: "array", items: { type: "object" } },
|
|
816
|
+
isError: { type: "boolean" },
|
|
817
|
+
_meta: { type: "object" },
|
|
818
|
+
},
|
|
819
|
+
required: ["content"],
|
|
820
|
+
} as unknown as TSchema;
|
|
821
|
+
|
|
822
|
+
function structuredResult(result: unknown, text: string, isError: boolean): Record<string, unknown> {
|
|
823
|
+
const record = asRecord(result);
|
|
824
|
+
if (record && Array.isArray(record.content)) {
|
|
825
|
+
const { _meta: _ignored, ...rest } = record;
|
|
826
|
+
const structured = isError ? { ...rest, isError: true } : rest;
|
|
827
|
+
if (Buffer.byteLength(JSON.stringify(structured)) <= MAX_STRUCTURED_RESULT_BYTES) return structured;
|
|
828
|
+
}
|
|
829
|
+
return { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) };
|
|
830
|
+
}
|
|
831
|
+
|
|
680
832
|
export async function executeMcpTool(
|
|
681
833
|
baseUrl: string,
|
|
682
834
|
apiKey: string,
|
|
@@ -687,7 +839,7 @@ export async function executeMcpTool(
|
|
|
687
839
|
parentSignal?: AbortSignal,
|
|
688
840
|
allowInsecureHttp = false,
|
|
689
841
|
onDiagnostic?: DiagnosticSink,
|
|
690
|
-
): Promise<
|
|
842
|
+
): Promise<McpToolOutcome> {
|
|
691
843
|
parentSignal?.throwIfAborted();
|
|
692
844
|
const signal = boundedSignal(CALL_TIMEOUT_MS, parentSignal);
|
|
693
845
|
let response: Response;
|
|
@@ -716,9 +868,11 @@ export async function executeMcpTool(
|
|
|
716
868
|
if (resultRecord?.isError === true || bodyRecord?.isError === true) {
|
|
717
869
|
const message =
|
|
718
870
|
mcpErrorMessage(resultRecord?.content ?? bodyRecord?.content ?? result) ?? serializeResult(result);
|
|
719
|
-
|
|
871
|
+
const text = mcpCallError(serverId, toolName, message).message;
|
|
872
|
+
return { text, structuredContent: structuredResult(result, text, true), isError: true };
|
|
720
873
|
}
|
|
721
|
-
|
|
874
|
+
const text = serializeResult(result);
|
|
875
|
+
return { text, structuredContent: structuredResult(result, text, false), isError: false };
|
|
722
876
|
} catch (error) {
|
|
723
877
|
if (parentSignal?.aborted) throw parentSignal.reason;
|
|
724
878
|
throw error;
|
|
@@ -743,9 +897,23 @@ function toolIdentity(tool: LiteLLMMcpTool): string {
|
|
|
743
897
|
// A conditional hash would make a survivor's name depend on which *other* tools happened to be in
|
|
744
898
|
// the same catalog, so adding or removing a sibling would rename it — and because Pi cannot
|
|
745
899
|
// unregister, the old name would linger and the model would see one tool twice.
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
900
|
+
// An alias provider's catalog is namespaced by the provider name, in both the prefix and the hash,
|
|
901
|
+
// so two providers exposing the same server never replace each other's tools.
|
|
902
|
+
function buildPiToolName(tool: LiteLLMMcpTool, namespace?: string): string {
|
|
903
|
+
const identity = namespace === undefined ? toolIdentity(tool) : JSON.stringify([namespace, toolIdentity(tool)]);
|
|
904
|
+
const hash = createHash("sha256").update(identity).digest("hex").slice(0, TOOL_NAME_HASH_LENGTH);
|
|
905
|
+
const prefix = namespace === undefined ? "mcp" : `mcp_${sanitizeName(namespace)}`;
|
|
906
|
+
const base = `${prefix}_${sanitizeName(tool.server_name)}_${sanitizeName(tool.name)}`;
|
|
907
|
+
return `${base.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// A namespace groups one server's tools, so its name is a pure function of server identity too:
|
|
911
|
+
// `My-Server` and `My_Server` sanitize alike, and long names truncate alike, so a hash keeps them apart.
|
|
912
|
+
function buildNamespaceName(tool: LiteLLMMcpTool, namespace?: string): string {
|
|
913
|
+
const identity = JSON.stringify([namespace ?? null, tool.server_id ?? tool.server_name, tool.server_name]);
|
|
914
|
+
const hash = createHash("sha256").update(identity).digest("hex").slice(0, 6);
|
|
915
|
+
const prefix = namespace === undefined ? "mcp" : `mcp_${sanitizeName(namespace)}`;
|
|
916
|
+
const base = `${prefix}_${sanitizeName(tool.server_name)}`;
|
|
749
917
|
return `${base.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;
|
|
750
918
|
}
|
|
751
919
|
|
|
@@ -1135,7 +1303,11 @@ function buildParameters(tool: LiteLLMMcpTool): BuiltParameters | McpDropReason
|
|
|
1135
1303
|
return { parameters: Type.Unsafe(inputSchema), syntheticArgsEnvelope: false };
|
|
1136
1304
|
}
|
|
1137
1305
|
|
|
1138
|
-
export function prepareTools(
|
|
1306
|
+
export function prepareTools(
|
|
1307
|
+
discovery: McpDiscovery,
|
|
1308
|
+
namespace?: string,
|
|
1309
|
+
exposurePolicy: McpExposurePolicy = DEFAULT_MCP_EXPOSURE_POLICY,
|
|
1310
|
+
): {
|
|
1139
1311
|
prepared: PreparedTool[];
|
|
1140
1312
|
report: McpPreparationReport;
|
|
1141
1313
|
} {
|
|
@@ -1160,12 +1332,12 @@ export function prepareTools(discovery: McpDiscovery): {
|
|
|
1160
1332
|
for (const tool of discovery.tools) {
|
|
1161
1333
|
const built = buildParameters(tool);
|
|
1162
1334
|
if (typeof built === "string") {
|
|
1163
|
-
recordDrop(built, buildPiToolName(tool));
|
|
1335
|
+
recordDrop(built, buildPiToolName(tool, namespace));
|
|
1164
1336
|
continue;
|
|
1165
1337
|
}
|
|
1166
1338
|
const identity = toolIdentity(tool);
|
|
1167
1339
|
if (acceptedIdentities.has(identity)) {
|
|
1168
|
-
recordDrop("duplicate-identity", buildPiToolName(tool));
|
|
1340
|
+
recordDrop("duplicate-identity", buildPiToolName(tool, namespace));
|
|
1169
1341
|
continue;
|
|
1170
1342
|
}
|
|
1171
1343
|
acceptedIdentities.add(identity);
|
|
@@ -1173,17 +1345,18 @@ export function prepareTools(discovery: McpDiscovery): {
|
|
|
1173
1345
|
tool,
|
|
1174
1346
|
parameters: built.parameters,
|
|
1175
1347
|
syntheticArgsEnvelope: built.syntheticArgsEnvelope,
|
|
1348
|
+
exposure: resolveMcpExposure(exposurePolicy, tool.server_name, tool.name),
|
|
1176
1349
|
degraded: built.degraded,
|
|
1177
1350
|
});
|
|
1178
1351
|
}
|
|
1179
1352
|
|
|
1180
1353
|
const candidates = accepted.slice(0, MAX_REGISTERED_TOOLS);
|
|
1181
|
-
const overflowTools = accepted.slice(MAX_REGISTERED_TOOLS).map(({ tool }) => buildPiToolName(tool));
|
|
1354
|
+
const overflowTools = accepted.slice(MAX_REGISTERED_TOOLS).map(({ tool }) => buildPiToolName(tool, namespace));
|
|
1182
1355
|
const finalNames = new Set<string>();
|
|
1183
1356
|
const prepared: PreparedTool[] = [];
|
|
1184
1357
|
for (const candidate of candidates) {
|
|
1185
1358
|
// Membership-independent: the name depends only on this tool's identity.
|
|
1186
|
-
const name = buildPiToolName(candidate.tool);
|
|
1359
|
+
const name = buildPiToolName(candidate.tool, namespace);
|
|
1187
1360
|
if (finalNames.has(name)) {
|
|
1188
1361
|
recordDrop("name-collision", name);
|
|
1189
1362
|
continue;
|
|
@@ -1210,6 +1383,7 @@ export function prepareTools(discovery: McpDiscovery): {
|
|
|
1210
1383
|
dropped: [...drops.entries()].map(([reason, tools]) => ({ reason, tools })),
|
|
1211
1384
|
dropMemberships: [...dropMemberships.entries()].map(([reason, identities]) => ({ reason, identities })),
|
|
1212
1385
|
degraded: [...degrades.entries()].map(([reason, tools]) => ({ reason, tools })),
|
|
1386
|
+
exposures: [...new Set(prepared.map((tool) => tool.exposure))].sort(),
|
|
1213
1387
|
};
|
|
1214
1388
|
return { prepared, report };
|
|
1215
1389
|
}
|
|
@@ -1251,7 +1425,7 @@ function emitPreparationDiagnostics(report: McpPreparationReport, onDiagnostic?:
|
|
|
1251
1425
|
// Clear classes that did not recur, so the same incident is reported again if it comes back
|
|
1252
1426
|
// after a clean refresh instead of being suppressed as an unchanged identity.
|
|
1253
1427
|
for (const reason of [...Object.keys(INCIDENT_REASON_TEXT), "tool-cap"]) {
|
|
1254
|
-
if (!seen.has(reason)) clearIncident(reason);
|
|
1428
|
+
if (!seen.has(reason)) clearIncident(reason, onDiagnostic);
|
|
1255
1429
|
}
|
|
1256
1430
|
}
|
|
1257
1431
|
|
|
@@ -1260,6 +1434,8 @@ export async function createMcpToolDefinitions(
|
|
|
1260
1434
|
onProgress?: (message: string) => void,
|
|
1261
1435
|
signal?: AbortSignal,
|
|
1262
1436
|
onDiagnostic?: DiagnosticSink,
|
|
1437
|
+
namespace?: string,
|
|
1438
|
+
exposurePolicy: McpExposurePolicy = DEFAULT_MCP_EXPOSURE_POLICY,
|
|
1263
1439
|
): Promise<{ definitions: ToolDefinition[]; report: McpPreparationReport }> {
|
|
1264
1440
|
const discoveryAuth = await getAuth();
|
|
1265
1441
|
const discovery = await discoverMcpTools(
|
|
@@ -1271,7 +1447,7 @@ export async function createMcpToolDefinitions(
|
|
|
1271
1447
|
discoveryAuth.allowInsecureHttp,
|
|
1272
1448
|
onDiagnostic,
|
|
1273
1449
|
);
|
|
1274
|
-
const { prepared, report } = prepareTools(discovery);
|
|
1450
|
+
const { prepared, report } = prepareTools(discovery, namespace, exposurePolicy);
|
|
1275
1451
|
emitPreparationDiagnostics(report, onDiagnostic);
|
|
1276
1452
|
const lostTotal = report.dropped.reduce((total, entry) => total + entry.tools.length, 0) + report.overflow;
|
|
1277
1453
|
const lostDetail = [
|
|
@@ -1285,7 +1461,7 @@ export async function createMcpToolDefinitions(
|
|
|
1285
1461
|
`${lostDetail ? ` (${lostDetail})` : ""}`,
|
|
1286
1462
|
);
|
|
1287
1463
|
|
|
1288
|
-
const definitions = prepared.map(({ tool: mcpTool, name, parameters, syntheticArgsEnvelope }) => {
|
|
1464
|
+
const definitions = prepared.map(({ tool: mcpTool, name, parameters, syntheticArgsEnvelope, exposure }) => {
|
|
1289
1465
|
const description = truncateUtf8(
|
|
1290
1466
|
`${mcpTool.description} (via ${mcpTool.server_name} MCP server)`,
|
|
1291
1467
|
MAX_DESCRIPTION_BYTES,
|
|
@@ -1301,6 +1477,16 @@ export async function createMcpToolDefinitions(
|
|
|
1301
1477
|
// untrusted string that reaches Pi's UI or the model.
|
|
1302
1478
|
const label = truncateUtf8(`${mcpTool.server_name}: ${mcpTool.name}`, MAX_LABEL_BYTES, SHORT_TRUNCATION_MARKER);
|
|
1303
1479
|
const boundedDetail = (value: string): string => truncateUtf8(value, MAX_DETAIL_BYTES, SHORT_TRUNCATION_MARKER);
|
|
1480
|
+
// Pi 0.99 groups tools by namespace, as its own MCP client does per server. The prefix matches the
|
|
1481
|
+
// generated tool names, so a group is recognisable in codemode listings; older Pi ignores it.
|
|
1482
|
+
const toolNamespace = {
|
|
1483
|
+
name: buildNamespaceName(mcpTool, namespace),
|
|
1484
|
+
description: truncateUtf8(
|
|
1485
|
+
mcpTool.server_description ?? `LiteLLM MCP server ${mcpTool.server_name}`,
|
|
1486
|
+
MAX_LABEL_BYTES,
|
|
1487
|
+
SHORT_TRUNCATION_MARKER,
|
|
1488
|
+
),
|
|
1489
|
+
};
|
|
1304
1490
|
|
|
1305
1491
|
return defineTool({
|
|
1306
1492
|
name,
|
|
@@ -1308,13 +1494,17 @@ export async function createMcpToolDefinitions(
|
|
|
1308
1494
|
description,
|
|
1309
1495
|
promptSnippet,
|
|
1310
1496
|
executionMode: "parallel",
|
|
1497
|
+
exposure: toToolExposure(exposure),
|
|
1498
|
+
namespace: toolNamespace,
|
|
1499
|
+
...(mcpTool.annotations ? { annotations: mcpTool.annotations } : {}),
|
|
1311
1500
|
parameters,
|
|
1501
|
+
outputSchema: MCP_RESULT_SCHEMA,
|
|
1312
1502
|
async execute(_toolCallId, params: Static<typeof parameters>, toolSignal, _onUpdate, ctx) {
|
|
1313
1503
|
const auth = await getAuth(ctx);
|
|
1314
1504
|
const rawParams = params as Record<string, unknown>;
|
|
1315
1505
|
const args = syntheticArgsEnvelope ? asRecord(rawParams.args) : rawParams;
|
|
1316
1506
|
if (!args) throw new Error("Synthetic MCP tool arguments must contain an object-valued args property");
|
|
1317
|
-
const text = await executeMcpTool(
|
|
1507
|
+
const { text, structuredContent, isError } = await executeMcpTool(
|
|
1318
1508
|
auth.baseUrl,
|
|
1319
1509
|
auth.apiKey,
|
|
1320
1510
|
mcpTool.server_id ?? mcpTool.server_name,
|
|
@@ -1325,8 +1515,13 @@ export async function createMcpToolDefinitions(
|
|
|
1325
1515
|
auth.allowInsecureHttp,
|
|
1326
1516
|
onDiagnostic,
|
|
1327
1517
|
);
|
|
1518
|
+
// Pi before 0.99 ignores a returned `isError`; `ctx.executeTool` exists only from 0.99.
|
|
1519
|
+
if (isError && typeof (ctx as { executeTool?: unknown }).executeTool !== "function") throw new Error(text);
|
|
1328
1520
|
return {
|
|
1329
1521
|
content: [{ type: "text", text }],
|
|
1522
|
+
// The body came from JSON.parse, so it is a JsonValue; Pi's type cannot see that.
|
|
1523
|
+
structuredContent: structuredContent as JsonValue,
|
|
1524
|
+
...(isError ? { isError: true } : {}),
|
|
1330
1525
|
details: {
|
|
1331
1526
|
server: boundedDetail(mcpTool.server_name),
|
|
1332
1527
|
serverId: boundedDetail(mcpTool.server_id ?? mcpTool.server_name),
|
package/src/model-groups.ts
CHANGED
|
@@ -1,8 +1,34 @@
|
|
|
1
1
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import { routesOnlyThrough } from "./backend-identity.js";
|
|
2
3
|
import { intersectThinkingLevelMaps, THINKING_LEVEL_DEFINITIONS } from "./thinking-levels.js";
|
|
3
4
|
import type { DiscoveredModel, ModelInfoEntry } from "./types.js";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
const BUILTIN_CONTEXT_WINDOW = 128_000;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Context window used when neither /model/info nor the catalog reports one.
|
|
10
|
+
* LITELLM_DEFAULT_CONTEXT_WINDOW raises it for proxies whose model map cannot
|
|
11
|
+
* populate `max_input_tokens`; an unset or unusable value keeps the 128K default.
|
|
12
|
+
*/
|
|
13
|
+
export function defaultContextWindow(): number {
|
|
14
|
+
// Number, not parseInt: parseInt takes a numeric prefix, so `1.5` would become a
|
|
15
|
+
// 1-token window and `922000junk` would pass as a limit instead of being rejected.
|
|
16
|
+
const parsed = Number(process.env.LITELLM_DEFAULT_CONTEXT_WINDOW ?? "");
|
|
17
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : BUILTIN_CONTEXT_WINDOW;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether a stored window is one this extension assumed rather than read. A model cached
|
|
22
|
+
* under the built-in default must still read as evidence-free once an operator configures
|
|
23
|
+
* LITELLM_DEFAULT_CONTEXT_WINDOW, or the setting would never reach cached entries.
|
|
24
|
+
* ponytail: a window stored under a setting that was later lowered is indistinguishable
|
|
25
|
+
* from measured evidence and stays until the next online discovery; persist a fallback
|
|
26
|
+
* marker on the model if that ever matters.
|
|
27
|
+
*/
|
|
28
|
+
export function isFallbackContextWindow(contextWindow: number): boolean {
|
|
29
|
+
return contextWindow === BUILTIN_CONTEXT_WINDOW || contextWindow === defaultContextWindow();
|
|
30
|
+
}
|
|
31
|
+
|
|
6
32
|
export const DEFAULT_MAX_TOKENS = 16_384;
|
|
7
33
|
|
|
8
34
|
export type SemanticFamily = "claude" | "deepseek" | "gemini" | "kimi" | "openai";
|
|
@@ -12,7 +38,7 @@ type FamilyEvidence = SemanticFamily | "conflicting";
|
|
|
12
38
|
|
|
13
39
|
export type MessagesBackendCompat = Pick<
|
|
14
40
|
NonNullable<Model<"anthropic-messages">["compat"]>,
|
|
15
|
-
"forceAdaptiveThinking" | "supportsTemperature"
|
|
41
|
+
"forceAdaptiveThinking" | "supportsTemperature"
|
|
16
42
|
>;
|
|
17
43
|
|
|
18
44
|
type OpenAICompat = NonNullable<Model<"openai-completions">["compat"]>;
|
|
@@ -32,6 +58,9 @@ export interface CatalogResolution {
|
|
|
32
58
|
semanticFamily?: FamilyEvidence;
|
|
33
59
|
semanticModel?: SemanticModel;
|
|
34
60
|
messagesCompat?: MessagesBackendCompat;
|
|
61
|
+
// Evidence for the actual Messages route, kept separate from model-generation
|
|
62
|
+
// serializer policy so strict-tool disagreement cannot change the transport.
|
|
63
|
+
messagesStrictTools?: boolean;
|
|
35
64
|
messagesThinkingLevelMap?: DiscoveredModel["thinkingLevelMap"];
|
|
36
65
|
reasoning?: boolean;
|
|
37
66
|
effortLevels?: string[];
|
|
@@ -52,6 +81,7 @@ export interface ReducedModelGroup {
|
|
|
52
81
|
thinkingLevelMap?: DiscoveredModel["thinkingLevelMap"];
|
|
53
82
|
vision: boolean;
|
|
54
83
|
contextWindow: number;
|
|
84
|
+
contextWindowDefaulted?: boolean;
|
|
55
85
|
maxTokens: number;
|
|
56
86
|
cost: DiscoveredModel["cost"];
|
|
57
87
|
hasCompleteCost: boolean;
|
|
@@ -60,6 +90,7 @@ export interface ReducedModelGroup {
|
|
|
60
90
|
semanticModel?: SemanticModel;
|
|
61
91
|
semanticFamily?: FamilyEvidence;
|
|
62
92
|
messagesCompat?: MessagesBackendCompat;
|
|
93
|
+
messagesStrictTools?: boolean;
|
|
63
94
|
// Set when deployments disagreed on catalog provider identity, so catalog
|
|
64
95
|
// limits, pricing, and reasoning metadata were withheld for the whole group.
|
|
65
96
|
catalogAuthorityAmbiguous?: boolean;
|
|
@@ -712,6 +743,7 @@ export function hasMixedIncompatibleDeploymentModes(entries: readonly ModelInfoE
|
|
|
712
743
|
}
|
|
713
744
|
|
|
714
745
|
const KIMI_FAMILY_PATTERN = /(?:^|[./_-])(?:moonshotai|moonshot|kimi)(?:$|[./_:-])/i;
|
|
746
|
+
const MOONSHOT_ROUTES: ReadonlySet<string> = new Set(["moonshot", "moonshotai"]);
|
|
715
747
|
const FORCED_THINKING_PATTERN = /(?:^|[./_-])thinking(?:$|[./_:-])/i;
|
|
716
748
|
|
|
717
749
|
function kimiDeploymentEvidence(entry: ModelInfoEntry): {
|
|
@@ -728,21 +760,12 @@ function kimiDeploymentEvidence(entry: ModelInfoEntry): {
|
|
|
728
760
|
.map((candidate) => wireString(candidate)?.trim())
|
|
729
761
|
.filter((candidate): candidate is string => Boolean(candidate));
|
|
730
762
|
const kimi = identities.filter((identity) => KIMI_FAMILY_PATTERN.test(identity));
|
|
731
|
-
const routingModel = wireString(entry.litellm_params?.model)?.trim();
|
|
732
|
-
const routingProviders = [
|
|
733
|
-
wireString(entry.litellm_params?.custom_llm_provider)?.trim(),
|
|
734
|
-
routingModel?.includes("/") ? routingModel.split("/", 1)[0] : undefined,
|
|
735
|
-
]
|
|
736
|
-
.filter((provider): provider is string => Boolean(provider))
|
|
737
|
-
.map((provider) => provider.toLowerCase());
|
|
738
763
|
return {
|
|
739
764
|
identified: kimi.length > 0,
|
|
740
765
|
forcedThinking: kimi.some((identity) => FORCED_THINKING_PATTERN.test(identity)),
|
|
741
766
|
// Visibility parameters are accepted by Moonshot's API, not by every host
|
|
742
767
|
// that serves a Kimi model. Both declared routing signals must name Moonshot.
|
|
743
|
-
moonshotTransport:
|
|
744
|
-
routingProviders.length > 0 &&
|
|
745
|
-
routingProviders.every((provider) => provider === "moonshot" || provider === "moonshotai"),
|
|
768
|
+
moonshotTransport: routesOnlyThrough(entry, MOONSHOT_ROUTES),
|
|
746
769
|
};
|
|
747
770
|
}
|
|
748
771
|
|
|
@@ -772,6 +795,9 @@ export function reduceModelGroup(
|
|
|
772
795
|
const semanticModel = unanimous(catalogs.map((catalog) => catalog?.semanticModel));
|
|
773
796
|
const semanticFamily = unanimous(catalogs.map((catalog) => catalog?.semanticFamily));
|
|
774
797
|
const messagesCompat = unanimous(catalogs.map((catalog) => stableJson(catalog?.messagesCompat)));
|
|
798
|
+
// Strict tools require affirmative evidence from every routable deployment.
|
|
799
|
+
const messagesStrictTools =
|
|
800
|
+
catalogs.length > 0 && catalogs.every((catalog) => catalog?.messagesStrictTools === true) ? true : undefined;
|
|
775
801
|
const messagesEndpointAllowed = deployments.every((entry) => {
|
|
776
802
|
const endpoints = entry.model_info?.supported_endpoints;
|
|
777
803
|
return endpoints === undefined || (Array.isArray(endpoints) && endpoints.includes("/v1/messages"));
|
|
@@ -801,7 +827,8 @@ export function reduceModelGroup(
|
|
|
801
827
|
(entry) => wireBoolean(entry.model_info?.supports_reasoning) === false,
|
|
802
828
|
);
|
|
803
829
|
const vision = visionEvidence.every((value) => value ?? false);
|
|
804
|
-
const
|
|
830
|
+
const fallbackContextWindow = defaultContextWindow();
|
|
831
|
+
const contextWindow = min(contextWindowEvidence.map((value) => value ?? fallbackContextWindow));
|
|
805
832
|
const maxTokens = min(maxTokensEvidence.map((value) => value ?? DEFAULT_MAX_TOKENS));
|
|
806
833
|
|
|
807
834
|
const costValues = COST_FIELDS.map((field) =>
|
|
@@ -930,6 +957,9 @@ export function reduceModelGroup(
|
|
|
930
957
|
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
|
931
958
|
vision,
|
|
932
959
|
contextWindow,
|
|
960
|
+
...(contextWindowEvidence.includes(undefined) && contextWindow === fallbackContextWindow
|
|
961
|
+
? { contextWindowDefaulted: true }
|
|
962
|
+
: {}),
|
|
933
963
|
maxTokens,
|
|
934
964
|
cost,
|
|
935
965
|
hasCompleteCost,
|
|
@@ -938,6 +968,7 @@ export function reduceModelGroup(
|
|
|
938
968
|
...(semanticModel ? { semanticModel } : {}),
|
|
939
969
|
...(semanticFamily ? { semanticFamily } : {}),
|
|
940
970
|
...(messagesCompat ? { messagesCompat: JSON.parse(messagesCompat) } : {}),
|
|
971
|
+
...(messagesStrictTools !== undefined ? { messagesStrictTools } : {}),
|
|
941
972
|
...(catalogAuthorityAmbiguous ? { catalogAuthorityAmbiguous: true } : {}),
|
|
942
973
|
deploymentFamilies: catalogs.map((catalog) => catalog?.semanticFamily),
|
|
943
974
|
normalizeThinkTags: unanimousNormalKimi,
|