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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # opencode-provider-litellm
2
2
 
3
- OpenCode plugin that auto-discovers models from any [LiteLLM](https://github.com/BerriAI/litellm) proxy — complete with costs, context limits, capabilities, and auth. Zero config, zero hand-maintained model lists.
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's model picker automatically.
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 two OpenCode hooks:
90
+ The plugin uses three OpenCode hooks:
62
91
 
63
92
  | Hook | Purpose |
64
93
  |------|---------|
65
- | `config` | Queries `/health` + `/model/info` on startup, discovers models with rich metadata (costs, limits, capabilities), and injects them into OpenCode |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-provider-litellm",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "OpenCode plugin for any LiteLLM proxy — auto-discovers models, auth, and capabilities",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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('tool hook passes correct config and apiKey to createSkillToolDefinitions', async () => {
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
- return
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
@@ -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
- }