opencode-provider-litellm 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/mcp-tools.test.ts +570 -0
- package/src/mcp-tools.ts +253 -0
- package/src/plugin.test.ts +135 -0
- package/src/plugin.ts +24 -0
- package/src/skills.test.ts +449 -0
- package/src/skills.ts +240 -0
- package/src/types.ts +15 -0
|
@@ -0,0 +1,449 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
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
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -8,6 +8,21 @@ export interface LiteLLMModel {
|
|
|
8
8
|
max_model_len?: number
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export interface McpTool {
|
|
12
|
+
name: string
|
|
13
|
+
server_name: string
|
|
14
|
+
description: string
|
|
15
|
+
input_schema: Record<string, unknown>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Skill {
|
|
19
|
+
id: string
|
|
20
|
+
name: string
|
|
21
|
+
description: string
|
|
22
|
+
enabled?: boolean
|
|
23
|
+
[key: string]: unknown
|
|
24
|
+
}
|
|
25
|
+
|
|
11
26
|
export interface OpencodeModelConfig {
|
|
12
27
|
name: string
|
|
13
28
|
tool_call?: boolean
|