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/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: 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
- if (
124
- model.api === "openai-completions" &&
125
- (model as LiteLLMModel).litellmBackendFamily === "openai" &&
126
- context.tools &&
127
- context.tools.length > CHAT_TOOL_CAP
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 ${context.tools.length} tools, exceeding the ` +
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: Context, requestOptions?: ApiStreamOptions<T>) =>
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: Context, requestOptions?: SimpleStreamOptions) =>
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
- const storedModels = context.stored?.models ?? [];
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
+ }
@@ -73,6 +73,7 @@ const PROVIDER_ALIASES: Readonly<Record<string, readonly string[]>> = {
73
73
  const PI_PROVIDER_ALIASES: Readonly<Record<string, readonly string[]>> = {
74
74
  "amazon-bedrock": ["amazon-bedrock"],
75
75
  azure: ["azure-openai-responses"],
76
+ azure_ai: ["azure-openai-responses"],
76
77
  chatgpt: ["openai-codex"],
77
78
  "fireworks-ai": ["fireworks"],
78
79
  openai: ["openai"],
@@ -245,6 +246,7 @@ export async function loadPublicCatalog(options: LoadPublicCatalogOptions = {}):
245
246
  const providers = providerCandidates(provider);
246
247
  const ids = lookupIds(id);
247
248
  const vendor = providers.find((candidate) => candidate !== "azure") ?? providers[0];
249
+ const adapter = provider?.trim().toLowerCase();
248
250
  for (const candidate of providers) {
249
251
  const models = catalog?.[candidate]?.models;
250
252
  for (const modelId of ids) {
@@ -252,20 +254,25 @@ export async function loadPublicCatalog(options: LoadPublicCatalogOptions = {}):
252
254
  if (!model) continue;
253
255
  const record = mapModelsDev(candidate, modelId, model);
254
256
  if (candidate === providers[0] || !vendor) return record;
255
- return { ...record, piProvider: (PI_PROVIDER_ALIASES[vendor] ?? [vendor])[0] };
257
+ const fallback =
258
+ adapter && (PI_PROVIDER_ALIASES[adapter] ?? [adapter]).some((piProvider) => findPiModel(piProvider, ids))
259
+ ? adapter
260
+ : vendor;
261
+ return { ...record, piProvider: (PI_PROVIDER_ALIASES[fallback] ?? [fallback])[0] };
256
262
  }
257
263
  }
258
- if (vendor) {
259
- for (const piProvider of PI_PROVIDER_ALIASES[vendor] ?? [vendor]) {
264
+ // Provider-specific limits and pricing must not be replaced by the generic
265
+ // vendor's entry just because both catalogs recognize the model id.
266
+ if (adapter && adapter !== vendor) {
267
+ for (const piProvider of PI_PROVIDER_ALIASES[adapter] ?? [adapter]) {
260
268
  const model = findPiModel(piProvider, ids);
261
- if (model) return toPiRecord("pi-vendor", piProvider, model);
269
+ if (model) return toPiRecord("pi-adapter", piProvider, model);
262
270
  }
263
271
  }
264
- const adapter = provider?.trim().toLowerCase();
265
- if (adapter) {
266
- for (const piProvider of PI_PROVIDER_ALIASES[adapter] ?? [adapter]) {
272
+ if (vendor) {
273
+ for (const piProvider of PI_PROVIDER_ALIASES[vendor] ?? [vendor]) {
267
274
  const model = findPiModel(piProvider, ids);
268
- if (model) return toPiRecord("pi-adapter", piProvider, model);
275
+ if (model) return toPiRecord("pi-vendor", piProvider, model);
269
276
  }
270
277
  }
271
278
  return undefined;
package/src/skills.ts CHANGED
@@ -42,97 +42,55 @@ export async function listSkills(
42
42
  return skillsCache.skills;
43
43
  }
44
44
 
45
- const fetchSkills = async (path: string): Promise<LiteLLMSkill[]> => {
45
+ const fetchSkills = async (path: string): Promise<LiteLLMSkill[] | undefined> => {
46
46
  try {
47
47
  const response = await fetch(`${normalizedBaseUrl}${path}`, {
48
48
  headers: { ...headers, Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
49
49
  signal: AbortSignal.timeout(10_000),
50
50
  });
51
- return response.ok ? getSkillsFromBody(await response.json()) : [];
51
+ return response.ok ? getSkillsFromBody(await response.json()) : undefined;
52
52
  } catch {
53
- return [];
53
+ return undefined;
54
54
  }
55
55
  };
56
56
 
57
- const [skillHub, legacy] = await Promise.all([
58
- fetchSkills("/claude-code/marketplace.json"),
59
- fetchSkills("/v1/skills"),
60
- ]);
61
- const skillHubNames = new Set(skillHub.map((skill) => skill.name));
62
- const skills = [...skillHub, ...legacy.filter((skill) => !skillHubNames.has(skill.name))];
57
+ const skills = (await fetchSkills("/claude-code/marketplace.json")) ?? (await fetchSkills("/v1/skills")) ?? [];
63
58
  skillsCache = { baseUrl: normalizedBaseUrl, apiKey, fetchedAt: Date.now(), skills };
64
59
  return skills;
65
60
  }
66
61
 
62
+ // Only the Skill Hub creates skills from JSON. LiteLLM's POST /v1/skills is Anthropic's multipart
63
+ // Skills API, so a JSON code skill sent there never becomes a listable skill.
67
64
  export async function createSkill(
68
65
  baseUrl: string,
69
66
  apiKey: string,
70
- input: {
71
- name: string;
72
- description?: string;
73
- source?: Record<string, unknown>;
74
- code?: string;
75
- inputSchema?: Record<string, unknown>;
76
- },
67
+ input: { name: string; description?: string; source: Record<string, unknown> },
77
68
  headers?: Record<string, string>,
78
69
  allowInsecureHttp = false,
79
70
  ): Promise<unknown> {
80
- if (!input.source && !input.code) throw new Error("code is required when source is omitted");
71
+ if (!input.source) throw new Error("source is required: skills are created through the LiteLLM Skill Hub");
81
72
  const normalizedBaseUrl = normalizeBaseUrl(baseUrl, allowInsecureHttp);
82
- let usedSkillHub = input.source !== undefined;
83
- const skillHubPayload = input.source
84
- ? {
85
- name: input.name,
86
- description: input.description,
87
- source: input.source,
88
- }
89
- : {
90
- name: input.name,
91
- description: input.description,
92
- code: input.code,
93
- input_schema: input.inputSchema ?? { type: "object", properties: {} },
94
- };
95
- let response = await fetch(`${normalizedBaseUrl}${input.source ? "/claude-code/plugins" : "/v1/skills"}`, {
73
+ const response = await fetch(`${normalizedBaseUrl}/claude-code/plugins`, {
96
74
  method: "POST",
97
75
  headers: {
98
76
  ...headers,
99
77
  Authorization: `Bearer ${apiKey}`,
100
78
  "Content-Type": "application/json",
101
79
  },
102
- body: JSON.stringify(skillHubPayload),
80
+ body: JSON.stringify({ name: input.name, description: input.description, source: input.source }),
103
81
  signal: AbortSignal.timeout(10_000),
104
82
  });
105
- if (input.source && response.status === 404) {
106
- usedSkillHub = false;
107
- response = await fetch(`${normalizedBaseUrl}/v1/skills`, {
108
- method: "POST",
109
- headers: {
110
- ...headers,
111
- Authorization: `Bearer ${apiKey}`,
112
- "Content-Type": "application/json",
113
- },
114
- body: JSON.stringify({
115
- name: input.name,
116
- description: input.description,
117
- code: input.code,
118
- input_schema: input.inputSchema ?? { type: "object", properties: {} },
119
- }),
120
- signal: AbortSignal.timeout(10_000),
121
- });
122
- }
123
83
  if (!response.ok) throw new Error(`LiteLLM skill create failed: HTTP ${response.status}`);
124
84
  skillsCache = undefined;
125
- if (usedSkillHub) {
126
- const enableResponse = await fetch(
127
- `${normalizedBaseUrl}/claude-code/plugins/${encodeURIComponent(input.name)}/enable`,
128
- {
129
- method: "POST",
130
- headers: { ...headers, Authorization: `Bearer ${apiKey}` },
131
- signal: AbortSignal.timeout(10_000),
132
- },
133
- );
134
- if (!enableResponse.ok) throw new Error(`LiteLLM skill enable failed: HTTP ${enableResponse.status}`);
135
- }
85
+ const enableResponse = await fetch(
86
+ `${normalizedBaseUrl}/claude-code/plugins/${encodeURIComponent(input.name)}/enable`,
87
+ {
88
+ method: "POST",
89
+ headers: { ...headers, Authorization: `Bearer ${apiKey}` },
90
+ signal: AbortSignal.timeout(10_000),
91
+ },
92
+ );
93
+ if (!enableResponse.ok) throw new Error(`LiteLLM skill enable failed: HTTP ${enableResponse.status}`);
136
94
  return response.json().catch(() => ({}));
137
95
  }
138
96
 
@@ -196,9 +154,7 @@ function formatSkills(skills: LiteLLMSkill[]): string {
196
154
  const CreateSkillParams = Type.Object({
197
155
  name: Type.String({ description: "Skill name" }),
198
156
  description: Type.Optional(Type.String({ description: "Skill description" })),
199
- sourceJson: Type.Optional(Type.String({ description: "Optional Skill Hub source metadata JSON object" })),
200
- code: Type.Optional(Type.String({ description: "Legacy Skills Gateway implementation or prompt code" })),
201
- inputSchemaJson: Type.Optional(Type.String({ description: "Optional JSON Schema string for skill inputs" })),
157
+ sourceJson: Type.String({ description: "Skill Hub source metadata JSON object" }),
202
158
  });
203
159
 
204
160
  const DeleteSkillParams = Type.Object({
@@ -213,6 +169,10 @@ function parseJsonObject(value: string, fieldName: string): Record<string, unkno
213
169
  return parsed as Record<string, unknown>;
214
170
  }
215
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
+
216
176
  export function createSkillToolDefinitions(
217
177
  getAuth: (ctx?: ExtensionContext) => Promise<LiteLLMRuntimeAuth>,
218
178
  ): ToolDefinition[] {
@@ -222,6 +182,8 @@ export function createSkillToolDefinitions(
222
182
  label: "LiteLLM Skills",
223
183
  description: "List skills registered on the LiteLLM proxy Skills Gateway.",
224
184
  promptSnippet: "List LiteLLM Skills Gateway skills",
185
+ namespace: SKILLS_NAMESPACE,
186
+ annotations: { readOnlyHint: true, openWorldHint: false },
225
187
  parameters: Type.Object({}),
226
188
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
227
189
  const auth = await getAuth(ctx);
@@ -232,24 +194,17 @@ export function createSkillToolDefinitions(
232
194
  defineTool({
233
195
  name: "litellm_skill_create",
234
196
  label: "Create LiteLLM Skill",
235
- description: "Create a skill on the LiteLLM proxy Skills Gateway.",
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 },
236
200
  parameters: CreateSkillParams,
237
201
  async execute(_toolCallId, params: Static<typeof CreateSkillParams>, _signal, _onUpdate, ctx) {
238
202
  const auth = await getAuth(ctx);
239
- const source = params.sourceJson ? parseJsonObject(params.sourceJson, "sourceJson") : undefined;
240
- const inputSchema = params.inputSchemaJson
241
- ? parseJsonObject(params.inputSchemaJson, "inputSchemaJson")
242
- : undefined;
203
+ const source = parseJsonObject(params.sourceJson, "sourceJson");
243
204
  const result = await createSkill(
244
205
  auth.baseUrl,
245
206
  auth.apiKey,
246
- {
247
- name: params.name,
248
- description: params.description,
249
- source,
250
- code: params.code,
251
- inputSchema,
252
- },
207
+ { name: params.name, description: params.description, source },
253
208
  auth.headers,
254
209
  auth.allowInsecureHttp,
255
210
  );
@@ -260,6 +215,8 @@ export function createSkillToolDefinitions(
260
215
  name: "litellm_skill_delete",
261
216
  label: "Delete LiteLLM Skill",
262
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 },
263
220
  parameters: DeleteSkillParams,
264
221
  async execute(_toolCallId, params: Static<typeof DeleteSkillParams>, _signal, _onUpdate, ctx) {
265
222
  const auth = await getAuth(ctx);
package/src/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Model } from "@earendil-works/pi-ai";
2
2
  import type { BackendFamily, LITELLM_DISCOVERY_VERSION } from "./backend-identity.js";
3
+ import type { ProxyVersion } from "./proxy-version.js";
3
4
 
4
5
  export type DiscoverySource = "model_info" | "models_list" | "health";
5
6
 
@@ -56,6 +57,9 @@ export type LiteLLMModel = Model<LiteLLMApi> & {
56
57
  export interface DiscoveryResult {
57
58
  models: DiscoveredModel[];
58
59
  source: DiscoverySource;
60
+ // Present only when a `/model/info` discovery had to read it and the proxy
61
+ // answered. Absent means unknown, not old: discovery asks only when it must.
62
+ proxyVersion?: ProxyVersion;
59
63
  }
60
64
 
61
65
  export interface DiscoveryOptions {
@@ -138,6 +142,14 @@ export interface ResolvedCredentials {
138
142
  apiKeyFromGcloudAdc?: boolean;
139
143
  }
140
144
 
145
+ // The boolean MCP tool annotation hints, with the meaning Pi gives `ToolAnnotations`.
146
+ export interface McpToolAnnotations {
147
+ readOnlyHint?: boolean;
148
+ destructiveHint?: boolean;
149
+ idempotentHint?: boolean;
150
+ openWorldHint?: boolean;
151
+ }
152
+
141
153
  export interface LiteLLMMcpTool {
142
154
  name: string;
143
155
  server_name: string;
@@ -147,6 +159,10 @@ export interface LiteLLMMcpTool {
147
159
  input_schema: Record<string, unknown>;
148
160
  // True when the proxy supplied an `inputSchema`/`input_schema` that was not a JSON object.
149
161
  input_schema_malformed?: boolean;
162
+ // Boolean hints from the proxy-supplied `annotations`; every other annotation field is dropped.
163
+ annotations?: McpToolAnnotations;
164
+ // `mcp_info.description` of the server, when the proxy supplies one.
165
+ server_description?: string;
150
166
  }
151
167
 
152
168
  export interface LiteLLMSkill {