pi-provider-litellm 3.2.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -9
- package/package.json +6 -6
- package/src/backend-identity.ts +17 -1
- package/src/discover.ts +180 -41
- package/src/index.ts +530 -150
- package/src/mcp-tools.ts +191 -9
- package/src/model-groups.ts +12 -11
- package/src/provider.ts +27 -12
- package/src/proxy-version.ts +68 -0
- package/src/skills.ts +10 -0
- package/src/types.ts +16 -0
package/src/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;
|
|
@@ -36,19 +43,110 @@ interface RawLiteLLMMcpTool {
|
|
|
36
43
|
description?: unknown;
|
|
37
44
|
inputSchema?: unknown;
|
|
38
45
|
input_schema?: unknown;
|
|
46
|
+
annotations?: unknown;
|
|
39
47
|
server_id?: unknown;
|
|
40
48
|
server_name?: unknown;
|
|
41
49
|
mcp_info?: {
|
|
42
50
|
server_id?: unknown;
|
|
43
51
|
server_name?: unknown;
|
|
52
|
+
description?: unknown;
|
|
44
53
|
};
|
|
45
54
|
}
|
|
46
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
|
+
|
|
47
144
|
interface PreparedTool {
|
|
48
145
|
tool: LiteLLMMcpTool;
|
|
49
146
|
name: string;
|
|
50
147
|
parameters: TSchema;
|
|
51
148
|
syntheticArgsEnvelope: boolean;
|
|
149
|
+
exposure: McpExposure;
|
|
52
150
|
}
|
|
53
151
|
|
|
54
152
|
interface InvalidMcpTool {
|
|
@@ -104,6 +202,8 @@ export interface McpPreparationReport {
|
|
|
104
202
|
// Non-printed identities paired with `dropped`, used to detect membership changes safely.
|
|
105
203
|
dropMemberships: Array<{ reason: McpDropReason; identities: string[] }>;
|
|
106
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[];
|
|
107
207
|
}
|
|
108
208
|
|
|
109
209
|
// Bounded discovery result: `raw` is what the proxy returned, so losses can be reconciled.
|
|
@@ -460,6 +560,21 @@ function parseDiscoveryJson(text: string): unknown {
|
|
|
460
560
|
return parseJson(text, "MCP discovery");
|
|
461
561
|
}
|
|
462
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
|
+
|
|
463
578
|
function normalizeMcpTool(value: unknown): LiteLLMMcpTool | undefined {
|
|
464
579
|
const raw = asRecord(value) as RawLiteLLMMcpTool | undefined;
|
|
465
580
|
if (!raw) return undefined;
|
|
@@ -474,6 +589,8 @@ function normalizeMcpTool(value: unknown): LiteLLMMcpTool | undefined {
|
|
|
474
589
|
const hasSnakeInputSchema = Object.hasOwn(raw, "input_schema");
|
|
475
590
|
const suppliedSchema = hasInputSchema ? raw.inputSchema : hasSnakeInputSchema ? raw.input_schema : undefined;
|
|
476
591
|
const inputSchema = asRecord(suppliedSchema);
|
|
592
|
+
const annotations = toolAnnotations(raw.annotations);
|
|
593
|
+
const serverDescription = stringValue(raw.mcp_info?.description);
|
|
477
594
|
return {
|
|
478
595
|
name,
|
|
479
596
|
server_name: serverName,
|
|
@@ -482,6 +599,8 @@ function normalizeMcpTool(value: unknown): LiteLLMMcpTool | undefined {
|
|
|
482
599
|
input_schema: inputSchema ?? {},
|
|
483
600
|
// Present but not an object: malformed, as distinct from absent.
|
|
484
601
|
...(inputSchema === undefined && (hasInputSchema || hasSnakeInputSchema) ? { input_schema_malformed: true } : {}),
|
|
602
|
+
...(annotations ? { annotations } : {}),
|
|
603
|
+
...(serverDescription ? { server_description: serverDescription } : {}),
|
|
485
604
|
};
|
|
486
605
|
}
|
|
487
606
|
|
|
@@ -682,6 +801,34 @@ function mcpCallError(serverId: string, toolName: string, detail: string): Error
|
|
|
682
801
|
);
|
|
683
802
|
}
|
|
684
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
|
+
|
|
685
832
|
export async function executeMcpTool(
|
|
686
833
|
baseUrl: string,
|
|
687
834
|
apiKey: string,
|
|
@@ -692,7 +839,7 @@ export async function executeMcpTool(
|
|
|
692
839
|
parentSignal?: AbortSignal,
|
|
693
840
|
allowInsecureHttp = false,
|
|
694
841
|
onDiagnostic?: DiagnosticSink,
|
|
695
|
-
): Promise<
|
|
842
|
+
): Promise<McpToolOutcome> {
|
|
696
843
|
parentSignal?.throwIfAborted();
|
|
697
844
|
const signal = boundedSignal(CALL_TIMEOUT_MS, parentSignal);
|
|
698
845
|
let response: Response;
|
|
@@ -721,9 +868,11 @@ export async function executeMcpTool(
|
|
|
721
868
|
if (resultRecord?.isError === true || bodyRecord?.isError === true) {
|
|
722
869
|
const message =
|
|
723
870
|
mcpErrorMessage(resultRecord?.content ?? bodyRecord?.content ?? result) ?? serializeResult(result);
|
|
724
|
-
|
|
871
|
+
const text = mcpCallError(serverId, toolName, message).message;
|
|
872
|
+
return { text, structuredContent: structuredResult(result, text, true), isError: true };
|
|
725
873
|
}
|
|
726
|
-
|
|
874
|
+
const text = serializeResult(result);
|
|
875
|
+
return { text, structuredContent: structuredResult(result, text, false), isError: false };
|
|
727
876
|
} catch (error) {
|
|
728
877
|
if (parentSignal?.aborted) throw parentSignal.reason;
|
|
729
878
|
throw error;
|
|
@@ -758,6 +907,16 @@ function buildPiToolName(tool: LiteLLMMcpTool, namespace?: string): string {
|
|
|
758
907
|
return `${base.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;
|
|
759
908
|
}
|
|
760
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)}`;
|
|
917
|
+
return `${base.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;
|
|
918
|
+
}
|
|
919
|
+
|
|
761
920
|
function schemaDepth(value: unknown, depth = 0): number {
|
|
762
921
|
if (!value || typeof value !== "object") return depth;
|
|
763
922
|
if (depth > MAX_SCHEMA_DEPTH) return depth;
|
|
@@ -1147,6 +1306,7 @@ function buildParameters(tool: LiteLLMMcpTool): BuiltParameters | McpDropReason
|
|
|
1147
1306
|
export function prepareTools(
|
|
1148
1307
|
discovery: McpDiscovery,
|
|
1149
1308
|
namespace?: string,
|
|
1309
|
+
exposurePolicy: McpExposurePolicy = DEFAULT_MCP_EXPOSURE_POLICY,
|
|
1150
1310
|
): {
|
|
1151
1311
|
prepared: PreparedTool[];
|
|
1152
1312
|
report: McpPreparationReport;
|
|
@@ -1185,6 +1345,7 @@ export function prepareTools(
|
|
|
1185
1345
|
tool,
|
|
1186
1346
|
parameters: built.parameters,
|
|
1187
1347
|
syntheticArgsEnvelope: built.syntheticArgsEnvelope,
|
|
1348
|
+
exposure: resolveMcpExposure(exposurePolicy, tool.server_name, tool.name),
|
|
1188
1349
|
degraded: built.degraded,
|
|
1189
1350
|
});
|
|
1190
1351
|
}
|
|
@@ -1222,6 +1383,7 @@ export function prepareTools(
|
|
|
1222
1383
|
dropped: [...drops.entries()].map(([reason, tools]) => ({ reason, tools })),
|
|
1223
1384
|
dropMemberships: [...dropMemberships.entries()].map(([reason, identities]) => ({ reason, identities })),
|
|
1224
1385
|
degraded: [...degrades.entries()].map(([reason, tools]) => ({ reason, tools })),
|
|
1386
|
+
exposures: [...new Set(prepared.map((tool) => tool.exposure))].sort(),
|
|
1225
1387
|
};
|
|
1226
1388
|
return { prepared, report };
|
|
1227
1389
|
}
|
|
@@ -1273,6 +1435,7 @@ export async function createMcpToolDefinitions(
|
|
|
1273
1435
|
signal?: AbortSignal,
|
|
1274
1436
|
onDiagnostic?: DiagnosticSink,
|
|
1275
1437
|
namespace?: string,
|
|
1438
|
+
exposurePolicy: McpExposurePolicy = DEFAULT_MCP_EXPOSURE_POLICY,
|
|
1276
1439
|
): Promise<{ definitions: ToolDefinition[]; report: McpPreparationReport }> {
|
|
1277
1440
|
const discoveryAuth = await getAuth();
|
|
1278
1441
|
const discovery = await discoverMcpTools(
|
|
@@ -1284,7 +1447,7 @@ export async function createMcpToolDefinitions(
|
|
|
1284
1447
|
discoveryAuth.allowInsecureHttp,
|
|
1285
1448
|
onDiagnostic,
|
|
1286
1449
|
);
|
|
1287
|
-
const { prepared, report } = prepareTools(discovery, namespace);
|
|
1450
|
+
const { prepared, report } = prepareTools(discovery, namespace, exposurePolicy);
|
|
1288
1451
|
emitPreparationDiagnostics(report, onDiagnostic);
|
|
1289
1452
|
const lostTotal = report.dropped.reduce((total, entry) => total + entry.tools.length, 0) + report.overflow;
|
|
1290
1453
|
const lostDetail = [
|
|
@@ -1298,7 +1461,7 @@ export async function createMcpToolDefinitions(
|
|
|
1298
1461
|
`${lostDetail ? ` (${lostDetail})` : ""}`,
|
|
1299
1462
|
);
|
|
1300
1463
|
|
|
1301
|
-
const definitions = prepared.map(({ tool: mcpTool, name, parameters, syntheticArgsEnvelope }) => {
|
|
1464
|
+
const definitions = prepared.map(({ tool: mcpTool, name, parameters, syntheticArgsEnvelope, exposure }) => {
|
|
1302
1465
|
const description = truncateUtf8(
|
|
1303
1466
|
`${mcpTool.description} (via ${mcpTool.server_name} MCP server)`,
|
|
1304
1467
|
MAX_DESCRIPTION_BYTES,
|
|
@@ -1314,6 +1477,16 @@ export async function createMcpToolDefinitions(
|
|
|
1314
1477
|
// untrusted string that reaches Pi's UI or the model.
|
|
1315
1478
|
const label = truncateUtf8(`${mcpTool.server_name}: ${mcpTool.name}`, MAX_LABEL_BYTES, SHORT_TRUNCATION_MARKER);
|
|
1316
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
|
+
};
|
|
1317
1490
|
|
|
1318
1491
|
return defineTool({
|
|
1319
1492
|
name,
|
|
@@ -1321,13 +1494,17 @@ export async function createMcpToolDefinitions(
|
|
|
1321
1494
|
description,
|
|
1322
1495
|
promptSnippet,
|
|
1323
1496
|
executionMode: "parallel",
|
|
1497
|
+
exposure: toToolExposure(exposure),
|
|
1498
|
+
namespace: toolNamespace,
|
|
1499
|
+
...(mcpTool.annotations ? { annotations: mcpTool.annotations } : {}),
|
|
1324
1500
|
parameters,
|
|
1501
|
+
outputSchema: MCP_RESULT_SCHEMA,
|
|
1325
1502
|
async execute(_toolCallId, params: Static<typeof parameters>, toolSignal, _onUpdate, ctx) {
|
|
1326
1503
|
const auth = await getAuth(ctx);
|
|
1327
1504
|
const rawParams = params as Record<string, unknown>;
|
|
1328
1505
|
const args = syntheticArgsEnvelope ? asRecord(rawParams.args) : rawParams;
|
|
1329
1506
|
if (!args) throw new Error("Synthetic MCP tool arguments must contain an object-valued args property");
|
|
1330
|
-
const text = await executeMcpTool(
|
|
1507
|
+
const { text, structuredContent, isError } = await executeMcpTool(
|
|
1331
1508
|
auth.baseUrl,
|
|
1332
1509
|
auth.apiKey,
|
|
1333
1510
|
mcpTool.server_id ?? mcpTool.server_name,
|
|
@@ -1338,8 +1515,13 @@ export async function createMcpToolDefinitions(
|
|
|
1338
1515
|
auth.allowInsecureHttp,
|
|
1339
1516
|
onDiagnostic,
|
|
1340
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);
|
|
1341
1520
|
return {
|
|
1342
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 } : {}),
|
|
1343
1525
|
details: {
|
|
1344
1526
|
server: boundedDetail(mcpTool.server_name),
|
|
1345
1527
|
serverId: boundedDetail(mcpTool.server_id ?? mcpTool.server_name),
|
package/src/model-groups.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
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
|
|
|
@@ -37,7 +38,7 @@ type FamilyEvidence = SemanticFamily | "conflicting";
|
|
|
37
38
|
|
|
38
39
|
export type MessagesBackendCompat = Pick<
|
|
39
40
|
NonNullable<Model<"anthropic-messages">["compat"]>,
|
|
40
|
-
"forceAdaptiveThinking" | "supportsTemperature"
|
|
41
|
+
"forceAdaptiveThinking" | "supportsTemperature"
|
|
41
42
|
>;
|
|
42
43
|
|
|
43
44
|
type OpenAICompat = NonNullable<Model<"openai-completions">["compat"]>;
|
|
@@ -57,6 +58,9 @@ export interface CatalogResolution {
|
|
|
57
58
|
semanticFamily?: FamilyEvidence;
|
|
58
59
|
semanticModel?: SemanticModel;
|
|
59
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;
|
|
60
64
|
messagesThinkingLevelMap?: DiscoveredModel["thinkingLevelMap"];
|
|
61
65
|
reasoning?: boolean;
|
|
62
66
|
effortLevels?: string[];
|
|
@@ -86,6 +90,7 @@ export interface ReducedModelGroup {
|
|
|
86
90
|
semanticModel?: SemanticModel;
|
|
87
91
|
semanticFamily?: FamilyEvidence;
|
|
88
92
|
messagesCompat?: MessagesBackendCompat;
|
|
93
|
+
messagesStrictTools?: boolean;
|
|
89
94
|
// Set when deployments disagreed on catalog provider identity, so catalog
|
|
90
95
|
// limits, pricing, and reasoning metadata were withheld for the whole group.
|
|
91
96
|
catalogAuthorityAmbiguous?: boolean;
|
|
@@ -738,6 +743,7 @@ export function hasMixedIncompatibleDeploymentModes(entries: readonly ModelInfoE
|
|
|
738
743
|
}
|
|
739
744
|
|
|
740
745
|
const KIMI_FAMILY_PATTERN = /(?:^|[./_-])(?:moonshotai|moonshot|kimi)(?:$|[./_:-])/i;
|
|
746
|
+
const MOONSHOT_ROUTES: ReadonlySet<string> = new Set(["moonshot", "moonshotai"]);
|
|
741
747
|
const FORCED_THINKING_PATTERN = /(?:^|[./_-])thinking(?:$|[./_:-])/i;
|
|
742
748
|
|
|
743
749
|
function kimiDeploymentEvidence(entry: ModelInfoEntry): {
|
|
@@ -754,21 +760,12 @@ function kimiDeploymentEvidence(entry: ModelInfoEntry): {
|
|
|
754
760
|
.map((candidate) => wireString(candidate)?.trim())
|
|
755
761
|
.filter((candidate): candidate is string => Boolean(candidate));
|
|
756
762
|
const kimi = identities.filter((identity) => KIMI_FAMILY_PATTERN.test(identity));
|
|
757
|
-
const routingModel = wireString(entry.litellm_params?.model)?.trim();
|
|
758
|
-
const routingProviders = [
|
|
759
|
-
wireString(entry.litellm_params?.custom_llm_provider)?.trim(),
|
|
760
|
-
routingModel?.includes("/") ? routingModel.split("/", 1)[0] : undefined,
|
|
761
|
-
]
|
|
762
|
-
.filter((provider): provider is string => Boolean(provider))
|
|
763
|
-
.map((provider) => provider.toLowerCase());
|
|
764
763
|
return {
|
|
765
764
|
identified: kimi.length > 0,
|
|
766
765
|
forcedThinking: kimi.some((identity) => FORCED_THINKING_PATTERN.test(identity)),
|
|
767
766
|
// Visibility parameters are accepted by Moonshot's API, not by every host
|
|
768
767
|
// that serves a Kimi model. Both declared routing signals must name Moonshot.
|
|
769
|
-
moonshotTransport:
|
|
770
|
-
routingProviders.length > 0 &&
|
|
771
|
-
routingProviders.every((provider) => provider === "moonshot" || provider === "moonshotai"),
|
|
768
|
+
moonshotTransport: routesOnlyThrough(entry, MOONSHOT_ROUTES),
|
|
772
769
|
};
|
|
773
770
|
}
|
|
774
771
|
|
|
@@ -798,6 +795,9 @@ export function reduceModelGroup(
|
|
|
798
795
|
const semanticModel = unanimous(catalogs.map((catalog) => catalog?.semanticModel));
|
|
799
796
|
const semanticFamily = unanimous(catalogs.map((catalog) => catalog?.semanticFamily));
|
|
800
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;
|
|
801
801
|
const messagesEndpointAllowed = deployments.every((entry) => {
|
|
802
802
|
const endpoints = entry.model_info?.supported_endpoints;
|
|
803
803
|
return endpoints === undefined || (Array.isArray(endpoints) && endpoints.includes("/v1/messages"));
|
|
@@ -968,6 +968,7 @@ export function reduceModelGroup(
|
|
|
968
968
|
...(semanticModel ? { semanticModel } : {}),
|
|
969
969
|
...(semanticFamily ? { semanticFamily } : {}),
|
|
970
970
|
...(messagesCompat ? { messagesCompat: JSON.parse(messagesCompat) } : {}),
|
|
971
|
+
...(messagesStrictTools !== undefined ? { messagesStrictTools } : {}),
|
|
971
972
|
...(catalogAuthorityAmbiguous ? { catalogAuthorityAmbiguous: true } : {}),
|
|
972
973
|
deploymentFamilies: catalogs.map((catalog) => catalog?.semanticFamily),
|
|
973
974
|
normalizeThinkTags: unanimousNormalKimi,
|
package/src/provider.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type AnyModel,
|
|
3
|
+
type Api,
|
|
2
4
|
type ApiStreamOptions,
|
|
3
|
-
type Context,
|
|
4
5
|
type Credential,
|
|
5
6
|
createProvider,
|
|
7
|
+
getCurrentTools,
|
|
6
8
|
type Model,
|
|
7
9
|
type Provider,
|
|
8
10
|
type ProviderAuth,
|
|
9
11
|
type SimpleStreamOptions,
|
|
12
|
+
type TranscriptContext,
|
|
10
13
|
} from "@earendil-works/pi-ai";
|
|
11
14
|
import { LITELLM_DISCOVERY_VERSION } from "./backend-identity.js";
|
|
12
15
|
import { enrichCachedModel, normalizeBaseUrl, restoreCachedModelPolicy } from "./discover.js";
|
|
@@ -26,6 +29,11 @@ export type LiteLLMProviderOptions = {
|
|
|
26
29
|
discover(credential: Credential, signal?: AbortSignal): Promise<DiscoveryResult & { baseUrl?: string }>;
|
|
27
30
|
};
|
|
28
31
|
|
|
32
|
+
// Chat models may omit `type`; Pi's `isModelType()` is newer than the peer floor, so keep the check local.
|
|
33
|
+
function isChatModel(model: AnyModel): model is Model<Api> {
|
|
34
|
+
return (model.type ?? "chat") === "chat";
|
|
35
|
+
}
|
|
36
|
+
|
|
29
37
|
export function toNativeModels(
|
|
30
38
|
provider: string,
|
|
31
39
|
baseUrl: string,
|
|
@@ -112,7 +120,7 @@ function modelRootError(
|
|
|
112
120
|
function requestModel(
|
|
113
121
|
provider: string,
|
|
114
122
|
model: Model<LiteLLMApi>,
|
|
115
|
-
context:
|
|
123
|
+
context: TranscriptContext,
|
|
116
124
|
credentialRoot: string | undefined,
|
|
117
125
|
allowInsecureHttp = false,
|
|
118
126
|
): Model<LiteLLMApi> {
|
|
@@ -120,14 +128,13 @@ function requestModel(
|
|
|
120
128
|
const active = activeCredentialRoot(credentialRoot, allowInsecureHttp);
|
|
121
129
|
const error = modelRootError(model, active, allowInsecureHttp);
|
|
122
130
|
if (error) throw error;
|
|
123
|
-
|
|
124
|
-
model.api === "openai-completions" &&
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
) {
|
|
131
|
+
const toolCount =
|
|
132
|
+
model.api === "openai-completions" && (model as LiteLLMModel).litellmBackendFamily === "openai"
|
|
133
|
+
? getCurrentTools(context.messages).length
|
|
134
|
+
: 0;
|
|
135
|
+
if (toolCount > CHAT_TOOL_CAP) {
|
|
129
136
|
throw new Error(
|
|
130
|
-
`LiteLLM model ${model.id} uses Chat Completions with ${
|
|
137
|
+
`LiteLLM model ${model.id} uses Chat Completions with ${toolCount} tools, exceeding the ` +
|
|
131
138
|
`${CHAT_TOOL_CAP}-tool cap; route this model via Responses or reduce enabled extensions`,
|
|
132
139
|
);
|
|
133
140
|
}
|
|
@@ -189,7 +196,7 @@ export function createLiteLLMProvider(options: LiteLLMProviderOptions): Provider
|
|
|
189
196
|
const refreshModels = provider.refreshModels;
|
|
190
197
|
const guardedProvider: Provider<LiteLLMApi> = {
|
|
191
198
|
...provider,
|
|
192
|
-
stream: <T extends LiteLLMApi>(model: Model<T>, context:
|
|
199
|
+
stream: <T extends LiteLLMApi>(model: Model<T>, context: TranscriptContext, requestOptions?: ApiStreamOptions<T>) =>
|
|
193
200
|
provider.stream(
|
|
194
201
|
requestModel(
|
|
195
202
|
options.id,
|
|
@@ -201,7 +208,7 @@ export function createLiteLLMProvider(options: LiteLLMProviderOptions): Provider
|
|
|
201
208
|
context,
|
|
202
209
|
requestOptions,
|
|
203
210
|
),
|
|
204
|
-
streamSimple: (model: Model<LiteLLMApi>, context:
|
|
211
|
+
streamSimple: (model: Model<LiteLLMApi>, context: TranscriptContext, requestOptions?: SimpleStreamOptions) =>
|
|
205
212
|
provider.streamSimple(
|
|
206
213
|
requestModel(
|
|
207
214
|
options.id,
|
|
@@ -215,13 +222,21 @@ export function createLiteLLMProvider(options: LiteLLMProviderOptions): Provider
|
|
|
215
222
|
),
|
|
216
223
|
};
|
|
217
224
|
if (!refreshModels) return guardedProvider;
|
|
225
|
+
const seedKey = (model: { id: string; baseUrl: string }) => `${model.id}\0${model.baseUrl}`;
|
|
226
|
+
const seeded = new Map((options.models ?? []).map((model) => [seedKey(model), model]));
|
|
218
227
|
return {
|
|
219
228
|
...guardedProvider,
|
|
220
229
|
refreshModels: async (context) => {
|
|
221
|
-
|
|
230
|
+
// Pi 0.99 persists models of every type; this provider only publishes chat models, and the
|
|
231
|
+
// policy helpers below read chat-only fields.
|
|
232
|
+
const storedModels = (context.stored?.models ?? []).filter(isChatModel);
|
|
222
233
|
let legacyCount = 0;
|
|
223
234
|
const models = storedModels.map((model) => {
|
|
224
235
|
if ((model as LiteLLMModel).litellmDiscoveryVersion !== LITELLM_DISCOVERY_VERSION) {
|
|
236
|
+
// Pi applies stored models over the seed, so a stale entry would otherwise mask what
|
|
237
|
+
// startup discovery just proved for the same route.
|
|
238
|
+
const fresh = seeded.get(seedKey(model));
|
|
239
|
+
if (fresh) return fresh;
|
|
225
240
|
legacyCount++;
|
|
226
241
|
return restoreCachedModelPolicy(model);
|
|
227
242
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { DiscoveryOptions } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export interface ProxyVersion {
|
|
4
|
+
major: number;
|
|
5
|
+
minor: number;
|
|
6
|
+
patch: number;
|
|
7
|
+
// Any suffix (rc, dev, post, local build). It orders before the release it names,
|
|
8
|
+
// so a pre-release never claims a fix that only the release is known to carry.
|
|
9
|
+
prerelease: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ProxyVersionFloor = readonly [major: number, minor: number, patch: number];
|
|
13
|
+
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
15
|
+
const MAX_VERSION_LENGTH = 64;
|
|
16
|
+
const VERSION_PATTERN = /^v?(\d{1,5})\.(\d{1,5})\.(\d{1,5})(.*)$/s;
|
|
17
|
+
const VERSION_HEADER = "x-litellm-version";
|
|
18
|
+
// LiteLLM stamps its version on inference routes only; `/model/info`, `/v1/models`
|
|
19
|
+
// and `/health/*` do not carry it. A made-up Responses id names no deployment, so
|
|
20
|
+
// the read is answered with an error that still carries the header and calls no model.
|
|
21
|
+
const VERSION_PROBE_PATH = "/v1/responses/resp_version_probe";
|
|
22
|
+
|
|
23
|
+
// The value is proxy-supplied: bound it before matching and keep only the numbers.
|
|
24
|
+
export function parseProxyVersion(value: unknown): ProxyVersion | undefined {
|
|
25
|
+
if (typeof value !== "string" || value.length > MAX_VERSION_LENGTH) return undefined;
|
|
26
|
+
const match = VERSION_PATTERN.exec(value.trim());
|
|
27
|
+
if (!match) return undefined;
|
|
28
|
+
return {
|
|
29
|
+
major: Number(match[1]),
|
|
30
|
+
minor: Number(match[2]),
|
|
31
|
+
patch: Number(match[3]),
|
|
32
|
+
prerelease: match[4] !== "",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// An unknown version satisfies no floor, so a version-gated workaround stays on
|
|
37
|
+
// whenever the proxy does not say what it is.
|
|
38
|
+
export function proxyVersionAtLeast(version: ProxyVersion | undefined, floor: ProxyVersionFloor): boolean {
|
|
39
|
+
if (!version) return false;
|
|
40
|
+
const actual = [version.major, version.minor, version.patch];
|
|
41
|
+
for (const [index, required] of floor.entries()) {
|
|
42
|
+
if (actual[index] !== required) return actual[index]! > required;
|
|
43
|
+
}
|
|
44
|
+
return !version.prerelease;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Best effort by design: the header rides on an error path LiteLLM does not
|
|
48
|
+
// document, so a missing header, an unreachable proxy or a timeout all mean
|
|
49
|
+
// "unknown" and never fail discovery.
|
|
50
|
+
export async function probeProxyVersion(
|
|
51
|
+
base: string,
|
|
52
|
+
apiKey: string,
|
|
53
|
+
options: Pick<DiscoveryOptions, "timeoutMs" | "signal" | "headers">,
|
|
54
|
+
): Promise<ProxyVersion | undefined> {
|
|
55
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
56
|
+
try {
|
|
57
|
+
const response = await fetch(`${base}${VERSION_PROBE_PATH}`, {
|
|
58
|
+
headers: { ...options.headers, Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
59
|
+
signal: options.signal
|
|
60
|
+
? AbortSignal.any([options.signal, AbortSignal.timeout(timeoutMs)])
|
|
61
|
+
: AbortSignal.timeout(timeoutMs),
|
|
62
|
+
});
|
|
63
|
+
await response.body?.cancel().catch(() => undefined);
|
|
64
|
+
return parseProxyVersion(response.headers.get(VERSION_HEADER));
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/skills.ts
CHANGED
|
@@ -169,6 +169,10 @@ 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.
|
|
174
|
+
const SKILLS_NAMESPACE = { name: "litellm_skills", description: "LiteLLM Skills Gateway" };
|
|
175
|
+
|
|
172
176
|
export function createSkillToolDefinitions(
|
|
173
177
|
getAuth: (ctx?: ExtensionContext) => Promise<LiteLLMRuntimeAuth>,
|
|
174
178
|
): ToolDefinition[] {
|
|
@@ -178,6 +182,8 @@ export function createSkillToolDefinitions(
|
|
|
178
182
|
label: "LiteLLM Skills",
|
|
179
183
|
description: "List skills registered on the LiteLLM proxy Skills Gateway.",
|
|
180
184
|
promptSnippet: "List LiteLLM Skills Gateway skills",
|
|
185
|
+
namespace: SKILLS_NAMESPACE,
|
|
186
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
181
187
|
parameters: Type.Object({}),
|
|
182
188
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
183
189
|
const auth = await getAuth(ctx);
|
|
@@ -189,6 +195,8 @@ export function createSkillToolDefinitions(
|
|
|
189
195
|
name: "litellm_skill_create",
|
|
190
196
|
label: "Create LiteLLM Skill",
|
|
191
197
|
description: "Create and enable a skill on the LiteLLM proxy Skill Hub from source metadata.",
|
|
198
|
+
namespace: SKILLS_NAMESPACE,
|
|
199
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
192
200
|
parameters: CreateSkillParams,
|
|
193
201
|
async execute(_toolCallId, params: Static<typeof CreateSkillParams>, _signal, _onUpdate, ctx) {
|
|
194
202
|
const auth = await getAuth(ctx);
|
|
@@ -207,6 +215,8 @@ export function createSkillToolDefinitions(
|
|
|
207
215
|
name: "litellm_skill_delete",
|
|
208
216
|
label: "Delete LiteLLM Skill",
|
|
209
217
|
description: "Delete a skill from the LiteLLM proxy Skills Gateway.",
|
|
218
|
+
namespace: SKILLS_NAMESPACE,
|
|
219
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
210
220
|
parameters: DeleteSkillParams,
|
|
211
221
|
async execute(_toolCallId, params: Static<typeof DeleteSkillParams>, _signal, _onUpdate, ctx) {
|
|
212
222
|
const auth = await getAuth(ctx);
|