opencode-provider-litellm 0.1.0 → 0.2.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.
@@ -0,0 +1,253 @@
1
+ import { tool } from '@opencode-ai/plugin'
2
+ import type { PluginConfig, McpTool } from './types.js'
3
+
4
+ /**
5
+ * Discovers MCP tools available on the LiteLLM proxy by calling
6
+ * GET /mcp-rest/tools/list.
7
+ *
8
+ * Returns an empty array on any error (network, 4xx, 5xx, parse failure).
9
+ * Uses a 10s timeout via AbortController.
10
+ */
11
+ export async function discoverMcpTools(
12
+ config: PluginConfig,
13
+ token: string,
14
+ ): Promise<McpTool[]> {
15
+ const controller = new AbortController()
16
+ const timeoutId = setTimeout(() => controller.abort(), 10_000)
17
+
18
+ try {
19
+ const response = await fetch(`${config.url}/mcp-rest/tools/list`, {
20
+ method: 'GET',
21
+ headers: {
22
+ Authorization: `Bearer ${token}`,
23
+ 'Content-Type': 'application/json',
24
+ },
25
+ signal: controller.signal,
26
+ })
27
+
28
+ if (!response.ok) {
29
+ return []
30
+ }
31
+
32
+ const body = await response.json()
33
+
34
+ if (!Array.isArray(body)) {
35
+ return []
36
+ }
37
+
38
+ return body as McpTool[]
39
+ } catch {
40
+ return []
41
+ } finally {
42
+ clearTimeout(timeoutId)
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Executes a specific MCP tool on the LiteLLM proxy by calling
48
+ * POST /mcp-rest/tools/call.
49
+ *
50
+ * Returns the result as a formatted string. On error, returns an error
51
+ * message string instead of throwing.
52
+ * Uses a 30s timeout via AbortController.
53
+ */
54
+ export async function executeMcpTool(
55
+ config: PluginConfig,
56
+ token: string,
57
+ server: string,
58
+ toolName: string,
59
+ args: Record<string, unknown>,
60
+ ): Promise<string> {
61
+ const controller = new AbortController()
62
+ const timeoutId = setTimeout(() => controller.abort(), 30_000)
63
+
64
+ try {
65
+ const response = await fetch(`${config.url}/mcp-rest/tools/call`, {
66
+ method: 'POST',
67
+ headers: {
68
+ Authorization: `Bearer ${token}`,
69
+ 'Content-Type': 'application/json',
70
+ },
71
+ body: JSON.stringify({ server, tool: toolName, args }),
72
+ signal: controller.signal,
73
+ })
74
+
75
+ if (!response.ok) {
76
+ return `Error calling ${toolName} on ${server}: HTTP ${response.status}`
77
+ }
78
+
79
+ const body = await response.json()
80
+
81
+ if (body && typeof body === 'object' && 'result' in body) {
82
+ return JSON.stringify(body.result, null, 2)
83
+ }
84
+
85
+ return JSON.stringify(body, null, 2)
86
+ } catch (error: unknown) {
87
+ const message = error instanceof Error ? error.message : String(error)
88
+ return `Error calling ${toolName} on ${server}: ${message}`
89
+ } finally {
90
+ clearTimeout(timeoutId)
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Sanitize a server name or tool name for use in opencode tool identifiers.
96
+ * Lowercase, replace any non-alphanumeric chars with underscore.
97
+ */
98
+ function sanitizeName(name: string): string {
99
+ return name.toLowerCase().replace(/[^a-z0-9]/g, '_')
100
+ }
101
+
102
+ /**
103
+ * Check if a JSON Schema property can be mapped to a zod type.
104
+ * Returns true for: string, number, integer, boolean, array of strings.
105
+ * Returns false for: nested objects, $ref, anyOf, etc.
106
+ */
107
+ function isMappableSchema(propSchema: unknown): boolean {
108
+ if (!propSchema || typeof propSchema !== 'object') return false
109
+
110
+ const schema = propSchema as Record<string, unknown>
111
+
112
+ // Reject if it has $ref or anyOf
113
+ if ('$ref' in schema || 'anyOf' in schema || 'oneOf' in schema || 'allOf' in schema) {
114
+ return false
115
+ }
116
+
117
+ const type = schema.type as string | undefined
118
+ if (!type) return false
119
+
120
+ if (type === 'string' || type === 'number' || type === 'integer' || type === 'boolean') {
121
+ return true
122
+ }
123
+
124
+ if (type === 'array') {
125
+ const items = schema.items as Record<string, unknown> | undefined
126
+ if (items && items.type === 'string') {
127
+ return true
128
+ }
129
+ return false
130
+ }
131
+
132
+ return false
133
+ }
134
+
135
+ /**
136
+ * Build zod args from a JSON Schema input_schema.
137
+ * Returns null if the schema cannot be mapped (use single-arg fallback).
138
+ */
139
+ function buildZodArgs(inputSchema: Record<string, unknown>): Record<string, unknown> | null {
140
+ const properties = inputSchema.properties as Record<string, unknown> | undefined
141
+ const required = (inputSchema.required as string[] | undefined) ?? []
142
+
143
+ if (!properties || typeof properties !== 'object') {
144
+ return null
145
+ }
146
+
147
+ // Check if all properties are mappable
148
+ for (const key of Object.keys(properties)) {
149
+ if (!isMappableSchema(properties[key])) {
150
+ return null
151
+ }
152
+ }
153
+
154
+ const zodArgs: Record<string, unknown> = {}
155
+
156
+ for (const [key, propSchema] of Object.entries(properties)) {
157
+ const schema = propSchema as Record<string, unknown>
158
+ const type = schema.type as string | undefined
159
+ const isRequired = required.includes(key)
160
+
161
+ let zodField: unknown
162
+
163
+ switch (type) {
164
+ case 'string':
165
+ zodField = tool.schema.string().describe(key)
166
+ break
167
+ case 'number':
168
+ case 'integer':
169
+ zodField = tool.schema.number().describe(key)
170
+ break
171
+ case 'boolean':
172
+ zodField = tool.schema.boolean().describe(key)
173
+ break
174
+ case 'array': {
175
+ const items = schema.items as Record<string, unknown> | undefined
176
+ if (items && items.type === 'string') {
177
+ zodField = tool.schema.array(tool.schema.string()).describe(key)
178
+ } else {
179
+ return null
180
+ }
181
+ break
182
+ }
183
+ default:
184
+ return null
185
+ }
186
+
187
+ if (!isRequired) {
188
+ // @ts-expect-error — optional() is available on all zod types
189
+ zodField = zodField.optional()
190
+ }
191
+
192
+ zodArgs[key] = zodField
193
+ }
194
+
195
+ return zodArgs
196
+ }
197
+
198
+ /**
199
+ * Creates opencode tool definitions for all discovered MCP tools.
200
+ *
201
+ * Each MCP tool is registered as `mcp_${serverName}_${toolName}` with
202
+ * a description appended with the server name.
203
+ *
204
+ * Returns an empty object if no tools are discovered.
205
+ */
206
+ export async function createMcpToolDefinitions(
207
+ config: PluginConfig,
208
+ token: string,
209
+ ): Promise<Record<string, any>> {
210
+ const mcpTools = await discoverMcpTools(config, token)
211
+
212
+ if (mcpTools.length === 0) {
213
+ return {}
214
+ }
215
+
216
+ const definitions: Record<string, any> = {}
217
+
218
+ for (const mcpTool of mcpTools) {
219
+ const serverName = mcpTool.server_name
220
+ const toolName = mcpTool.name
221
+
222
+ const safeServer = sanitizeName(serverName)
223
+ const safeTool = sanitizeName(toolName)
224
+ const opencodeName = `mcp_${safeServer}_${safeTool}`
225
+
226
+ const description = `${mcpTool.description} (via ${serverName} MCP server)`
227
+
228
+ // Build args from input_schema
229
+ const zodArgs = buildZodArgs(mcpTool.input_schema)
230
+
231
+ let args: Record<string, any>
232
+ if (zodArgs) {
233
+ args = zodArgs
234
+ } else {
235
+ // Fallback: single-arg mode
236
+ args = {
237
+ args: tool.schema
238
+ .record(tool.schema.string(), tool.schema.unknown())
239
+ .describe('Tool arguments as key-value pairs'),
240
+ }
241
+ }
242
+
243
+ definitions[opencodeName] = tool({
244
+ description,
245
+ args: args as Parameters<typeof tool>[0]['args'],
246
+ async execute(args: Record<string, unknown>, _context: unknown): Promise<string> {
247
+ return executeMcpTool(config, token, serverName, toolName, args)
248
+ },
249
+ })
250
+ }
251
+
252
+ return definitions
253
+ }
@@ -15,9 +15,22 @@ vi.mock('./utils.js', () => ({
15
15
  mapLiteLLMModel: vi.fn(),
16
16
  }))
17
17
 
18
+ // Mock the MCP tools module
19
+ vi.mock('./mcp-tools.js', () => ({
20
+ createMcpToolDefinitions: vi.fn(),
21
+ }))
22
+
23
+ // Mock the Skills module
24
+ vi.mock('./skills.js', () => ({
25
+ createSkillToolDefinitions: vi.fn(),
26
+ createSkillsInjector: vi.fn(),
27
+ }))
28
+
18
29
  import { LiteLLMPlugin } from './plugin.js'
19
30
  import { discoverModels, injectModelsIntoConfig } from './discovery.js'
20
31
  import { resolvePluginConfig } from './utils.js'
32
+ import { createMcpToolDefinitions } from './mcp-tools.js'
33
+ import { createSkillToolDefinitions, createSkillsInjector } from './skills.js'
21
34
 
22
35
  function createMockInput(): PluginInput {
23
36
  const logFn = vi.fn().mockResolvedValue(true)
@@ -61,6 +74,19 @@ describe('LiteLLMPlugin', () => {
61
74
  url: 'https://litellm.example.com',
62
75
  apiKey: 'test-api-key',
63
76
  })
77
+
78
+ // Default MCP mock: returns one tool
79
+ vi.mocked(createMcpToolDefinitions).mockResolvedValue({
80
+ mcp_test_server_test_tool: 'mock-mcp-tool',
81
+ })
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())
64
90
  })
65
91
 
66
92
  it('throws on missing config', async () => {
@@ -177,4 +203,113 @@ describe('LiteLLMPlugin', () => {
177
203
  const result = await hooks.auth?.methods[0].authorize?.()
178
204
  expect(result).toEqual({ type: 'failed' })
179
205
  })
206
+
207
+ it('tool hook returns merged MCP + Skills tools', async () => {
208
+ const hooks = await LiteLLMPlugin(mockInput, {
209
+ url: 'https://litellm.example.com',
210
+ apiKey: 'test-api-key',
211
+ })
212
+
213
+ expect(hooks.tool).toBeDefined()
214
+ 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
+ })
219
+
220
+ it('tool hook passes correct config and apiKey to createMcpToolDefinitions', async () => {
221
+ await LiteLLMPlugin(mockInput, {
222
+ url: 'https://litellm.example.com',
223
+ apiKey: 'test-api-key',
224
+ })
225
+
226
+ expect(createMcpToolDefinitions).toHaveBeenCalledWith(
227
+ { url: 'https://litellm.example.com', apiKey: 'test-api-key' },
228
+ 'test-api-key',
229
+ )
230
+ })
231
+
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 () => {
267
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
268
+ vi.mocked(createMcpToolDefinitions).mockRejectedValue(new Error('MCP server unavailable'))
269
+
270
+ const hooks = await LiteLLMPlugin(mockInput, {
271
+ url: 'https://litellm.example.com',
272
+ apiKey: 'test-api-key',
273
+ })
274
+
275
+ expect(warnSpy).toHaveBeenCalledWith(
276
+ '[opencode-provider-litellm] MCP tool discovery failed: Error: MCP server unavailable',
277
+ )
278
+
279
+ // Skills tools should still be present
280
+ 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
+ // MCP tools should not be present (empty object merged)
286
+ expect(hooks.tool).not.toHaveProperty('mcp_test_server_test_tool')
287
+
288
+ warnSpy.mockRestore()
289
+ })
290
+
291
+ it('plugin works with env vars (no inline config)', async () => {
292
+ process.env.LITELLM_URL = 'https://env.litellm.example.com'
293
+ process.env.LITELLM_KEY = 'env-api-key'
294
+
295
+ vi.mocked(resolvePluginConfig).mockReturnValue({
296
+ url: 'https://env.litellm.example.com',
297
+ apiKey: 'env-api-key',
298
+ })
299
+
300
+ const hooks = await LiteLLMPlugin(mockInput, undefined)
301
+
302
+ 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
+ expect(createMcpToolDefinitions).toHaveBeenCalledWith(
308
+ { url: 'https://env.litellm.example.com', apiKey: 'env-api-key' },
309
+ 'env-api-key',
310
+ )
311
+
312
+ delete process.env.LITELLM_URL
313
+ delete process.env.LITELLM_KEY
314
+ })
180
315
  })
package/src/plugin.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  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
+ import { createMcpToolDefinitions } from './mcp-tools.js'
5
+ import { createSkillToolDefinitions, createSkillsInjector } from './skills.js'
4
6
 
5
7
  /**
6
8
  * Main plugin entry point for the LiteLLM provider.
@@ -25,6 +27,14 @@ export const LiteLLMPlugin: Plugin = async (
25
27
 
26
28
  const providerId = getProviderId()
27
29
 
30
+ // Discover MCP tools with graceful error handling
31
+ let mcpTools: Record<string, any> = {}
32
+ try {
33
+ mcpTools = await createMcpToolDefinitions(pluginConfig, pluginConfig.apiKey)
34
+ } catch (e) {
35
+ console.warn(`[opencode-provider-litellm] MCP tool discovery failed: ${e}`)
36
+ }
37
+
28
38
  return {
29
39
  /**
30
40
  * Config hook — discovers models from the LiteLLM proxy and injects
@@ -100,5 +110,19 @@ export const LiteLLMPlugin: Plugin = async (
100
110
  },
101
111
  ],
102
112
  },
113
+
114
+ /**
115
+ * Tool hook — merges dynamically-discovered MCP tools with static
116
+ * Skills CRUD tools.
117
+ */
118
+ tool: {
119
+ ...mcpTools,
120
+ ...createSkillToolDefinitions(pluginConfig, pluginConfig.apiKey),
121
+ },
122
+
123
+ /**
124
+ * Chat message hook — injects active Skills as context into chat messages.
125
+ */
126
+ "chat.message": createSkillsInjector(pluginConfig, pluginConfig.apiKey),
103
127
  }
104
128
  }