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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-provider-litellm",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "OpenCode plugin for any LiteLLM proxy — auto-discovers models, auth, and capabilities",
5
5
  "type": "module",
6
6
  "exports": {
package/src/index.ts CHANGED
@@ -1 +1 @@
1
- export { LiteLLMPlugin } from './plugin.js'
1
+ export { LiteLLMPlugin as server } from './plugin.js'
@@ -0,0 +1,570 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { tool } from '@opencode-ai/plugin'
3
+ import type { PluginConfig, McpTool } from './types.js'
4
+ import { discoverMcpTools, executeMcpTool, createMcpToolDefinitions } from './mcp-tools.js'
5
+
6
+ describe('discoverMcpTools', () => {
7
+ const config: PluginConfig = {
8
+ url: 'https://litellm.example.com',
9
+ apiKey: 'test-api-key',
10
+ }
11
+ const token = 'test-token'
12
+
13
+ beforeEach(() => {
14
+ vi.restoreAllMocks()
15
+ })
16
+
17
+ it('returns tools from a mock response', async () => {
18
+ const mockTools: McpTool[] = [
19
+ {
20
+ name: 'search',
21
+ server_name: 'brave',
22
+ description: 'Search the web',
23
+ input_schema: {
24
+ type: 'object',
25
+ properties: {
26
+ query: { type: 'string' },
27
+ },
28
+ required: ['query'],
29
+ },
30
+ },
31
+ {
32
+ name: 'fetch',
33
+ server_name: 'fetch',
34
+ description: 'Fetch a URL',
35
+ input_schema: {
36
+ type: 'object',
37
+ properties: {
38
+ url: { type: 'string' },
39
+ },
40
+ required: ['url'],
41
+ },
42
+ },
43
+ ]
44
+
45
+ const mockFetch = vi.fn().mockResolvedValue({
46
+ ok: true,
47
+ status: 200,
48
+ json: async () => mockTools,
49
+ })
50
+ vi.stubGlobal('fetch', mockFetch)
51
+
52
+ const result = await discoverMcpTools(config, token)
53
+
54
+ expect(result).toEqual(mockTools)
55
+ expect(mockFetch).toHaveBeenCalledWith(
56
+ 'https://litellm.example.com/mcp-rest/tools/list',
57
+ expect.objectContaining({
58
+ method: 'GET',
59
+ headers: expect.objectContaining({
60
+ Authorization: 'Bearer test-token',
61
+ 'Content-Type': 'application/json',
62
+ }),
63
+ signal: expect.any(AbortSignal),
64
+ }),
65
+ )
66
+ })
67
+
68
+ it('returns [] on network error', async () => {
69
+ const mockFetch = vi.fn().mockRejectedValue(new Error('network error'))
70
+ vi.stubGlobal('fetch', mockFetch)
71
+
72
+ const result = await discoverMcpTools(config, token)
73
+ expect(result).toEqual([])
74
+ })
75
+
76
+ it('returns [] on 4xx response', async () => {
77
+ const mockFetch = vi.fn().mockResolvedValue({
78
+ ok: false,
79
+ status: 404,
80
+ })
81
+ vi.stubGlobal('fetch', mockFetch)
82
+
83
+ const result = await discoverMcpTools(config, token)
84
+ expect(result).toEqual([])
85
+ })
86
+
87
+ it('returns [] on 5xx response', async () => {
88
+ const mockFetch = vi.fn().mockResolvedValue({
89
+ ok: false,
90
+ status: 500,
91
+ })
92
+ vi.stubGlobal('fetch', mockFetch)
93
+
94
+ const result = await discoverMcpTools(config, token)
95
+ expect(result).toEqual([])
96
+ })
97
+
98
+ it('returns [] when response is not an array', async () => {
99
+ const mockFetch = vi.fn().mockResolvedValue({
100
+ ok: true,
101
+ status: 200,
102
+ json: async () => ({ tools: [] }),
103
+ })
104
+ vi.stubGlobal('fetch', mockFetch)
105
+
106
+ const result = await discoverMcpTools(config, token)
107
+ expect(result).toEqual([])
108
+ })
109
+
110
+ it('returns [] when response is empty array', async () => {
111
+ const mockFetch = vi.fn().mockResolvedValue({
112
+ ok: true,
113
+ status: 200,
114
+ json: async () => [],
115
+ })
116
+ vi.stubGlobal('fetch', mockFetch)
117
+
118
+ const result = await discoverMcpTools(config, token)
119
+ expect(result).toEqual([])
120
+ })
121
+
122
+ it('respects timeout (AbortError after 10s)', async () => {
123
+ vi.useFakeTimers()
124
+
125
+ const mockFetch = vi.fn().mockImplementation((_url: string, init: RequestInit) => {
126
+ return new Promise<Response>((_resolve, reject) => {
127
+ const signal = init?.signal
128
+ if (signal) {
129
+ signal.addEventListener('abort', () => {
130
+ reject(new DOMException('The operation was aborted.', 'AbortError'))
131
+ })
132
+ }
133
+ })
134
+ })
135
+ vi.stubGlobal('fetch', mockFetch)
136
+
137
+ const promise = discoverMcpTools(config, token)
138
+
139
+ await vi.advanceTimersByTimeAsync(10001)
140
+
141
+ const result = await promise
142
+
143
+ vi.useRealTimers()
144
+
145
+ expect(result).toEqual([])
146
+ })
147
+ })
148
+
149
+ describe('executeMcpTool', () => {
150
+ const config: PluginConfig = {
151
+ url: 'https://litellm.example.com',
152
+ apiKey: 'test-api-key',
153
+ }
154
+ const token = 'test-token'
155
+
156
+ beforeEach(() => {
157
+ vi.restoreAllMocks()
158
+ })
159
+
160
+ it('returns formatted result on success', async () => {
161
+ const mockFetch = vi.fn().mockResolvedValue({
162
+ ok: true,
163
+ status: 200,
164
+ json: async () => ({
165
+ result: { content: [{ type: 'text', text: 'Search results found' }] },
166
+ }),
167
+ })
168
+ vi.stubGlobal('fetch', mockFetch)
169
+
170
+ const result = await executeMcpTool(
171
+ config,
172
+ token,
173
+ 'brave',
174
+ 'search',
175
+ { query: 'test' },
176
+ )
177
+
178
+ expect(result).toBe(JSON.stringify({ content: [{ type: 'text', text: 'Search results found' }] }, null, 2))
179
+ expect(mockFetch).toHaveBeenCalledWith(
180
+ 'https://litellm.example.com/mcp-rest/tools/call',
181
+ expect.objectContaining({
182
+ method: 'POST',
183
+ headers: expect.objectContaining({
184
+ Authorization: 'Bearer test-token',
185
+ 'Content-Type': 'application/json',
186
+ }),
187
+ body: JSON.stringify({ server: 'brave', tool: 'search', args: { query: 'test' } }),
188
+ signal: expect.any(AbortSignal),
189
+ }),
190
+ )
191
+ })
192
+
193
+ it('stringifies entire response when no result field', async () => {
194
+ const mockFetch = vi.fn().mockResolvedValue({
195
+ ok: true,
196
+ status: 200,
197
+ json: async () => ({
198
+ output: [{ type: 'text', text: 'plain text' }],
199
+ }),
200
+ })
201
+ vi.stubGlobal('fetch', mockFetch)
202
+
203
+ const result = await executeMcpTool(
204
+ config,
205
+ token,
206
+ 'brave',
207
+ 'search',
208
+ { query: 'test' },
209
+ )
210
+
211
+ expect(result).toBe(JSON.stringify({ output: [{ type: 'text', text: 'plain text' }] }, null, 2))
212
+ })
213
+
214
+ it('returns error string on failure', async () => {
215
+ const mockFetch = vi.fn().mockRejectedValue(new Error('connection refused'))
216
+ vi.stubGlobal('fetch', mockFetch)
217
+
218
+ const result = await executeMcpTool(
219
+ config,
220
+ token,
221
+ 'brave',
222
+ 'search',
223
+ { query: 'test' },
224
+ )
225
+
226
+ expect(result).toBe('Error calling search on brave: connection refused')
227
+ })
228
+
229
+ it('returns error string on non-ok response', async () => {
230
+ const mockFetch = vi.fn().mockResolvedValue({
231
+ ok: false,
232
+ status: 500,
233
+ })
234
+ vi.stubGlobal('fetch', mockFetch)
235
+
236
+ const result = await executeMcpTool(
237
+ config,
238
+ token,
239
+ 'myserver',
240
+ 'mytool',
241
+ { arg1: 'val1' },
242
+ )
243
+
244
+ expect(result).toContain('Error calling mytool on myserver')
245
+ })
246
+
247
+ it('respects timeout (AbortError after 30s)', async () => {
248
+ vi.useFakeTimers()
249
+
250
+ const mockFetch = vi.fn().mockImplementation((_url: string, init: RequestInit) => {
251
+ return new Promise<string>((_resolve, reject) => {
252
+ const signal = init?.signal
253
+ if (signal) {
254
+ signal.addEventListener('abort', () => {
255
+ reject(new DOMException('The operation was aborted.', 'AbortError'))
256
+ })
257
+ }
258
+ })
259
+ })
260
+ vi.stubGlobal('fetch', mockFetch)
261
+
262
+ const promise = executeMcpTool(
263
+ config,
264
+ token,
265
+ 'brave',
266
+ 'search',
267
+ { query: 'test' },
268
+ )
269
+
270
+ await vi.advanceTimersByTimeAsync(30001)
271
+
272
+ const result = await promise
273
+
274
+ vi.useRealTimers()
275
+
276
+ expect(result).toContain('Error calling search on brave')
277
+ })
278
+ })
279
+
280
+ describe('createMcpToolDefinitions', () => {
281
+ const config: PluginConfig = {
282
+ url: 'https://litellm.example.com',
283
+ apiKey: 'test-api-key',
284
+ }
285
+ const token = 'test-token'
286
+
287
+ beforeEach(() => {
288
+ vi.restoreAllMocks()
289
+ })
290
+
291
+ it('produces correct tool names (namespaced, sanitized)', async () => {
292
+ const mockTools: McpTool[] = [
293
+ {
294
+ name: 'web-search',
295
+ server_name: 'Brave-API',
296
+ description: 'Search the web',
297
+ input_schema: {
298
+ type: 'object',
299
+ properties: {
300
+ query: { type: 'string' },
301
+ },
302
+ required: ['query'],
303
+ },
304
+ },
305
+ ]
306
+
307
+ const mockFetch = vi.fn().mockResolvedValue({
308
+ ok: true,
309
+ status: 200,
310
+ json: async () => mockTools,
311
+ })
312
+ vi.stubGlobal('fetch', mockFetch)
313
+
314
+ const result = await createMcpToolDefinitions(config, token)
315
+
316
+ expect(Object.keys(result)).toEqual(['mcp_brave_api_web_search'])
317
+ const toolDef = result['mcp_brave_api_web_search']
318
+ expect(toolDef.description).toBe('Search the web (via Brave-API MCP server)')
319
+ })
320
+
321
+ it('returns {} when no tools discovered', async () => {
322
+ const mockFetch = vi.fn().mockResolvedValue({
323
+ ok: true,
324
+ status: 200,
325
+ json: async () => [],
326
+ })
327
+ vi.stubGlobal('fetch', mockFetch)
328
+
329
+ const result = await createMcpToolDefinitions(config, token)
330
+ expect(result).toEqual({})
331
+ })
332
+
333
+ it('maps JSON Schema types correctly', async () => {
334
+ const mockTools: McpTool[] = [
335
+ {
336
+ name: 'test_tool',
337
+ server_name: 'test_server',
338
+ description: 'Test tool',
339
+ input_schema: {
340
+ type: 'object',
341
+ properties: {
342
+ name: { type: 'string' },
343
+ count: { type: 'number' },
344
+ is_active: { type: 'boolean' },
345
+ tags: { type: 'array', items: { type: 'string' } },
346
+ optional_val: { type: 'string' },
347
+ },
348
+ required: ['name', 'count', 'is_active', 'tags'],
349
+ },
350
+ },
351
+ ]
352
+
353
+ const mockFetch = vi.fn().mockResolvedValue({
354
+ ok: true,
355
+ status: 200,
356
+ json: async () => mockTools,
357
+ })
358
+ vi.stubGlobal('fetch', mockFetch)
359
+
360
+ const result = await createMcpToolDefinitions(config, token)
361
+
362
+ const toolDef = result['mcp_test_server_test_tool']
363
+ expect(toolDef).toBeDefined()
364
+
365
+ // Check that args were built correctly
366
+ const args = toolDef.args
367
+ expect(args).toBeDefined()
368
+
369
+ const z = tool.schema
370
+
371
+ // Required string field
372
+ expect(args.name).toBeInstanceOf(z.ZodString)
373
+ expect(args.name.isOptional()).toBe(false)
374
+
375
+ // Required number field
376
+ expect(args.count).toBeInstanceOf(z.ZodNumber)
377
+ expect(args.count.isOptional()).toBe(false)
378
+
379
+ // Required boolean field
380
+ expect(args.is_active).toBeInstanceOf(z.ZodBoolean)
381
+ expect(args.is_active.isOptional()).toBe(false)
382
+
383
+ // Required array field
384
+ expect(args.tags).toBeInstanceOf(z.ZodArray)
385
+ expect(args.tags.isOptional()).toBe(false)
386
+
387
+ // Optional field
388
+ expect(args.optional_val.isOptional()).toBe(true)
389
+ })
390
+
391
+ it('falls back to single-arg mode for unmappable schemas', async () => {
392
+ const mockTools: McpTool[] = [
393
+ {
394
+ name: 'complex_tool',
395
+ server_name: 'complex_server',
396
+ description: 'Complex tool with nested schema',
397
+ input_schema: {
398
+ type: 'object',
399
+ properties: {
400
+ nested: {
401
+ type: 'object',
402
+ properties: {
403
+ deep: { type: 'string' },
404
+ },
405
+ },
406
+ },
407
+ required: ['nested'],
408
+ },
409
+ },
410
+ ]
411
+
412
+ const mockFetch = vi.fn().mockResolvedValue({
413
+ ok: true,
414
+ status: 200,
415
+ json: async () => mockTools,
416
+ })
417
+ vi.stubGlobal('fetch', mockFetch)
418
+
419
+ const result = await createMcpToolDefinitions(config, token)
420
+
421
+ const toolDef = result['mcp_complex_server_complex_tool']
422
+ expect(toolDef).toBeDefined()
423
+
424
+ // Should fall back to single-arg mode
425
+ const args = toolDef.args
426
+ expect(args.args).toBeDefined()
427
+ expect(args.args).toBeInstanceOf(tool.schema.ZodRecord)
428
+ })
429
+
430
+ it('falls back to single-arg mode for $ref schemas', async () => {
431
+ const mockTools: McpTool[] = [
432
+ {
433
+ name: 'ref_tool',
434
+ server_name: 'ref_server',
435
+ description: 'Tool with $ref',
436
+ input_schema: {
437
+ type: 'object',
438
+ properties: {
439
+ data: { $ref: '#/definitions/Data' },
440
+ },
441
+ required: ['data'],
442
+ },
443
+ },
444
+ ]
445
+
446
+ const mockFetch = vi.fn().mockResolvedValue({
447
+ ok: true,
448
+ status: 200,
449
+ json: async () => mockTools,
450
+ })
451
+ vi.stubGlobal('fetch', mockFetch)
452
+
453
+ const result = await createMcpToolDefinitions(config, token)
454
+
455
+ const toolDef = result['mcp_ref_server_ref_tool']
456
+ expect(toolDef).toBeDefined()
457
+
458
+ // Should fall back to single-arg mode
459
+ const args = toolDef.args
460
+ expect(args.args).toBeDefined()
461
+ expect(args.args).toBeInstanceOf(tool.schema.ZodRecord)
462
+ })
463
+
464
+ it('falls back to single-arg mode for anyOf schemas', async () => {
465
+ const mockTools: McpTool[] = [
466
+ {
467
+ name: 'anyof_tool',
468
+ server_name: 'anyof_server',
469
+ description: 'Tool with anyOf',
470
+ input_schema: {
471
+ type: 'object',
472
+ properties: {
473
+ value: { anyOf: [{ type: 'string' }, { type: 'number' }] },
474
+ },
475
+ required: ['value'],
476
+ },
477
+ },
478
+ ]
479
+
480
+ const mockFetch = vi.fn().mockResolvedValue({
481
+ ok: true,
482
+ status: 200,
483
+ json: async () => mockTools,
484
+ })
485
+ vi.stubGlobal('fetch', mockFetch)
486
+
487
+ const result = await createMcpToolDefinitions(config, token)
488
+
489
+ const toolDef = result['mcp_anyof_server_anyof_tool']
490
+ expect(toolDef).toBeDefined()
491
+
492
+ // Should fall back to single-arg mode
493
+ const args = toolDef.args
494
+ expect(args.args).toBeDefined()
495
+ expect(args.args).toBeInstanceOf(tool.schema.ZodRecord)
496
+ })
497
+
498
+ it('execute function calls executeMcpTool correctly', async () => {
499
+ let callCount = 0
500
+ const mockFetch = vi.fn().mockImplementation((_url: string, _init: RequestInit) => {
501
+ callCount++
502
+ if (callCount === 1) {
503
+ // First call: discover
504
+ return Promise.resolve({
505
+ ok: true,
506
+ status: 200,
507
+ json: async () => [{
508
+ name: 'search',
509
+ server_name: 'brave',
510
+ description: 'Search the web',
511
+ input_schema: {
512
+ type: 'object',
513
+ properties: { query: { type: 'string' } },
514
+ required: ['query'],
515
+ },
516
+ }],
517
+ })
518
+ }
519
+ // Second call: execute
520
+ return Promise.resolve({
521
+ ok: true,
522
+ status: 200,
523
+ json: async () => ({ result: { content: [{ type: 'text', text: 'found' }] } }),
524
+ })
525
+ })
526
+ vi.stubGlobal('fetch', mockFetch)
527
+
528
+ const result = await createMcpToolDefinitions(config, token)
529
+
530
+ const toolDef = result['mcp_brave_search']
531
+ expect(toolDef).toBeDefined()
532
+
533
+ // Call execute
534
+ const executeResult = await toolDef.execute({ query: 'test' }, {} as any)
535
+ expect(executeResult).toBe(JSON.stringify({ content: [{ type: 'text', text: 'found' }] }, null, 2))
536
+ expect(callCount).toBe(2)
537
+ })
538
+
539
+ it('maps integer type to number()', async () => {
540
+ const mockTools: McpTool[] = [
541
+ {
542
+ name: 'int_tool',
543
+ server_name: 'int_server',
544
+ description: 'Tool with integer',
545
+ input_schema: {
546
+ type: 'object',
547
+ properties: {
548
+ page: { type: 'integer' },
549
+ },
550
+ required: ['page'],
551
+ },
552
+ },
553
+ ]
554
+
555
+ const mockFetch = vi.fn().mockResolvedValue({
556
+ ok: true,
557
+ status: 200,
558
+ json: async () => mockTools,
559
+ })
560
+ vi.stubGlobal('fetch', mockFetch)
561
+
562
+ const result = await createMcpToolDefinitions(config, token)
563
+
564
+ const toolDef = result['mcp_int_server_int_tool']
565
+ expect(toolDef).toBeDefined()
566
+
567
+ const args = toolDef.args
568
+ expect(args.page).toBeInstanceOf(tool.schema.ZodNumber)
569
+ })
570
+ })