@danmademe/pi-provider-litellm 0.1.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 ADDED
@@ -0,0 +1,110 @@
1
+ # pi-provider-litellm
2
+
3
+ > [!NOTE]
4
+ > This extension requires [pi](https://github.com/earendil-works/pi) and a running [LiteLLM](https://github.com/BerriAI/litellm) proxy.
5
+
6
+ Pi agent extension that auto-discovers and registers models, MCP tools, and skills from a LiteLLM proxy.
7
+
8
+ ## Features
9
+
10
+ - **Model discovery** — queries `/health` and `/model/info` endpoints to discover available models, registers them under the `litellm` provider with cost, context window, and capability metadata
11
+ - **MCP tools** — discovers tools from LiteLLM's MCP REST gateway (`/mcp-rest/tools/list`) and registers them as pi tools with JSON schema to TypeBox mapping
12
+ - **Skills management** — `skill_list`, `skill_use`, `skill_register`, `skill_enable`, and `skill_disable` tools for managing LiteLLM skills
13
+ - **Skills injection** — injects enabled skills as structured context into the agent's system prompt on session start, with 60-second caching
14
+
15
+ All discovery runs in parallel with a 30s timeout. Each step fails gracefully — a failed model discovery won't block MCP tools or skills.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ # From npm (once published)
21
+ pi install npm:pi-provider-litellm
22
+
23
+ # From local path
24
+ pi install ~/Coding/Protector/AI/pi-provider-litellm
25
+ ```
26
+
27
+ ## Configuration
28
+
29
+ Provide your LiteLLM proxy URL and API key via environment variables or settings.
30
+
31
+ ### Environment variables (recommended)
32
+
33
+ ```bash
34
+ export LITELLM_URL="https://your-litellm-proxy.example.com"
35
+ export LITELLM_KEY="sk-your-api-key"
36
+ ```
37
+
38
+ Optional: override the provider name (defaults to `litellm`):
39
+
40
+ ```bash
41
+ export LITELLM_PROVIDER_ID="my-proxy"
42
+ ```
43
+
44
+ ### Settings file
45
+
46
+ Add to `~/.pi/agent/settings.json`:
47
+
48
+ ```json
49
+ {
50
+ "pi-provider-litellm": {
51
+ "url": "https://your-litellm-proxy.example.com",
52
+ "token": "sk-your-api-key"
53
+ }
54
+ }
55
+ ```
56
+
57
+ Optional: override the provider name:
58
+
59
+ ```json
60
+ {
61
+ "pi-provider-litellm": {
62
+ "url": "https://your-litellm-proxy.example.com",
63
+ "token": "sk-your-api-key",
64
+ "providerId": "my-proxy"
65
+ }
66
+ }
67
+ ```
68
+
69
+ Environment variables take precedence over the settings file.
70
+
71
+ ### Enabling models
72
+
73
+ Models registered by this extension use the configured provider ID (default: `litellm`). To include them in your model cycling, add them to `enabledModels` in `settings.json`:
74
+
75
+ ```json
76
+ {
77
+ "enabledModels": [
78
+ "litellm/qwen/qwen3.6-27b",
79
+ "litellm/anthropic/claude-sonnet"
80
+ ]
81
+ }
82
+ ```
83
+
84
+ > [!TIP]
85
+ > The model ID in pi is `<providerId>/<model_name>`, where `<providerId>` defaults to `litellm` and `<model_name>` is the ID as reported by your LiteLLM proxy (e.g., `qwen/qwen3.6-27b`, `anthropic/claude-sonnet`).
86
+
87
+ ## Tools
88
+
89
+ Once loaded, the extension registers the following tools:
90
+
91
+ | Tool | Description |
92
+ |------|-------------|
93
+ | `skill_list` | List all available skills on the proxy |
94
+ | `skill_use` | Fetch full content of a skill by name |
95
+ | `skill_register` | Register a new skill from a git repository |
96
+ | `skill_enable` | Enable a skill by name |
97
+ | `skill_disable` | Disable a skill by name |
98
+ | `mcp_<server>_<tool>` | Auto-discovered MCP tools from the proxy |
99
+
100
+ ## Development
101
+
102
+ ```bash
103
+ npm install
104
+ npm run typecheck
105
+ npm run test:run
106
+ ```
107
+
108
+ ## License
109
+
110
+ MIT
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@danmademe/pi-provider-litellm",
3
+ "version": "0.1.0",
4
+ "description": "Pi agent extension for LiteLLM proxy auto-discovery and model configuration",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "pi": {
14
+ "extensions": [
15
+ "./src/index.ts"
16
+ ]
17
+ },
18
+ "scripts": {
19
+ "typecheck": "tsc --noEmit",
20
+ "test": "vitest",
21
+ "test:run": "vitest run"
22
+ },
23
+ "keywords": [
24
+ "pi-package",
25
+ "pi",
26
+ "pi-agent",
27
+ "litellm",
28
+ "extension",
29
+ "auto-discovery"
30
+ ],
31
+ "peerDependencies": {
32
+ "@earendil-works/pi-coding-agent": "*",
33
+ "typebox": "*"
34
+ },
35
+ "devDependencies": {
36
+ "@earendil-works/pi-coding-agent": "*",
37
+ "@types/node": "^22.0.0",
38
+ "typescript": "^5.9.0",
39
+ "vitest": "^4.0.0"
40
+ },
41
+ "author": "Daniel Cherubini",
42
+ "license": "MIT"
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,90 @@
1
+ import type { ExtensionAPI, BeforeAgentStartEvent, BeforeAgentStartEventResult } from '@earendil-works/pi-coding-agent'
2
+ import { resolvePluginConfig, discoverModels, discoverMcpTools, listSkills, buildProviderConfig } from './litellm-api.js'
3
+ import { createMcpToolDefinitions, createSkillToolDefinitions, createSkillsInjector } from './tools.js'
4
+ import type { LiteLLMModelInfo, McpTool, PluginConfig } from './types.js'
5
+
6
+ export default async function (pi: ExtensionAPI): Promise<void> {
7
+ const config = resolvePluginConfig()
8
+ if (!config) {
9
+ return
10
+ }
11
+
12
+ await discoverAndRegister(pi, config)
13
+
14
+ const injector = createSkillsInjector(config, config.apiKey)
15
+ const setupCompleteSessions = new Set<string>()
16
+ pi.on('before_agent_start', async (event: BeforeAgentStartEvent, ctx): Promise<BeforeAgentStartEventResult> => {
17
+ const sessionId = ctx.sessionManager.getSessionFile()
18
+ if (sessionId && setupCompleteSessions.has(sessionId)) return {}
19
+ if (sessionId) setupCompleteSessions.add(sessionId)
20
+
21
+ const summary = await injector.getSkillsSummary()
22
+ if (!summary) return {}
23
+ return { systemPrompt: event.systemPrompt + '\n\n' + summary }
24
+ })
25
+
26
+ pi.on('session_start', async (_event, _ctx) => {
27
+ setupCompleteSessions.clear()
28
+ injector.clearCache()
29
+ await discoverAndRegister(pi, config)
30
+ })
31
+
32
+ pi.on('session_shutdown', async (_event, _ctx) => {
33
+ injector.clearCache()
34
+ })
35
+ }
36
+
37
+ export async function discoverAndRegister(pi: ExtensionAPI, config: PluginConfig): Promise<void> {
38
+ try {
39
+ pi.unregisterProvider(config.providerId)
40
+ } catch {
41
+ // Provider not yet registered
42
+ }
43
+
44
+ const DISCOVERY_TIMEOUT_MS = 30_000
45
+
46
+ let modelsResult: PromiseSettledResult<Record<string, LiteLLMModelInfo>>
47
+ let mcpResult: PromiseSettledResult<McpTool[]>
48
+
49
+ const timeoutPromise = new Promise<never>((_, reject) => {
50
+ setTimeout(() => reject(new Error('Discovery timeout')), DISCOVERY_TIMEOUT_MS)
51
+ })
52
+
53
+ try {
54
+ const results = await Promise.race([
55
+ Promise.allSettled([
56
+ discoverModels(config, config.apiKey),
57
+ discoverMcpTools(config, config.apiKey),
58
+ listSkills(config, config.apiKey),
59
+ ]),
60
+ timeoutPromise,
61
+ ])
62
+ const settledResults = results as [
63
+ PromiseSettledResult<Record<string, LiteLLMModelInfo>>,
64
+ PromiseSettledResult<McpTool[]>,
65
+ PromiseSettledResult<unknown>,
66
+ ]
67
+ modelsResult = settledResults[0]
68
+ mcpResult = settledResults[1]
69
+ } catch (error) {
70
+ modelsResult = { status: 'rejected', reason: error as Error }
71
+ mcpResult = { status: 'rejected', reason: error as Error }
72
+ }
73
+
74
+ if (modelsResult.status === 'fulfilled' && Object.keys(modelsResult.value).length > 0) {
75
+ const providerConfig = buildProviderConfig(config.url, config.apiKey, modelsResult.value)
76
+ pi.registerProvider(config.providerId, providerConfig)
77
+ }
78
+
79
+ if (mcpResult.status === 'fulfilled') {
80
+ const mcpTools = createMcpToolDefinitions(config, config.apiKey, mcpResult.value)
81
+ for (const tool of mcpTools) {
82
+ pi.registerTool(tool)
83
+ }
84
+ }
85
+
86
+ const skillTools = createSkillToolDefinitions(config, config.apiKey)
87
+ for (const tool of skillTools) {
88
+ pi.registerTool(tool)
89
+ }
90
+ }
@@ -0,0 +1,382 @@
1
+ import os from 'node:os'
2
+ import path from 'node:path'
3
+ import fs from 'node:fs'
4
+ import type {
5
+ LiteLLMHealthModel,
6
+ LiteLLMHealthResponse,
7
+ LiteLLMModelInfo,
8
+ McpTool,
9
+ Skill,
10
+ SkillPluginsResponse,
11
+ PluginConfig,
12
+ ProviderModelConfig,
13
+ ProviderConfig,
14
+ } from './types.js'
15
+
16
+ const DISCOVERY_TIMEOUT = 10_000
17
+ const TOOL_EXEC_TIMEOUT = 30_000
18
+ const SKILL_FETCH_TIMEOUT = 5_000
19
+
20
+ async function fetchJson<T>(url: string, timeout: number, options?: RequestInit): Promise<T | null> {
21
+ try {
22
+ const controller = new AbortController()
23
+ const timer = setTimeout(() => controller.abort(), timeout)
24
+
25
+ const res = await fetch(url, {
26
+ ...options,
27
+ signal: controller.signal,
28
+ })
29
+
30
+ clearTimeout(timer)
31
+
32
+ if (!res.ok) return null
33
+ return (await res.json()) as T
34
+ } catch {
35
+ return null
36
+ }
37
+ }
38
+
39
+ async function fetchJsonWithStatus<T>(url: string, timeout: number, options?: RequestInit): Promise<{ data: T | null, status: number }> {
40
+ try {
41
+ const controller = new AbortController()
42
+ const timer = setTimeout(() => controller.abort(), timeout)
43
+
44
+ const res = await fetch(url, {
45
+ ...options,
46
+ signal: controller.signal,
47
+ })
48
+
49
+ clearTimeout(timer)
50
+
51
+ if (!res.ok) return { data: null, status: res.status }
52
+ return { data: (await res.json()) as T, status: res.status }
53
+ } catch {
54
+ return { data: null, status: 0 }
55
+ }
56
+ }
57
+
58
+ export async function discoverModels(config: PluginConfig, token: string): Promise<Record<string, LiteLLMModelInfo>> {
59
+ const { data: healthRes, status } = await fetchJsonWithStatus<LiteLLMHealthResponse>(
60
+ `${config.url}/health`,
61
+ DISCOVERY_TIMEOUT,
62
+ { headers: { 'Authorization': `Bearer ${token}` } }
63
+ )
64
+
65
+ if (status === 403) {
66
+ throw new Error('Access denied (403). Check your LiteLLM API key or contact your admin.')
67
+ }
68
+
69
+ if (!healthRes || !healthRes.healthy_endpoints?.length) return {}
70
+
71
+ const infoMap: Record<string, LiteLLMModelInfo> = {}
72
+
73
+ const results = await Promise.allSettled(
74
+ healthRes.healthy_endpoints.map(async (endpoint: LiteLLMHealthModel) => {
75
+ const raw = await fetchJson<unknown>(
76
+ `${config.url}/model/info?litellm_model_id=${encodeURIComponent(endpoint.model_id)}`,
77
+ DISCOVERY_TIMEOUT,
78
+ { headers: { 'Authorization': `Bearer ${token}` } }
79
+ )
80
+ return { endpoint, raw }
81
+ })
82
+ )
83
+
84
+ for (const result of results) {
85
+ if (result.status !== 'fulfilled') continue
86
+ const { raw } = result.value
87
+ if (!raw || typeof raw !== 'object') continue
88
+
89
+ // /model/info returns { data: [{ model_name, model_info, litellm_params }] }
90
+ const data = (raw as { data?: unknown[] }).data
91
+ if (!Array.isArray(data) || !data.length) continue
92
+ const entry = data[0] as Record<string, unknown>
93
+
94
+ const modelName = typeof entry.model_name === 'string' ? entry.model_name : null
95
+ if (!modelName) continue
96
+
97
+ const modelInfo = (entry.model_info ?? {}) as Record<string, unknown>
98
+ const litellmParams = (entry.litellm_params ?? {}) as Record<string, unknown>
99
+
100
+ // Merge model_info and litellm_params into a flat LiteLLMModelInfo
101
+ const merged: Record<string, unknown> = {
102
+ model_name: modelName,
103
+ ...modelInfo,
104
+ ...litellmParams,
105
+ }
106
+
107
+ // Normalize max_tokens if not set from model_info
108
+ if (!merged.max_input_tokens && merged.max_tokens) {
109
+ merged.max_input_tokens = merged.max_tokens
110
+ }
111
+ if (!merged.max_output_tokens && merged.max_tokens) {
112
+ merged.max_output_tokens = merged.max_tokens
113
+ }
114
+
115
+ infoMap[modelName] = merged as LiteLLMModelInfo
116
+ }
117
+
118
+ return infoMap
119
+ }
120
+
121
+ export async function discoverMcpTools(config: PluginConfig, token: string): Promise<McpTool[]> {
122
+ const res = await fetchJson<unknown>(
123
+ `${config.url}/mcp-rest/tools/list`,
124
+ DISCOVERY_TIMEOUT,
125
+ { headers: { 'Authorization': `Bearer ${token}` } }
126
+ )
127
+ if (!Array.isArray(res)) return []
128
+ return res as McpTool[]
129
+ }
130
+
131
+ export async function executeMcpTool(
132
+ config: PluginConfig,
133
+ token: string,
134
+ server: string,
135
+ toolName: string,
136
+ args: Record<string, unknown>
137
+ ): Promise<string> {
138
+ try {
139
+ const controller = new AbortController()
140
+ const timer = setTimeout(() => controller.abort(), TOOL_EXEC_TIMEOUT)
141
+
142
+ const res = await fetch(`${config.url}/mcp-rest/tools/call`, {
143
+ method: 'POST',
144
+ headers: {
145
+ 'Authorization': `Bearer ${token}`,
146
+ 'Content-Type': 'application/json',
147
+ },
148
+ body: JSON.stringify({ server_name: server, tool_name: toolName, arguments: args }),
149
+ signal: controller.signal,
150
+ })
151
+
152
+ clearTimeout(timer)
153
+
154
+ if (!res.ok) {
155
+ return `Error: HTTP ${res.status} ${res.statusText}`
156
+ }
157
+
158
+ const data = await res.json()
159
+ return JSON.stringify(data)
160
+ } catch (err: unknown) {
161
+ return `Error: ${err instanceof Error ? err.message : String(err)}`
162
+ }
163
+ }
164
+
165
+ export async function listSkills(config: PluginConfig, token: string): Promise<Skill[]> {
166
+ const res = await fetchJson<SkillPluginsResponse>(
167
+ `${config.url}/claude-code/plugins`,
168
+ DISCOVERY_TIMEOUT,
169
+ { headers: { 'Authorization': `Bearer ${token}` } }
170
+ )
171
+ return res?.plugins || []
172
+ }
173
+
174
+ export async function registerSkill(
175
+ config: PluginConfig,
176
+ token: string,
177
+ name: string,
178
+ gitUrl: string,
179
+ gitPath: string,
180
+ description?: string,
181
+ domain?: string
182
+ ): Promise<string> {
183
+ try {
184
+ const controller = new AbortController()
185
+ const timer = setTimeout(() => controller.abort(), DISCOVERY_TIMEOUT)
186
+
187
+ const res = await fetch(`${config.url}/claude-code/plugins`, {
188
+ method: 'POST',
189
+ headers: {
190
+ 'Authorization': `Bearer ${token}`,
191
+ 'Content-Type': 'application/json',
192
+ },
193
+ body: JSON.stringify({
194
+ name,
195
+ git_url: gitUrl,
196
+ git_path: gitPath,
197
+ ...(description && { description }),
198
+ ...(domain && { domain }),
199
+ }),
200
+ signal: controller.signal,
201
+ })
202
+
203
+ clearTimeout(timer)
204
+
205
+ if (!res.ok) {
206
+ return `Error: HTTP ${res.status} ${res.statusText}`
207
+ }
208
+
209
+ const data = await res.json()
210
+ return JSON.stringify(data)
211
+ } catch (err: unknown) {
212
+ return `Error: ${err instanceof Error ? err.message : String(err)}`
213
+ }
214
+ }
215
+
216
+ export async function enableSkill(config: PluginConfig, token: string, name: string): Promise<string> {
217
+ try {
218
+ const controller = new AbortController()
219
+ const timer = setTimeout(() => controller.abort(), DISCOVERY_TIMEOUT)
220
+
221
+ const res = await fetch(`${config.url}/claude-code/plugins/${encodeURIComponent(name)}/enable`, {
222
+ method: 'POST',
223
+ headers: {
224
+ 'Authorization': `Bearer ${token}`,
225
+ },
226
+ signal: controller.signal,
227
+ })
228
+
229
+ clearTimeout(timer)
230
+
231
+ if (!res.ok) {
232
+ return `Error: HTTP ${res.status} ${res.statusText}`
233
+ }
234
+
235
+ const data = await res.json()
236
+ return JSON.stringify(data)
237
+ } catch (err: unknown) {
238
+ return `Error: ${err instanceof Error ? err.message : String(err)}`
239
+ }
240
+ }
241
+
242
+ export async function disableSkill(config: PluginConfig, token: string, name: string): Promise<string> {
243
+ try {
244
+ const controller = new AbortController()
245
+ const timer = setTimeout(() => controller.abort(), DISCOVERY_TIMEOUT)
246
+
247
+ const res = await fetch(`${config.url}/claude-code/plugins/${encodeURIComponent(name)}/disable`, {
248
+ method: 'POST',
249
+ headers: {
250
+ 'Authorization': `Bearer ${token}`,
251
+ },
252
+ signal: controller.signal,
253
+ })
254
+
255
+ clearTimeout(timer)
256
+
257
+ if (!res.ok) {
258
+ return `Error: HTTP ${res.status} ${res.statusText}`
259
+ }
260
+
261
+ const data = await res.json()
262
+ return JSON.stringify(data)
263
+ } catch (err: unknown) {
264
+ return `Error: ${err instanceof Error ? err.message : String(err)}`
265
+ }
266
+ }
267
+
268
+ export async function fetchSkillContent(skill: Skill): Promise<string | null> {
269
+ const { source } = skill
270
+ const { url, path } = source
271
+
272
+ // Build GitHub raw URL from git URL
273
+ let rawUrl: string | null = null
274
+
275
+ if (url.includes('github.com')) {
276
+ const gitMatch = url.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/)
277
+ if (gitMatch) {
278
+ const [, owner, repo] = gitMatch
279
+ const branch = 'main'
280
+ rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}${path ? '/' + path : ''}/SKILL.md`
281
+ }
282
+ }
283
+
284
+ if (!rawUrl) return null
285
+
286
+ // 2 retries on 429/5xx with exponential backoff
287
+ for (let attempt = 0; attempt < 3; attempt++) {
288
+ if (attempt > 0) {
289
+ const backoff = Math.min(500 * 2 ** (attempt - 1), 1000)
290
+ await new Promise(r => setTimeout(r, backoff))
291
+ }
292
+ try {
293
+ const controller = new AbortController()
294
+ const timer = setTimeout(() => controller.abort(), SKILL_FETCH_TIMEOUT)
295
+
296
+ const res = await fetch(rawUrl, { signal: controller.signal })
297
+ clearTimeout(timer)
298
+
299
+ if (res.ok) {
300
+ return await res.text()
301
+ }
302
+
303
+ // Retry on 429 or 5xx
304
+ if ((res.status === 429 || res.status >= 500) && attempt < 2) {
305
+ continue
306
+ }
307
+ return null
308
+ } catch {
309
+ if (attempt < 2) continue
310
+ return null
311
+ }
312
+ }
313
+
314
+ return null
315
+ }
316
+
317
+ export function resolvePluginConfig(): PluginConfig | null {
318
+ // Check env vars first
319
+ const envUrl = process.env.LITELLM_URL
320
+ const envKey = process.env.LITELLM_KEY
321
+
322
+ if (envUrl && envKey) {
323
+ return { url: envUrl, apiKey: envKey, providerId: process.env.LITELLM_PROVIDER_ID ?? 'litellm' }
324
+ }
325
+
326
+ // Check settings.json
327
+ try {
328
+ const settingsPath = path.join(os.homedir(), '.pi', 'agent', 'settings.json')
329
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as Record<string, unknown>
330
+
331
+ const providerSettings = settings['pi-provider-litellm'] as Record<string, string> | undefined
332
+
333
+ if (providerSettings?.url && providerSettings?.token) {
334
+ return { url: providerSettings.url, apiKey: providerSettings.token, providerId: providerSettings.providerId ?? 'litellm' }
335
+ }
336
+ } catch {
337
+ // settings.json not found or invalid
338
+ }
339
+
340
+ return null
341
+ }
342
+
343
+ export function mapToProviderModel(info: LiteLLMModelInfo): ProviderModelConfig {
344
+ const input: ('text' | 'image')[] = ['text']
345
+ if (info.supports_vision) {
346
+ input.push('image')
347
+ }
348
+
349
+ return {
350
+ id: info.model_name ?? '',
351
+ name: info.model_name ?? '',
352
+ reasoning: info.supports_reasoning ?? false,
353
+ input,
354
+ cost: {
355
+ input: info.input_cost_per_token ? info.input_cost_per_token * 1_000_000 : 0,
356
+ output: info.output_cost_per_token ? info.output_cost_per_token * 1_000_000 : 0,
357
+ cacheRead: info.cache_read_input_token_cost ? info.cache_read_input_token_cost * 1_000_000 : 0,
358
+ cacheWrite: info.cache_creation_input_token_cost ? info.cache_creation_input_token_cost * 1_000_000 : 0,
359
+ },
360
+ contextWindow: info.max_input_tokens ?? info.max_tokens ?? 0,
361
+ maxTokens: info.max_output_tokens ?? info.max_tokens ?? 0,
362
+ compat: {
363
+ supportsDeveloperRole: false,
364
+ supportsReasoningEffort: false,
365
+ },
366
+ }
367
+ }
368
+
369
+ export function buildProviderConfig(
370
+ url: string,
371
+ apiKey: string,
372
+ models: Record<string, LiteLLMModelInfo>
373
+ ): ProviderConfig {
374
+ const mappedModels = Object.values(models).map(mapToProviderModel)
375
+
376
+ return {
377
+ baseUrl: url,
378
+ apiKey,
379
+ api: 'openai-completions',
380
+ models: mappedModels,
381
+ }
382
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,309 @@
1
+ import { Type, type TSchema } from 'typebox'
2
+ import type {
3
+ ToolDefinition,
4
+ AgentToolResult,
5
+ AgentToolUpdateCallback,
6
+ ExtensionContext,
7
+ } from '@earendil-works/pi-coding-agent'
8
+ import type { McpTool, PluginConfig, Skill } from './types.js'
9
+ import {
10
+ executeMcpTool,
11
+ listSkills,
12
+ registerSkill,
13
+ enableSkill,
14
+ disableSkill,
15
+ fetchSkillContent,
16
+ } from './litellm-api.js'
17
+
18
+ export function sanitizeName(name: string): string {
19
+ return name.toLowerCase().replace(/[^a-z0-9]/g, '_')
20
+ }
21
+
22
+ function mapProperty(type: string, schema: Record<string, unknown>): TSchema | null {
23
+ if (schema.$ref != null) return null
24
+ if (schema.anyOf != null) return null
25
+ if (schema.oneOf != null) return null
26
+ if (schema.allOf != null) return null
27
+
28
+ // enum → Type.Union of literals
29
+ if (Array.isArray(schema.enum)) {
30
+ const valid = schema.enum.every((v) => typeof v === 'string' || typeof v === 'number')
31
+ if (!valid) return null
32
+ return Type.Union(schema.enum.map((v) => Type.Literal(v as string | number)))
33
+ }
34
+
35
+ // const → Type.Literal
36
+ if ('const' in schema && schema.const != null) {
37
+ const v = schema.const
38
+ if (typeof v !== 'string' && typeof v !== 'number') return null
39
+ return Type.Literal(v)
40
+ }
41
+
42
+ switch (type) {
43
+ case 'string': {
44
+ const opts: Record<string, unknown> = {}
45
+ if (typeof schema.minLength === 'number') opts.minLength = schema.minLength
46
+ if (typeof schema.maxLength === 'number') opts.maxLength = schema.maxLength
47
+ return Object.keys(opts).length ? Type.String(opts) : Type.String()
48
+ }
49
+ case 'number':
50
+ case 'integer': {
51
+ const opts: Record<string, unknown> = {}
52
+ if (typeof schema.minimum === 'number') opts.minimum = schema.minimum
53
+ if (typeof schema.maximum === 'number') opts.maximum = schema.maximum
54
+ return Object.keys(opts).length ? Type.Number(opts) : Type.Number()
55
+ }
56
+ case 'boolean':
57
+ return Type.Boolean()
58
+ case 'array': {
59
+ const items = schema.items as Record<string, unknown> | undefined
60
+ if (!items || typeof items !== 'object') return null
61
+ const itemType = items.type as string | undefined
62
+ if (itemType === 'string') return Type.Array(Type.String())
63
+ if (itemType === 'number' || itemType === 'integer') return Type.Array(Type.Number())
64
+ return null
65
+ }
66
+ default:
67
+ return null
68
+ }
69
+ }
70
+
71
+ export function buildTypeBoxSchema(inputSchema: Record<string, unknown>): TSchema | null {
72
+ const properties = inputSchema.properties as Record<string, unknown> | undefined
73
+ if (!properties || typeof properties !== 'object') return null
74
+
75
+ // Check for unsupported patterns at top level
76
+ if (inputSchema.$ref != null) return null
77
+ if (inputSchema.anyOf != null) return null
78
+ if (inputSchema.oneOf != null) return null
79
+ if (inputSchema.allOf != null) return null
80
+
81
+ const schemaProps: Record<string, TSchema> = {}
82
+ const required: string[] = []
83
+ const requiredList = inputSchema.required as string[] | undefined
84
+
85
+ for (const [key, val] of Object.entries(properties)) {
86
+ const prop = val as Record<string, unknown>
87
+ const propType = prop.type as string | undefined
88
+ if (!propType) return null
89
+
90
+ // Reject nested objects
91
+ if (propType === 'object') return null
92
+
93
+ // Check for nested $ref, anyOf, etc.
94
+ if (prop.$ref != null) return null
95
+ if (prop.anyOf != null) return null
96
+ if (prop.oneOf != null) return null
97
+ if (prop.allOf != null) return null
98
+
99
+ const mapped = mapProperty(propType, prop)
100
+ if (mapped === null) return null
101
+
102
+ schemaProps[key] = mapped
103
+ if (Array.isArray(requiredList) && requiredList.includes(key)) {
104
+ required.push(key)
105
+ }
106
+ }
107
+
108
+ if (required.length) {
109
+ return Type.Object(schemaProps, { required })
110
+ }
111
+
112
+ return Type.Object(schemaProps)
113
+ }
114
+
115
+ export function createMcpToolDefinitions(
116
+ config: PluginConfig,
117
+ token: string,
118
+ mcpTools: McpTool[],
119
+ ): ToolDefinition[] {
120
+ return mcpTools.map((tool) => {
121
+ const server = tool.server_name
122
+ const toolName = tool.name
123
+ const sanitizedName = `mcp_${sanitizeName(server)}_${sanitizeName(toolName)}`
124
+ const description = `${tool.description} (via ${server} MCP server)`
125
+ const parameters = buildTypeBoxSchema(tool.input_schema) ?? Type.Object({ args: Type.String() })
126
+
127
+ return {
128
+ name: sanitizedName,
129
+ label: sanitizedName,
130
+ description,
131
+ parameters,
132
+ async execute(
133
+ toolCallId: string,
134
+ params: Record<string, unknown>,
135
+ _signal: AbortSignal | undefined,
136
+ _onUpdate: AgentToolUpdateCallback<unknown> | undefined,
137
+ _ctx: ExtensionContext,
138
+ ): Promise<AgentToolResult<{ server: string; tool: string }>> {
139
+ // If using fallback args schema, parse it
140
+ const args = typeof params === 'object' && params !== null && 'args' in params
141
+ ? (typeof params.args === 'string' ? (() => { try { return JSON.parse(params.args) } catch { return params.args } })() : params.args)
142
+ : params
143
+
144
+ const result = await executeMcpTool(config, token, server, toolName, args as Record<string, unknown>)
145
+ return {
146
+ content: [{ type: 'text', text: result }],
147
+ details: { server, tool: toolName },
148
+ }
149
+ },
150
+ }
151
+ })
152
+ }
153
+
154
+ export function createSkillToolDefinitions(
155
+ config: PluginConfig,
156
+ token: string,
157
+ ): ToolDefinition[] {
158
+ return [
159
+ {
160
+ name: 'skill_list',
161
+ label: 'skill_list',
162
+ description: 'List all available skills',
163
+ parameters: Type.Object({}),
164
+ async execute(
165
+ _toolCallId: string,
166
+ _params: Record<string, unknown>,
167
+ _signal: AbortSignal | undefined,
168
+ _onUpdate: AgentToolUpdateCallback<unknown> | undefined,
169
+ _ctx: ExtensionContext,
170
+ ): Promise<AgentToolResult<undefined>> {
171
+ const skills = await listSkills(config, token)
172
+ if (!skills.length) {
173
+ return { content: [{ type: 'text', text: 'No skills found.' }], details: undefined }
174
+ }
175
+ const header = '| Name | Version | Enabled | Description |'
176
+ const sep = '|------|---------|---------|-------------|'
177
+ const rows = skills.map((s) =>
178
+ `| ${s.name} | ${s.version} | ${s.enabled ? 'yes' : 'no'} | ${s.description ?? '-'} |`,
179
+ )
180
+ return {
181
+ content: [{ type: 'text', text: [header, sep, ...rows].join('\n') }],
182
+ details: undefined,
183
+ }
184
+ },
185
+ },
186
+ {
187
+ name: 'skill_use',
188
+ label: 'skill_use',
189
+ description: 'Get the full content of a skill by name',
190
+ parameters: Type.Object({ name: Type.String() }),
191
+ async execute(
192
+ _toolCallId: string,
193
+ params: { name: string },
194
+ _signal: AbortSignal | undefined,
195
+ _onUpdate: AgentToolUpdateCallback<unknown> | undefined,
196
+ _ctx: ExtensionContext,
197
+ ): Promise<AgentToolResult<undefined>> {
198
+ const skills = await listSkills(config, token)
199
+ const skill = skills.find((s) => s.name === params.name)
200
+ if (!skill) {
201
+ return { content: [{ type: 'text', text: `Skill "${params.name}" not found.` }], details: undefined }
202
+ }
203
+ const content = await fetchSkillContent(skill)
204
+ const text = content
205
+ ? `<skill name="${skill.name}">\n${content}\n</skill>`
206
+ : `Skill "${skill.name}" found but content could not be fetched.`
207
+ return { content: [{ type: 'text', text }], details: undefined }
208
+ },
209
+ },
210
+ {
211
+ name: 'skill_register',
212
+ label: 'skill_register',
213
+ description: 'Register a new skill from a git repository',
214
+ parameters: Type.Object({
215
+ name: Type.String(),
216
+ git_url: Type.String(),
217
+ git_path: Type.String(),
218
+ description: Type.String(),
219
+ domain: Type.Optional(Type.String()),
220
+ }),
221
+ async execute(
222
+ _toolCallId: string,
223
+ params: { name: string; git_url: string; git_path: string; description: string; domain?: string },
224
+ _signal: AbortSignal | undefined,
225
+ _onUpdate: AgentToolUpdateCallback<unknown> | undefined,
226
+ _ctx: ExtensionContext,
227
+ ): Promise<AgentToolResult<undefined>> {
228
+ const result = await registerSkill(
229
+ config,
230
+ token,
231
+ params.name,
232
+ params.git_url,
233
+ params.git_path,
234
+ params.description,
235
+ params.domain,
236
+ )
237
+ return { content: [{ type: 'text', text: result }], details: undefined }
238
+ },
239
+ },
240
+ {
241
+ name: 'skill_enable',
242
+ label: 'skill_enable',
243
+ description: 'Enable a skill by name',
244
+ parameters: Type.Object({ name: Type.String() }),
245
+ async execute(
246
+ _toolCallId: string,
247
+ params: { name: string },
248
+ _signal: AbortSignal | undefined,
249
+ _onUpdate: AgentToolUpdateCallback<unknown> | undefined,
250
+ _ctx: ExtensionContext,
251
+ ): Promise<AgentToolResult<undefined>> {
252
+ const result = await enableSkill(config, token, params.name)
253
+ return { content: [{ type: 'text', text: result }], details: undefined }
254
+ },
255
+ },
256
+ {
257
+ name: 'skill_disable',
258
+ label: 'skill_disable',
259
+ description: 'Disable a skill by name',
260
+ parameters: Type.Object({ name: Type.String() }),
261
+ async execute(
262
+ _toolCallId: string,
263
+ params: { name: string },
264
+ _signal: AbortSignal | undefined,
265
+ _onUpdate: AgentToolUpdateCallback<unknown> | undefined,
266
+ _ctx: ExtensionContext,
267
+ ): Promise<AgentToolResult<undefined>> {
268
+ const result = await disableSkill(config, token, params.name)
269
+ return { content: [{ type: 'text', text: result }], details: undefined }
270
+ },
271
+ },
272
+ ]
273
+ }
274
+
275
+ export interface SkillsInjector {
276
+ getSkillsSummary(): Promise<string | null>
277
+ clearCache(): void
278
+ }
279
+
280
+ export function createSkillsInjector(
281
+ config: PluginConfig,
282
+ token: string,
283
+ ): SkillsInjector {
284
+ let cache: { skills: Skill[]; timestamp: number } | null = null
285
+ const TTL = 60_000 // 60 seconds
286
+
287
+ const getCachedSkills = async (): Promise<Skill[]> => {
288
+ const now = Date.now()
289
+ if (cache && now - cache.timestamp < TTL) {
290
+ return cache.skills
291
+ }
292
+ const skills = await listSkills(config, token)
293
+ cache = { skills, timestamp: now }
294
+ return skills
295
+ }
296
+
297
+ return {
298
+ async getSkillsSummary(): Promise<string | null> {
299
+ const skills = await getCachedSkills()
300
+ const enabled = skills.filter((s) => s.enabled)
301
+ if (!enabled.length) return null
302
+ const lines = enabled.map((s) => `- ${s.name}: ${s.description ?? '(no description)'}`)
303
+ return `<available-skills>\n${lines.join('\n')}\n</available-skills>`
304
+ },
305
+ clearCache(): void {
306
+ cache = null
307
+ },
308
+ }
309
+ }
package/src/types.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type { ProviderModelConfig, ProviderConfig } from '@earendil-works/pi-coding-agent'
2
+
3
+ export type { ProviderModelConfig, ProviderConfig }
4
+
5
+ // LiteLLM /health endpoint response
6
+ export interface LiteLLMHealthModel {
7
+ model: string
8
+ model_id: string
9
+ }
10
+
11
+ export interface LiteLLMHealthResponse {
12
+ healthy_endpoints?: LiteLLMHealthModel[]
13
+ }
14
+
15
+ // LiteLLM /model/info response
16
+ export interface LiteLLMModelInfo {
17
+ model_name?: string
18
+ max_tokens?: number
19
+ max_input_tokens?: number
20
+ max_output_tokens?: number
21
+ supports_function_calling?: boolean
22
+ supports_reasoning?: boolean
23
+ supports_vision?: boolean
24
+ supports_audio_input?: boolean
25
+ supports_pdf_input?: boolean
26
+ input_cost_per_token?: number
27
+ output_cost_per_token?: number
28
+ cache_read_input_token_cost?: number
29
+ cache_creation_input_token_cost?: number
30
+ }
31
+
32
+ // MCP tool from /mcp-rest/tools/list
33
+ export interface McpTool {
34
+ name: string
35
+ server_name: string
36
+ description: string
37
+ input_schema: Record<string, unknown>
38
+ }
39
+
40
+ // Skill types from /claude-code/plugins
41
+ export interface SkillSource {
42
+ source: string
43
+ url: string
44
+ path?: string
45
+ }
46
+
47
+ export interface Skill {
48
+ id: string
49
+ name: string
50
+ version: string
51
+ description: string | null
52
+ source: SkillSource
53
+ author: string | null
54
+ homepage: string | null
55
+ keywords: string | null
56
+ category: string | null
57
+ domain: string | null
58
+ namespace: string | null
59
+ enabled: boolean
60
+ created_at: string
61
+ updated_at: string
62
+ }
63
+
64
+ export interface SkillPluginsResponse {
65
+ plugins?: Skill[]
66
+ }
67
+
68
+ // Plugin config resolved from env or settings
69
+ export interface PluginConfig {
70
+ url: string
71
+ apiKey: string
72
+ providerId: string
73
+ }