@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 +110 -0
- package/package.json +43 -0
- package/src/index.ts +90 -0
- package/src/litellm-api.ts +382 -0
- package/src/tools.ts +309 -0
- package/src/types.ts +73 -0
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
|
+
}
|