opencode-provider-litellm 0.3.0 → 0.4.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 +35 -11
- package/package.json +1 -1
- package/src/plugin.test.ts +1 -61
- package/src/plugin.ts +16 -46
- package/src/types.ts +0 -8
- package/src/skills.test.ts +0 -449
- package/src/skills.ts +0 -240
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# opencode-provider-litellm
|
|
2
2
|
|
|
3
|
-
OpenCode plugin
|
|
3
|
+
OpenCode plugin for any [LiteLLM](https://github.com/BerriAI/litellm) proxy — auto-discovers models, MCP tools, and auth. Zero config, zero hand-maintained model lists.
|
|
4
4
|
|
|
5
5
|
## Quick start
|
|
6
6
|
|
|
@@ -15,7 +15,7 @@ opencode plugin opencode-provider-litellm
|
|
|
15
15
|
# 3. Restart OpenCode
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
All models from your LiteLLM proxy appear in OpenCode
|
|
18
|
+
All models and MCP tools from your LiteLLM proxy appear in OpenCode automatically.
|
|
19
19
|
|
|
20
20
|
## Configuration
|
|
21
21
|
|
|
@@ -56,21 +56,44 @@ You can also authenticate interactively via the OpenCode TUI:
|
|
|
56
56
|
|
|
57
57
|
The key is stored in OpenCode's auth store.
|
|
58
58
|
|
|
59
|
+
## Features
|
|
60
|
+
|
|
61
|
+
### Model discovery
|
|
62
|
+
|
|
63
|
+
Queries LiteLLM on startup and injects all models with rich metadata into OpenCode:
|
|
64
|
+
|
|
65
|
+
- `/health` — model list with internal UUIDs
|
|
66
|
+
- `/model/info?litellm_model_id={uuid}` — costs, context limits, vision, tool calling, reasoning, etc.
|
|
67
|
+
|
|
68
|
+
Custom `model_info` updates via `/model/update` are respected — no hardcoded fallbacks.
|
|
69
|
+
|
|
70
|
+
### MCP tools
|
|
71
|
+
|
|
72
|
+
Discovers tools registered on LiteLLM's MCP servers at startup and exposes them as native OpenCode tools. Each tool keeps its original description and parameter schema.
|
|
73
|
+
|
|
74
|
+
### Skills
|
|
75
|
+
|
|
76
|
+
Skills registered in LiteLLM's [Skills Gateway](https://docs.litellm.ai/docs/skills_gateway) are made available to OpenCode via the [proxy-sidecar](../llm-server/proxy-sidecar/), which serves skills in OpenCode's native format. Add the sidecar URL to your config:
|
|
77
|
+
|
|
78
|
+
```jsonc
|
|
79
|
+
{
|
|
80
|
+
"skills": {
|
|
81
|
+
"urls": ["https://your-litellm-proxy.example.com/opencode/skills"]
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Skills appear in OpenCode's `/skills` menu and are loaded natively by the agent.
|
|
87
|
+
|
|
59
88
|
## How it works
|
|
60
89
|
|
|
61
|
-
The plugin uses
|
|
90
|
+
The plugin uses three OpenCode hooks:
|
|
62
91
|
|
|
63
92
|
| Hook | Purpose |
|
|
64
93
|
|------|---------|
|
|
65
|
-
| `config` |
|
|
94
|
+
| `config` | Discovers models from LiteLLM and injects them into OpenCode |
|
|
66
95
|
| `auth` | Provides a `/connect` entry point for pasting an API key |
|
|
67
|
-
|
|
68
|
-
Model metadata is fetched from LiteLLM's admin API:
|
|
69
|
-
|
|
70
|
-
- `/health` — model list with internal UUIDs
|
|
71
|
-
- `/model/info?litellm_model_id={uuid}` — rich metadata per model (costs, context limits, vision, tool calling, reasoning, etc.)
|
|
72
|
-
|
|
73
|
-
Custom model_info updates via `/model/update` are respected — no hardcoded fallbacks.
|
|
96
|
+
| `tool` | Exposes discovered MCP tools as native OpenCode tools |
|
|
74
97
|
|
|
75
98
|
## Troubleshooting
|
|
76
99
|
|
|
@@ -79,6 +102,7 @@ Custom model_info updates via `/model/update` are respected — no hardcoded fal
|
|
|
79
102
|
| "Plugin config error" | Set `LITELLM_URL` and `LITELLM_KEY`, or add `url`/`apiKey` to plugin options |
|
|
80
103
|
| "Access denied" (403) | Verify the API key has access to the LiteLLM proxy |
|
|
81
104
|
| "No models discovered" | Check that the proxy is reachable and the `/health` endpoint responds |
|
|
105
|
+
| Skills not showing | Verify the proxy-sidecar is running and the skills URL is in `opencode.json` |
|
|
82
106
|
|
|
83
107
|
## Development
|
|
84
108
|
|
package/package.json
CHANGED
package/src/plugin.test.ts
CHANGED
|
@@ -20,17 +20,10 @@ vi.mock('./mcp-tools.js', () => ({
|
|
|
20
20
|
createMcpToolDefinitions: vi.fn(),
|
|
21
21
|
}))
|
|
22
22
|
|
|
23
|
-
// Mock the Skills module
|
|
24
|
-
vi.mock('./skills.js', () => ({
|
|
25
|
-
createSkillToolDefinitions: vi.fn(),
|
|
26
|
-
createSkillsInjector: vi.fn(),
|
|
27
|
-
}))
|
|
28
|
-
|
|
29
23
|
import { LiteLLMPlugin } from './plugin.js'
|
|
30
24
|
import { discoverModels, injectModelsIntoConfig } from './discovery.js'
|
|
31
25
|
import { resolvePluginConfig } from './utils.js'
|
|
32
26
|
import { createMcpToolDefinitions } from './mcp-tools.js'
|
|
33
|
-
import { createSkillToolDefinitions, createSkillsInjector } from './skills.js'
|
|
34
27
|
|
|
35
28
|
function createMockInput(): PluginInput {
|
|
36
29
|
const logFn = vi.fn().mockResolvedValue(true)
|
|
@@ -79,14 +72,6 @@ describe('LiteLLMPlugin', () => {
|
|
|
79
72
|
vi.mocked(createMcpToolDefinitions).mockResolvedValue({
|
|
80
73
|
mcp_test_server_test_tool: 'mock-mcp-tool',
|
|
81
74
|
})
|
|
82
|
-
// Default Skills mock: returns two tools
|
|
83
|
-
vi.mocked(createSkillToolDefinitions).mockReturnValue({
|
|
84
|
-
skill_list: 'mock-skill-list',
|
|
85
|
-
skill_create: 'mock-skill-create',
|
|
86
|
-
skill_delete: 'mock-skill-delete',
|
|
87
|
-
})
|
|
88
|
-
// Default Skills injector mock
|
|
89
|
-
vi.mocked(createSkillsInjector).mockReturnValue(vi.fn())
|
|
90
75
|
})
|
|
91
76
|
|
|
92
77
|
it('throws on missing config', async () => {
|
|
@@ -212,9 +197,6 @@ describe('LiteLLMPlugin', () => {
|
|
|
212
197
|
|
|
213
198
|
expect(hooks.tool).toBeDefined()
|
|
214
199
|
expect(hooks.tool).toHaveProperty('mcp_test_server_test_tool')
|
|
215
|
-
expect(hooks.tool).toHaveProperty('skill_list')
|
|
216
|
-
expect(hooks.tool).toHaveProperty('skill_create')
|
|
217
|
-
expect(hooks.tool).toHaveProperty('skill_delete')
|
|
218
200
|
})
|
|
219
201
|
|
|
220
202
|
it('tool hook passes correct config and apiKey to createMcpToolDefinitions', async () => {
|
|
@@ -229,41 +211,7 @@ describe('LiteLLMPlugin', () => {
|
|
|
229
211
|
)
|
|
230
212
|
})
|
|
231
213
|
|
|
232
|
-
it('
|
|
233
|
-
await LiteLLMPlugin(mockInput, {
|
|
234
|
-
url: 'https://litellm.example.com',
|
|
235
|
-
apiKey: 'test-api-key',
|
|
236
|
-
})
|
|
237
|
-
|
|
238
|
-
expect(createSkillToolDefinitions).toHaveBeenCalledWith(
|
|
239
|
-
{ url: 'https://litellm.example.com', apiKey: 'test-api-key' },
|
|
240
|
-
'test-api-key',
|
|
241
|
-
)
|
|
242
|
-
})
|
|
243
|
-
|
|
244
|
-
it('chat.message hook is defined', async () => {
|
|
245
|
-
const hooks = await LiteLLMPlugin(mockInput, {
|
|
246
|
-
url: 'https://litellm.example.com',
|
|
247
|
-
apiKey: 'test-api-key',
|
|
248
|
-
})
|
|
249
|
-
|
|
250
|
-
expect(hooks['chat.message']).toBeDefined()
|
|
251
|
-
expect(typeof hooks['chat.message']).toBe('function')
|
|
252
|
-
})
|
|
253
|
-
|
|
254
|
-
it('chat.message hook is created with correct config and apiKey', async () => {
|
|
255
|
-
await LiteLLMPlugin(mockInput, {
|
|
256
|
-
url: 'https://litellm.example.com',
|
|
257
|
-
apiKey: 'test-api-key',
|
|
258
|
-
})
|
|
259
|
-
|
|
260
|
-
expect(createSkillsInjector).toHaveBeenCalledWith(
|
|
261
|
-
{ url: 'https://litellm.example.com', apiKey: 'test-api-key' },
|
|
262
|
-
'test-api-key',
|
|
263
|
-
)
|
|
264
|
-
})
|
|
265
|
-
|
|
266
|
-
it('MCP discovery failure does not break the plugin — Skills tools still present', async () => {
|
|
214
|
+
it('MCP discovery failure does not break the plugin', async () => {
|
|
267
215
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
268
216
|
vi.mocked(createMcpToolDefinitions).mockRejectedValue(new Error('MCP server unavailable'))
|
|
269
217
|
|
|
@@ -278,10 +226,6 @@ describe('LiteLLMPlugin', () => {
|
|
|
278
226
|
|
|
279
227
|
// Skills tools should still be present
|
|
280
228
|
expect(hooks.tool).toBeDefined()
|
|
281
|
-
expect(hooks.tool).toHaveProperty('skill_list')
|
|
282
|
-
expect(hooks.tool).toHaveProperty('skill_create')
|
|
283
|
-
expect(hooks.tool).toHaveProperty('skill_delete')
|
|
284
|
-
|
|
285
229
|
// MCP tools should not be present (empty object merged)
|
|
286
230
|
expect(hooks.tool).not.toHaveProperty('mcp_test_server_test_tool')
|
|
287
231
|
|
|
@@ -300,10 +244,6 @@ describe('LiteLLMPlugin', () => {
|
|
|
300
244
|
const hooks = await LiteLLMPlugin(mockInput, undefined)
|
|
301
245
|
|
|
302
246
|
expect(hooks.tool).toBeDefined()
|
|
303
|
-
expect(hooks.tool).toHaveProperty('skill_list')
|
|
304
|
-
expect(hooks['chat.message']).toBeDefined()
|
|
305
|
-
expect(typeof hooks['chat.message']).toBe('function')
|
|
306
|
-
|
|
307
247
|
expect(createMcpToolDefinitions).toHaveBeenCalledWith(
|
|
308
248
|
{ url: 'https://env.litellm.example.com', apiKey: 'env-api-key' },
|
|
309
249
|
'env-api-key',
|
package/src/plugin.ts
CHANGED
|
@@ -2,17 +2,7 @@ import type { Plugin, PluginInput, PluginOptions } from '@opencode-ai/plugin'
|
|
|
2
2
|
import { resolvePluginConfig, getProviderId } from './utils.js'
|
|
3
3
|
import { discoverModels, injectModelsIntoConfig } from './discovery.js'
|
|
4
4
|
import { createMcpToolDefinitions } from './mcp-tools.js'
|
|
5
|
-
import { createSkillToolDefinitions, createSkillsInjector } from './skills.js'
|
|
6
5
|
|
|
7
|
-
/**
|
|
8
|
-
* Main plugin entry point for the LiteLLM provider.
|
|
9
|
-
*
|
|
10
|
-
* Wires together config (model discovery), auth (/connect API key flow),
|
|
11
|
-
* and chat.headers (per-request Bearer token injection).
|
|
12
|
-
*
|
|
13
|
-
* Auth: LITELLM_URL / LITELLM_KEY env vars take precedence,
|
|
14
|
-
* with fallback to values in opencode.json plugin options.
|
|
15
|
-
*/
|
|
16
6
|
export const LiteLLMPlugin: Plugin = async (
|
|
17
7
|
input: PluginInput,
|
|
18
8
|
options?: PluginOptions,
|
|
@@ -27,7 +17,6 @@ export const LiteLLMPlugin: Plugin = async (
|
|
|
27
17
|
|
|
28
18
|
const providerId = getProviderId()
|
|
29
19
|
|
|
30
|
-
// Discover MCP tools with graceful error handling
|
|
31
20
|
let mcpTools: Record<string, any> = {}
|
|
32
21
|
try {
|
|
33
22
|
mcpTools = await createMcpToolDefinitions(pluginConfig, pluginConfig.apiKey)
|
|
@@ -36,11 +25,7 @@ export const LiteLLMPlugin: Plugin = async (
|
|
|
36
25
|
}
|
|
37
26
|
|
|
38
27
|
return {
|
|
39
|
-
|
|
40
|
-
* Config hook — discovers models from the LiteLLM proxy and injects
|
|
41
|
-
* them into the OpenCode config under the provider.
|
|
42
|
-
*/
|
|
43
|
-
config: async (config) => {
|
|
28
|
+
config: async (config: Record<string, any>) => {
|
|
44
29
|
try {
|
|
45
30
|
const models = await discoverModels(
|
|
46
31
|
pluginConfig,
|
|
@@ -55,23 +40,22 @@ export const LiteLLMPlugin: Plugin = async (
|
|
|
55
40
|
message: 'No models discovered',
|
|
56
41
|
},
|
|
57
42
|
})
|
|
58
|
-
|
|
43
|
+
} else {
|
|
44
|
+
injectModelsIntoConfig(
|
|
45
|
+
config as Parameters<typeof injectModelsIntoConfig>[0],
|
|
46
|
+
providerId,
|
|
47
|
+
pluginConfig.url,
|
|
48
|
+
pluginConfig.apiKey,
|
|
49
|
+
models,
|
|
50
|
+
)
|
|
51
|
+
await input.client.app.log({
|
|
52
|
+
body: {
|
|
53
|
+
service: providerId,
|
|
54
|
+
level: 'info',
|
|
55
|
+
message: `Discovered ${Object.keys(models).length} models`,
|
|
56
|
+
},
|
|
57
|
+
})
|
|
59
58
|
}
|
|
60
|
-
|
|
61
|
-
injectModelsIntoConfig(
|
|
62
|
-
config as Parameters<typeof injectModelsIntoConfig>[0],
|
|
63
|
-
providerId,
|
|
64
|
-
pluginConfig.url,
|
|
65
|
-
pluginConfig.apiKey,
|
|
66
|
-
models,
|
|
67
|
-
)
|
|
68
|
-
await input.client.app.log({
|
|
69
|
-
body: {
|
|
70
|
-
service: providerId,
|
|
71
|
-
level: 'info',
|
|
72
|
-
message: `Discovered ${Object.keys(models).length} models`,
|
|
73
|
-
},
|
|
74
|
-
})
|
|
75
59
|
} catch (error) {
|
|
76
60
|
await input.client.app.log({
|
|
77
61
|
body: {
|
|
@@ -83,10 +67,6 @@ export const LiteLLMPlugin: Plugin = async (
|
|
|
83
67
|
}
|
|
84
68
|
},
|
|
85
69
|
|
|
86
|
-
/**
|
|
87
|
-
* Auth hook — lets the user paste an API key via the /connect flow.
|
|
88
|
-
* The key is stored in OpenCode's auth store and used as the Bearer token.
|
|
89
|
-
*/
|
|
90
70
|
auth: {
|
|
91
71
|
provider: providerId,
|
|
92
72
|
methods: [
|
|
@@ -111,18 +91,8 @@ export const LiteLLMPlugin: Plugin = async (
|
|
|
111
91
|
],
|
|
112
92
|
},
|
|
113
93
|
|
|
114
|
-
/**
|
|
115
|
-
* Tool hook — merges dynamically-discovered MCP tools with static
|
|
116
|
-
* Skills CRUD tools.
|
|
117
|
-
*/
|
|
118
94
|
tool: {
|
|
119
95
|
...mcpTools,
|
|
120
|
-
...createSkillToolDefinitions(pluginConfig, pluginConfig.apiKey),
|
|
121
96
|
},
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Chat message hook — injects active Skills as context into chat messages.
|
|
125
|
-
*/
|
|
126
|
-
"chat.message": createSkillsInjector(pluginConfig, pluginConfig.apiKey),
|
|
127
97
|
}
|
|
128
98
|
}
|
package/src/types.ts
CHANGED
|
@@ -15,14 +15,6 @@ export interface McpTool {
|
|
|
15
15
|
input_schema: Record<string, unknown>
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export interface Skill {
|
|
19
|
-
id: string
|
|
20
|
-
name: string
|
|
21
|
-
description: string
|
|
22
|
-
enabled?: boolean
|
|
23
|
-
[key: string]: unknown
|
|
24
|
-
}
|
|
25
|
-
|
|
26
18
|
export interface OpencodeModelConfig {
|
|
27
19
|
name: string
|
|
28
20
|
tool_call?: boolean
|
package/src/skills.test.ts
DELETED
|
@@ -1,449 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
-
import { tool } from '@opencode-ai/plugin'
|
|
3
|
-
import type { PluginConfig, Skill } from './types.js'
|
|
4
|
-
import {
|
|
5
|
-
listSkills,
|
|
6
|
-
createSkill,
|
|
7
|
-
deleteSkill,
|
|
8
|
-
createSkillToolDefinitions,
|
|
9
|
-
createSkillsInjector,
|
|
10
|
-
resetSkillsCache,
|
|
11
|
-
} from './skills.js'
|
|
12
|
-
|
|
13
|
-
describe('listSkills', () => {
|
|
14
|
-
const config: PluginConfig = {
|
|
15
|
-
url: 'https://litellm.example.com',
|
|
16
|
-
apiKey: 'test-api-key',
|
|
17
|
-
}
|
|
18
|
-
const token = 'test-token'
|
|
19
|
-
|
|
20
|
-
beforeEach(() => {
|
|
21
|
-
vi.restoreAllMocks()
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('returns skills from mock response', async () => {
|
|
25
|
-
const mockSkills: Skill[] = [
|
|
26
|
-
{ id: 'skill-1', name: 'code-review', description: 'Reviews code for best practices', enabled: true },
|
|
27
|
-
{ id: 'skill-2', name: 'security-scan', description: 'Scans for security issues', enabled: true },
|
|
28
|
-
]
|
|
29
|
-
|
|
30
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
31
|
-
ok: true,
|
|
32
|
-
status: 200,
|
|
33
|
-
json: async () => mockSkills,
|
|
34
|
-
})
|
|
35
|
-
vi.stubGlobal('fetch', mockFetch)
|
|
36
|
-
|
|
37
|
-
const result = await listSkills(config, token)
|
|
38
|
-
|
|
39
|
-
expect(result).toEqual(mockSkills)
|
|
40
|
-
expect(mockFetch).toHaveBeenCalledWith(
|
|
41
|
-
'https://litellm.example.com/v1/skills',
|
|
42
|
-
expect.objectContaining({
|
|
43
|
-
method: 'GET',
|
|
44
|
-
headers: expect.objectContaining({
|
|
45
|
-
Authorization: 'Bearer test-token',
|
|
46
|
-
}),
|
|
47
|
-
signal: expect.any(AbortSignal),
|
|
48
|
-
}),
|
|
49
|
-
)
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
it('returns [] on error', async () => {
|
|
53
|
-
const mockFetch = vi.fn().mockRejectedValue(new Error('network error'))
|
|
54
|
-
vi.stubGlobal('fetch', mockFetch)
|
|
55
|
-
|
|
56
|
-
const result = await listSkills(config, token)
|
|
57
|
-
expect(result).toEqual([])
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('returns [] on non-ok response', async () => {
|
|
61
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
62
|
-
ok: false,
|
|
63
|
-
status: 500,
|
|
64
|
-
})
|
|
65
|
-
vi.stubGlobal('fetch', mockFetch)
|
|
66
|
-
|
|
67
|
-
const result = await listSkills(config, token)
|
|
68
|
-
expect(result).toEqual([])
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
it('respects timeout (AbortError after 10s)', async () => {
|
|
72
|
-
vi.useFakeTimers()
|
|
73
|
-
|
|
74
|
-
const mockFetch = vi.fn().mockImplementation((_url: string, init: RequestInit) => {
|
|
75
|
-
return new Promise<Response>((_resolve, reject) => {
|
|
76
|
-
const signal = init?.signal
|
|
77
|
-
if (signal) {
|
|
78
|
-
signal.addEventListener('abort', () => {
|
|
79
|
-
reject(new DOMException('The operation was aborted.', 'AbortError'))
|
|
80
|
-
})
|
|
81
|
-
}
|
|
82
|
-
})
|
|
83
|
-
})
|
|
84
|
-
vi.stubGlobal('fetch', mockFetch)
|
|
85
|
-
|
|
86
|
-
const promise = listSkills(config, token)
|
|
87
|
-
|
|
88
|
-
await vi.advanceTimersByTimeAsync(10001)
|
|
89
|
-
|
|
90
|
-
const result = await promise
|
|
91
|
-
|
|
92
|
-
vi.useRealTimers()
|
|
93
|
-
|
|
94
|
-
expect(result).toEqual([])
|
|
95
|
-
})
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
describe('createSkill', () => {
|
|
99
|
-
const config: PluginConfig = {
|
|
100
|
-
url: 'https://litellm.example.com',
|
|
101
|
-
apiKey: 'test-api-key',
|
|
102
|
-
}
|
|
103
|
-
const token = 'test-token'
|
|
104
|
-
|
|
105
|
-
beforeEach(() => {
|
|
106
|
-
vi.restoreAllMocks()
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
it('returns success message', async () => {
|
|
110
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
111
|
-
ok: true,
|
|
112
|
-
status: 200,
|
|
113
|
-
json: async () => ({ id: 'skill-new-1', name: 'my-skill' }),
|
|
114
|
-
})
|
|
115
|
-
vi.stubGlobal('fetch', mockFetch)
|
|
116
|
-
|
|
117
|
-
const result = await createSkill(config, token, 'my-skill', 'A test skill')
|
|
118
|
-
|
|
119
|
-
expect(result).toBe('Skill "my-skill" created (id: skill-new-1)')
|
|
120
|
-
expect(mockFetch).toHaveBeenCalledWith(
|
|
121
|
-
'https://litellm.example.com/v1/skills',
|
|
122
|
-
expect.objectContaining({
|
|
123
|
-
method: 'POST',
|
|
124
|
-
headers: expect.objectContaining({
|
|
125
|
-
Authorization: 'Bearer test-token',
|
|
126
|
-
'Content-Type': 'application/json',
|
|
127
|
-
}),
|
|
128
|
-
body: JSON.stringify({ name: 'my-skill', description: 'A test skill', input_schema: undefined, code: undefined }),
|
|
129
|
-
signal: expect.any(AbortSignal),
|
|
130
|
-
}),
|
|
131
|
-
)
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
it('returns error string on failure', async () => {
|
|
135
|
-
const mockFetch = vi.fn().mockRejectedValue(new Error('connection refused'))
|
|
136
|
-
vi.stubGlobal('fetch', mockFetch)
|
|
137
|
-
|
|
138
|
-
const result = await createSkill(config, token, 'my-skill', 'A test skill')
|
|
139
|
-
|
|
140
|
-
expect(result).toBe('Error creating skill: connection refused')
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
it('returns error string on non-ok response', async () => {
|
|
144
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
145
|
-
ok: false,
|
|
146
|
-
status: 400,
|
|
147
|
-
})
|
|
148
|
-
vi.stubGlobal('fetch', mockFetch)
|
|
149
|
-
|
|
150
|
-
const result = await createSkill(config, token, 'my-skill', 'A test skill')
|
|
151
|
-
|
|
152
|
-
expect(result).toContain('Error creating skill')
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
it('includes input_schema and code when provided', async () => {
|
|
156
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
157
|
-
ok: true,
|
|
158
|
-
status: 200,
|
|
159
|
-
json: async () => ({ id: 'skill-3', name: 'complex-skill' }),
|
|
160
|
-
})
|
|
161
|
-
vi.stubGlobal('fetch', mockFetch)
|
|
162
|
-
|
|
163
|
-
await createSkill(
|
|
164
|
-
config,
|
|
165
|
-
token,
|
|
166
|
-
'complex-skill',
|
|
167
|
-
'A complex skill',
|
|
168
|
-
{ type: 'object', properties: { value: { type: 'string' } } },
|
|
169
|
-
'print("hello")',
|
|
170
|
-
)
|
|
171
|
-
|
|
172
|
-
expect(mockFetch).toHaveBeenCalledWith(
|
|
173
|
-
'https://litellm.example.com/v1/skills',
|
|
174
|
-
expect.objectContaining({
|
|
175
|
-
body: JSON.stringify({
|
|
176
|
-
name: 'complex-skill',
|
|
177
|
-
description: 'A complex skill',
|
|
178
|
-
input_schema: { type: 'object', properties: { value: { type: 'string' } } },
|
|
179
|
-
code: 'print("hello")',
|
|
180
|
-
}),
|
|
181
|
-
}),
|
|
182
|
-
)
|
|
183
|
-
})
|
|
184
|
-
})
|
|
185
|
-
|
|
186
|
-
describe('deleteSkill', () => {
|
|
187
|
-
const config: PluginConfig = {
|
|
188
|
-
url: 'https://litellm.example.com',
|
|
189
|
-
apiKey: 'test-api-key',
|
|
190
|
-
}
|
|
191
|
-
const token = 'test-token'
|
|
192
|
-
|
|
193
|
-
beforeEach(() => {
|
|
194
|
-
vi.restoreAllMocks()
|
|
195
|
-
})
|
|
196
|
-
|
|
197
|
-
it('returns success message', async () => {
|
|
198
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
199
|
-
ok: true,
|
|
200
|
-
status: 200,
|
|
201
|
-
})
|
|
202
|
-
vi.stubGlobal('fetch', mockFetch)
|
|
203
|
-
|
|
204
|
-
const result = await deleteSkill(config, token, 'skill-1')
|
|
205
|
-
|
|
206
|
-
expect(result).toBe('Skill "skill-1" deleted')
|
|
207
|
-
expect(mockFetch).toHaveBeenCalledWith(
|
|
208
|
-
'https://litellm.example.com/v1/skills/skill-1',
|
|
209
|
-
expect.objectContaining({
|
|
210
|
-
method: 'DELETE',
|
|
211
|
-
headers: expect.objectContaining({
|
|
212
|
-
Authorization: 'Bearer test-token',
|
|
213
|
-
}),
|
|
214
|
-
signal: expect.any(AbortSignal),
|
|
215
|
-
}),
|
|
216
|
-
)
|
|
217
|
-
})
|
|
218
|
-
|
|
219
|
-
it('returns error string on failure', async () => {
|
|
220
|
-
const mockFetch = vi.fn().mockRejectedValue(new Error('connection refused'))
|
|
221
|
-
vi.stubGlobal('fetch', mockFetch)
|
|
222
|
-
|
|
223
|
-
const result = await deleteSkill(config, token, 'skill-1')
|
|
224
|
-
|
|
225
|
-
expect(result).toBe('Error deleting skill: connection refused')
|
|
226
|
-
})
|
|
227
|
-
|
|
228
|
-
it('returns error string on non-ok response', async () => {
|
|
229
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
230
|
-
ok: false,
|
|
231
|
-
status: 404,
|
|
232
|
-
})
|
|
233
|
-
vi.stubGlobal('fetch', mockFetch)
|
|
234
|
-
|
|
235
|
-
const result = await deleteSkill(config, token, 'skill-1')
|
|
236
|
-
|
|
237
|
-
expect(result).toContain('Error deleting skill')
|
|
238
|
-
})
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
describe('createSkillToolDefinitions', () => {
|
|
242
|
-
const config: PluginConfig = {
|
|
243
|
-
url: 'https://litellm.example.com',
|
|
244
|
-
apiKey: 'test-api-key',
|
|
245
|
-
}
|
|
246
|
-
const token = 'test-token'
|
|
247
|
-
|
|
248
|
-
beforeEach(() => {
|
|
249
|
-
vi.restoreAllMocks()
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
it('returns 3 tools with correct names', () => {
|
|
253
|
-
const result = createSkillToolDefinitions(config, token)
|
|
254
|
-
|
|
255
|
-
expect(Object.keys(result)).toHaveLength(3)
|
|
256
|
-
expect(result).toHaveProperty('skill_list')
|
|
257
|
-
expect(result).toHaveProperty('skill_create')
|
|
258
|
-
expect(result).toHaveProperty('skill_delete')
|
|
259
|
-
})
|
|
260
|
-
|
|
261
|
-
it('skill_list has correct description', () => {
|
|
262
|
-
const result = createSkillToolDefinitions(config, token)
|
|
263
|
-
|
|
264
|
-
expect(result.skill_list.description).toBe('List all skills registered on the LiteLLM proxy')
|
|
265
|
-
})
|
|
266
|
-
|
|
267
|
-
it('skill_create has correct description', () => {
|
|
268
|
-
const result = createSkillToolDefinitions(config, token)
|
|
269
|
-
|
|
270
|
-
expect(result.skill_create.description).toBe('Create a new skill on the LiteLLM proxy')
|
|
271
|
-
})
|
|
272
|
-
|
|
273
|
-
it('skill_delete has correct description', () => {
|
|
274
|
-
const result = createSkillToolDefinitions(config, token)
|
|
275
|
-
|
|
276
|
-
expect(result.skill_delete.description).toBe('Delete a skill from the LiteLLM proxy')
|
|
277
|
-
})
|
|
278
|
-
|
|
279
|
-
it('skill_list execute returns formatted markdown table', async () => {
|
|
280
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
281
|
-
ok: true,
|
|
282
|
-
status: 200,
|
|
283
|
-
json: async () => [
|
|
284
|
-
{ id: 'skill-1', name: 'code-review', description: 'Reviews code', enabled: true },
|
|
285
|
-
{ id: 'skill-2', name: 'security-scan', description: 'Scans security', enabled: false },
|
|
286
|
-
],
|
|
287
|
-
})
|
|
288
|
-
vi.stubGlobal('fetch', mockFetch)
|
|
289
|
-
|
|
290
|
-
const result = createSkillToolDefinitions(config, token)
|
|
291
|
-
|
|
292
|
-
const output = await result.skill_list.execute({}, {} as any)
|
|
293
|
-
|
|
294
|
-
expect(output).toContain('code-review')
|
|
295
|
-
expect(output).toContain('security-scan')
|
|
296
|
-
expect(output).toContain('Reviews code')
|
|
297
|
-
expect(output).toContain('Scans security')
|
|
298
|
-
})
|
|
299
|
-
|
|
300
|
-
it('skill_create execute calls createSkill', async () => {
|
|
301
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
302
|
-
ok: true,
|
|
303
|
-
status: 200,
|
|
304
|
-
json: async () => ({ id: 'skill-new', name: 'new-skill' }),
|
|
305
|
-
})
|
|
306
|
-
vi.stubGlobal('fetch', mockFetch)
|
|
307
|
-
|
|
308
|
-
const result = createSkillToolDefinitions(config, token)
|
|
309
|
-
|
|
310
|
-
const output = await result.skill_create.execute(
|
|
311
|
-
{ name: 'new-skill', description: 'A new skill' },
|
|
312
|
-
{} as any,
|
|
313
|
-
)
|
|
314
|
-
|
|
315
|
-
expect(output).toBe('Skill "new-skill" created (id: skill-new)')
|
|
316
|
-
})
|
|
317
|
-
|
|
318
|
-
it('skill_delete execute calls deleteSkill', async () => {
|
|
319
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
320
|
-
ok: true,
|
|
321
|
-
status: 200,
|
|
322
|
-
})
|
|
323
|
-
vi.stubGlobal('fetch', mockFetch)
|
|
324
|
-
|
|
325
|
-
const result = createSkillToolDefinitions(config, token)
|
|
326
|
-
|
|
327
|
-
const output = await result.skill_delete.execute(
|
|
328
|
-
{ skill_id: 'skill-to-delete' },
|
|
329
|
-
{} as any,
|
|
330
|
-
)
|
|
331
|
-
|
|
332
|
-
expect(output).toBe('Skill "skill-to-delete" deleted')
|
|
333
|
-
})
|
|
334
|
-
})
|
|
335
|
-
|
|
336
|
-
describe('createSkillsInjector', () => {
|
|
337
|
-
const config: PluginConfig = {
|
|
338
|
-
url: 'https://litellm.example.com',
|
|
339
|
-
apiKey: 'test-api-key',
|
|
340
|
-
}
|
|
341
|
-
const token = 'test-token'
|
|
342
|
-
|
|
343
|
-
beforeEach(() => {
|
|
344
|
-
vi.restoreAllMocks()
|
|
345
|
-
resetSkillsCache()
|
|
346
|
-
})
|
|
347
|
-
|
|
348
|
-
it('injects skills as text parts', async () => {
|
|
349
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
350
|
-
ok: true,
|
|
351
|
-
status: 200,
|
|
352
|
-
json: async () => [
|
|
353
|
-
{ id: 'skill-1', name: 'code-review', description: 'Reviews code', enabled: true },
|
|
354
|
-
{ id: 'skill-2', name: 'security-scan', description: 'Scans security' },
|
|
355
|
-
],
|
|
356
|
-
})
|
|
357
|
-
vi.stubGlobal('fetch', mockFetch)
|
|
358
|
-
|
|
359
|
-
const injector = createSkillsInjector(config, token)
|
|
360
|
-
|
|
361
|
-
const input = { sessionID: 'main-session' }
|
|
362
|
-
const output: { message: any; parts: Array<{ type: string; text: string }> } = { message: { content: 'Hello' }, parts: [] }
|
|
363
|
-
|
|
364
|
-
await injector(input, output)
|
|
365
|
-
|
|
366
|
-
expect(output.parts).toHaveLength(1)
|
|
367
|
-
expect(output.parts[0].type).toBe('text')
|
|
368
|
-
expect(output.parts[0].text).toContain('<skill name="code-review">Reviews code</skill>')
|
|
369
|
-
expect(output.parts[0].text).toContain('<skill name="security-scan">Scans security</skill>')
|
|
370
|
-
})
|
|
371
|
-
|
|
372
|
-
it('skips ALL sub-agent sessions (returns when input.agent is truthy)', async () => {
|
|
373
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
374
|
-
ok: true,
|
|
375
|
-
status: 200,
|
|
376
|
-
json: async () => [
|
|
377
|
-
{ id: 'skill-1', name: 'code-review', description: 'Reviews code', enabled: true },
|
|
378
|
-
],
|
|
379
|
-
})
|
|
380
|
-
vi.stubGlobal('fetch', mockFetch)
|
|
381
|
-
|
|
382
|
-
const injector = createSkillsInjector(config, token)
|
|
383
|
-
|
|
384
|
-
const input = { sessionID: 'main-session', agent: 'sub-agent-1' }
|
|
385
|
-
const output = { message: { content: 'Hello' }, parts: [] }
|
|
386
|
-
|
|
387
|
-
await injector(input, output)
|
|
388
|
-
|
|
389
|
-
// Should not have injected anything — sub-agent sessions are skipped
|
|
390
|
-
expect(output.parts).toEqual([])
|
|
391
|
-
// Should not have called fetch since it returns early
|
|
392
|
-
expect(mockFetch).not.toHaveBeenCalled()
|
|
393
|
-
})
|
|
394
|
-
|
|
395
|
-
it('skips when no enabled skills', async () => {
|
|
396
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
397
|
-
ok: true,
|
|
398
|
-
status: 200,
|
|
399
|
-
json: async () => [
|
|
400
|
-
{ id: 'skill-1', name: 'disabled-skill', description: 'Disabled', enabled: false },
|
|
401
|
-
],
|
|
402
|
-
})
|
|
403
|
-
vi.stubGlobal('fetch', mockFetch)
|
|
404
|
-
|
|
405
|
-
const injector = createSkillsInjector(config, token)
|
|
406
|
-
|
|
407
|
-
const input = { sessionID: 'main-session' }
|
|
408
|
-
const output = { message: { content: 'Hello' }, parts: [] }
|
|
409
|
-
|
|
410
|
-
await injector(input, output)
|
|
411
|
-
|
|
412
|
-
expect(output.parts).toEqual([])
|
|
413
|
-
})
|
|
414
|
-
|
|
415
|
-
it('cache TTL works (second call within TTL uses cache)', async () => {
|
|
416
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
417
|
-
ok: true,
|
|
418
|
-
status: 200,
|
|
419
|
-
json: async () => [
|
|
420
|
-
{ id: 'skill-1', name: 'cached-skill', description: 'Cached', enabled: true },
|
|
421
|
-
],
|
|
422
|
-
})
|
|
423
|
-
vi.stubGlobal('fetch', mockFetch)
|
|
424
|
-
|
|
425
|
-
const injector = createSkillsInjector(config, token)
|
|
426
|
-
|
|
427
|
-
// First call — should fetch
|
|
428
|
-
await injector({ sessionID: 'session-1' }, { message: {}, parts: [] })
|
|
429
|
-
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
430
|
-
|
|
431
|
-
// Second call — should use cache (fetch not called again)
|
|
432
|
-
await injector({ sessionID: 'session-2' }, { message: {}, parts: [] })
|
|
433
|
-
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
434
|
-
})
|
|
435
|
-
|
|
436
|
-
it('silently skips on fetch failure', async () => {
|
|
437
|
-
const mockFetch = vi.fn().mockRejectedValue(new Error('network error'))
|
|
438
|
-
vi.stubGlobal('fetch', mockFetch)
|
|
439
|
-
|
|
440
|
-
const injector = createSkillsInjector(config, token)
|
|
441
|
-
|
|
442
|
-
const input = { sessionID: 'main-session' }
|
|
443
|
-
const output = { message: { content: 'Hello' }, parts: [] }
|
|
444
|
-
|
|
445
|
-
// Should not throw
|
|
446
|
-
await expect(injector(input, output)).resolves.toBeUndefined()
|
|
447
|
-
expect(output.parts).toEqual([])
|
|
448
|
-
})
|
|
449
|
-
})
|
package/src/skills.ts
DELETED
|
@@ -1,240 +0,0 @@
|
|
|
1
|
-
import { tool } from '@opencode-ai/plugin'
|
|
2
|
-
import type { PluginConfig, Skill } from './types.js'
|
|
3
|
-
|
|
4
|
-
interface CacheEntry<T> {
|
|
5
|
-
data: T
|
|
6
|
-
timestamp: number
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
let skillsCache: CacheEntry<Skill[]> | null = null
|
|
10
|
-
const CACHE_TTL_MS = 60_000
|
|
11
|
-
|
|
12
|
-
/** Reset the skills cache. Used for testing. */
|
|
13
|
-
export function resetSkillsCache(): void {
|
|
14
|
-
skillsCache = null
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Fetches all skills from the LiteLLM proxy.
|
|
19
|
-
* Returns an empty array on any error (network, 4xx, 5xx, parse failure).
|
|
20
|
-
* Uses a 10s timeout via AbortController.
|
|
21
|
-
*/
|
|
22
|
-
export async function listSkills(
|
|
23
|
-
config: PluginConfig,
|
|
24
|
-
token: string,
|
|
25
|
-
): Promise<Skill[]> {
|
|
26
|
-
const controller = new AbortController()
|
|
27
|
-
const timeoutId = setTimeout(() => controller.abort(), 10_000)
|
|
28
|
-
|
|
29
|
-
try {
|
|
30
|
-
const response = await fetch(`${config.url}/v1/skills`, {
|
|
31
|
-
method: 'GET',
|
|
32
|
-
headers: {
|
|
33
|
-
Authorization: `Bearer ${token}`,
|
|
34
|
-
},
|
|
35
|
-
signal: controller.signal,
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
if (!response.ok) {
|
|
39
|
-
return []
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const body = await response.json()
|
|
43
|
-
|
|
44
|
-
if (!Array.isArray(body)) {
|
|
45
|
-
return []
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return body as Skill[]
|
|
49
|
-
} catch {
|
|
50
|
-
return []
|
|
51
|
-
} finally {
|
|
52
|
-
clearTimeout(timeoutId)
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Creates a new skill on the LiteLLM proxy.
|
|
58
|
-
* Returns a success message string on success, error string on failure.
|
|
59
|
-
* Uses a 10s timeout via AbortController.
|
|
60
|
-
*/
|
|
61
|
-
export async function createSkill(
|
|
62
|
-
config: PluginConfig,
|
|
63
|
-
token: string,
|
|
64
|
-
name: string,
|
|
65
|
-
description: string,
|
|
66
|
-
inputSchema?: Record<string, unknown>,
|
|
67
|
-
code?: string,
|
|
68
|
-
): Promise<string> {
|
|
69
|
-
const controller = new AbortController()
|
|
70
|
-
const timeoutId = setTimeout(() => controller.abort(), 10_000)
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
const response = await fetch(`${config.url}/v1/skills`, {
|
|
74
|
-
method: 'POST',
|
|
75
|
-
headers: {
|
|
76
|
-
Authorization: `Bearer ${token}`,
|
|
77
|
-
'Content-Type': 'application/json',
|
|
78
|
-
},
|
|
79
|
-
body: JSON.stringify({
|
|
80
|
-
name,
|
|
81
|
-
description,
|
|
82
|
-
input_schema: inputSchema,
|
|
83
|
-
code,
|
|
84
|
-
}),
|
|
85
|
-
signal: controller.signal,
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
if (!response.ok) {
|
|
89
|
-
return `Error creating skill: HTTP ${response.status}`
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const body = await response.json()
|
|
93
|
-
const id = body.id ?? 'unknown'
|
|
94
|
-
return `Skill "${name}" created (id: ${id})`
|
|
95
|
-
} catch (error: unknown) {
|
|
96
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
97
|
-
return `Error creating skill: ${message}`
|
|
98
|
-
} finally {
|
|
99
|
-
clearTimeout(timeoutId)
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Deletes a skill from the LiteLLM proxy.
|
|
105
|
-
* Returns a success message string on success, error string on failure.
|
|
106
|
-
* Uses a 10s timeout via AbortController.
|
|
107
|
-
*/
|
|
108
|
-
export async function deleteSkill(
|
|
109
|
-
config: PluginConfig,
|
|
110
|
-
token: string,
|
|
111
|
-
skillId: string,
|
|
112
|
-
): Promise<string> {
|
|
113
|
-
const controller = new AbortController()
|
|
114
|
-
const timeoutId = setTimeout(() => controller.abort(), 10_000)
|
|
115
|
-
|
|
116
|
-
try {
|
|
117
|
-
const response = await fetch(`${config.url}/v1/skills/${skillId}`, {
|
|
118
|
-
method: 'DELETE',
|
|
119
|
-
headers: {
|
|
120
|
-
Authorization: `Bearer ${token}`,
|
|
121
|
-
},
|
|
122
|
-
signal: controller.signal,
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
if (!response.ok) {
|
|
126
|
-
return `Error deleting skill: HTTP ${response.status}`
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return `Skill "${skillId}" deleted`
|
|
130
|
-
} catch (error: unknown) {
|
|
131
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
132
|
-
return `Error deleting skill: ${message}`
|
|
133
|
-
} finally {
|
|
134
|
-
clearTimeout(timeoutId)
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Creates opencode tool definitions for skill CRUD operations.
|
|
140
|
-
* Returns a static Record with three tools: skill_list, skill_create, skill_delete.
|
|
141
|
-
*/
|
|
142
|
-
export function createSkillToolDefinitions(
|
|
143
|
-
config: PluginConfig,
|
|
144
|
-
token: string,
|
|
145
|
-
): Record<string, any> {
|
|
146
|
-
return {
|
|
147
|
-
skill_list: tool({
|
|
148
|
-
description: 'List all skills registered on the LiteLLM proxy',
|
|
149
|
-
args: {},
|
|
150
|
-
async execute(_args: Record<string, unknown>, _context: unknown): Promise<string> {
|
|
151
|
-
const skills = await listSkills(config, token)
|
|
152
|
-
|
|
153
|
-
if (skills.length === 0) {
|
|
154
|
-
return 'No skills found.'
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const header = '| ID | Name | Description | Enabled |'
|
|
158
|
-
const sep = '|------|------|-------------|---------|'
|
|
159
|
-
const rows = skills
|
|
160
|
-
.map(
|
|
161
|
-
(s) =>
|
|
162
|
-
`| ${s.id} | ${s.name} | ${s.description} | ${s.enabled !== false ? 'yes' : 'no'} |`,
|
|
163
|
-
)
|
|
164
|
-
.join('\n')
|
|
165
|
-
|
|
166
|
-
return [header, sep, ...rows.split('\n')].join('\n')
|
|
167
|
-
},
|
|
168
|
-
}),
|
|
169
|
-
|
|
170
|
-
skill_create: tool({
|
|
171
|
-
description: 'Create a new skill on the LiteLLM proxy',
|
|
172
|
-
args: {
|
|
173
|
-
name: tool.schema.string().describe('Name of the skill'),
|
|
174
|
-
description: tool.schema.string().describe('Description of the skill'),
|
|
175
|
-
input_schema: tool.schema
|
|
176
|
-
.object({})
|
|
177
|
-
.passthrough()
|
|
178
|
-
.optional()
|
|
179
|
-
.describe('Input schema for the skill'),
|
|
180
|
-
code: tool.schema.string().optional().describe('Code for the skill'),
|
|
181
|
-
},
|
|
182
|
-
async execute(args: Record<string, unknown>, _context: unknown): Promise<string> {
|
|
183
|
-
return createSkill(
|
|
184
|
-
config,
|
|
185
|
-
token,
|
|
186
|
-
args.name as string,
|
|
187
|
-
args.description as string,
|
|
188
|
-
args.input_schema as Record<string, unknown> | undefined,
|
|
189
|
-
args.code as string | undefined,
|
|
190
|
-
)
|
|
191
|
-
},
|
|
192
|
-
}),
|
|
193
|
-
|
|
194
|
-
skill_delete: tool({
|
|
195
|
-
description: 'Delete a skill from the LiteLLM proxy',
|
|
196
|
-
args: {
|
|
197
|
-
skill_id: tool.schema.string().describe('ID of the skill to delete'),
|
|
198
|
-
},
|
|
199
|
-
async execute(args: Record<string, unknown>, _context: unknown): Promise<string> {
|
|
200
|
-
return deleteSkill(config, token, args.skill_id as string)
|
|
201
|
-
},
|
|
202
|
-
}),
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Creates a chat.message hook that injects active skills as context.
|
|
208
|
-
* Uses in-memory cache with 60s TTL to avoid hammering the API.
|
|
209
|
-
* Only injects for main agent sessions — skips all sub-agents.
|
|
210
|
-
*/
|
|
211
|
-
export function createSkillsInjector(
|
|
212
|
-
config: PluginConfig,
|
|
213
|
-
token: string,
|
|
214
|
-
): (
|
|
215
|
-
input: { sessionID: string; agent?: string; model?: any; messageID?: string; variant?: string },
|
|
216
|
-
output: { message: any; parts: any[] },
|
|
217
|
-
) => Promise<void> {
|
|
218
|
-
return async (input, output) => {
|
|
219
|
-
// Only inject for main agent session — skip ALL sub-agents
|
|
220
|
-
if (input.agent) return
|
|
221
|
-
|
|
222
|
-
// Fetch skills with simple in-memory cache
|
|
223
|
-
let skills: Skill[] = []
|
|
224
|
-
if (skillsCache && Date.now() - skillsCache.timestamp < CACHE_TTL_MS) {
|
|
225
|
-
skills = skillsCache.data
|
|
226
|
-
} else {
|
|
227
|
-
skills = await listSkills(config, token)
|
|
228
|
-
skillsCache = { data: skills, timestamp: Date.now() }
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const enabledSkills = skills.filter((s) => s.enabled !== false)
|
|
232
|
-
if (enabledSkills.length === 0) return
|
|
233
|
-
|
|
234
|
-
const context = enabledSkills
|
|
235
|
-
.map((s) => `<skill name="${s.name}">${s.description}</skill>`)
|
|
236
|
-
.join('\n')
|
|
237
|
-
|
|
238
|
-
output.parts.push({ type: 'text', text: context })
|
|
239
|
-
}
|
|
240
|
-
}
|