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.
- package/package.json +3 -2
- 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
package/src/mcp-tools.ts
ADDED
|
@@ -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
|
+
}
|
package/src/plugin.test.ts
CHANGED
|
@@ -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
|
}
|