opencode-provider-litellm 0.3.1 → 0.5.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.
@@ -1,4 +1,4 @@
1
- import { vi, describe, it, expect, beforeEach } from 'vitest'
1
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
2
2
  import type { PluginInput } from '@opencode-ai/plugin'
3
3
  import type { OpencodeModelConfig } from './types.js'
4
4
 
@@ -20,17 +20,16 @@ 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(),
23
+ // Mock the gcloud token module
24
+ vi.mock('./gcloud-token.js', () => ({
25
+ getGcloudToken: vi.fn(),
26
+ resetTokenCache: vi.fn(),
27
27
  }))
28
28
 
29
29
  import { LiteLLMPlugin } from './plugin.js'
30
30
  import { discoverModels, injectModelsIntoConfig } from './discovery.js'
31
31
  import { resolvePluginConfig } from './utils.js'
32
32
  import { createMcpToolDefinitions } from './mcp-tools.js'
33
- import { createSkillToolDefinitions, createSkillsInjector } from './skills.js'
34
33
 
35
34
  function createMockInput(): PluginInput {
36
35
  const logFn = vi.fn().mockResolvedValue(true)
@@ -79,14 +78,10 @@ describe('LiteLLMPlugin', () => {
79
78
  vi.mocked(createMcpToolDefinitions).mockResolvedValue({
80
79
  mcp_test_server_test_tool: 'mock-mcp-tool',
81
80
  })
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())
81
+ })
82
+
83
+ afterEach(() => {
84
+ delete process.env.LITELLM_GCLOUD_TOKEN_AUTH
90
85
  })
91
86
 
92
87
  it('throws on missing config', async () => {
@@ -99,6 +94,28 @@ describe('LiteLLMPlugin', () => {
99
94
  )
100
95
  })
101
96
 
97
+ it('throws gcloud-specific error when LITELLM_GCLOUD_TOKEN_AUTH is set but config is missing', async () => {
98
+ vi.mocked(resolvePluginConfig).mockReturnValue(null)
99
+ process.env.LITELLM_GCLOUD_TOKEN_AUTH = '1'
100
+
101
+ await expect(
102
+ LiteLLMPlugin(mockInput, {})
103
+ ).rejects.toThrow(
104
+ 'LITELLM_KEY is optional when LITELLM_GCLOUD_TOKEN_AUTH=1',
105
+ )
106
+ })
107
+
108
+ it('throws generic error when LITELLM_GCLOUD_TOKEN_AUTH is not set and config is missing', async () => {
109
+ vi.mocked(resolvePluginConfig).mockReturnValue(null)
110
+ delete process.env.LITELLM_GCLOUD_TOKEN_AUTH
111
+
112
+ await expect(
113
+ LiteLLMPlugin(mockInput, {})
114
+ ).rejects.toThrow(
115
+ "Plugin config error: set 'url' and 'apiKey'",
116
+ )
117
+ })
118
+
102
119
  it('config hook calls discoverModels and injects models into config', async () => {
103
120
  const hooks = await LiteLLMPlugin(mockInput, {
104
121
  url: 'https://litellm.example.com',
@@ -212,9 +229,6 @@ describe('LiteLLMPlugin', () => {
212
229
 
213
230
  expect(hooks.tool).toBeDefined()
214
231
  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
232
  })
219
233
 
220
234
  it('tool hook passes correct config and apiKey to createMcpToolDefinitions', async () => {
@@ -229,41 +243,7 @@ describe('LiteLLMPlugin', () => {
229
243
  )
230
244
  })
231
245
 
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 () => {
246
+ it('MCP discovery failure does not break the plugin', async () => {
267
247
  const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
268
248
  vi.mocked(createMcpToolDefinitions).mockRejectedValue(new Error('MCP server unavailable'))
269
249
 
@@ -278,10 +258,6 @@ describe('LiteLLMPlugin', () => {
278
258
 
279
259
  // Skills tools should still be present
280
260
  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
261
  // MCP tools should not be present (empty object merged)
286
262
  expect(hooks.tool).not.toHaveProperty('mcp_test_server_test_tool')
287
263
 
@@ -300,10 +276,6 @@ describe('LiteLLMPlugin', () => {
300
276
  const hooks = await LiteLLMPlugin(mockInput, undefined)
301
277
 
302
278
  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
279
  expect(createMcpToolDefinitions).toHaveBeenCalledWith(
308
280
  { url: 'https://env.litellm.example.com', apiKey: 'env-api-key' },
309
281
  'env-api-key',
@@ -312,4 +284,35 @@ describe('LiteLLMPlugin', () => {
312
284
  delete process.env.LITELLM_URL
313
285
  delete process.env.LITELLM_KEY
314
286
  })
287
+
288
+ it('registers chat.headers hook when LITELLM_GCLOUD_TOKEN_AUTH is set', async () => {
289
+ process.env.LITELLM_GCLOUD_TOKEN_AUTH = '1'
290
+
291
+ const { getGcloudToken } = await import('./gcloud-token.js')
292
+ vi.mocked(getGcloudToken).mockResolvedValue('mock-gcloud-token')
293
+
294
+ const hooks = await LiteLLMPlugin(mockInput, {
295
+ url: 'https://litellm.example.com',
296
+ apiKey: 'test-api-key',
297
+ })
298
+
299
+ expect(hooks['chat.headers']).toBeDefined()
300
+ expect(typeof hooks['chat.headers']).toBe('function')
301
+
302
+ // Verify the hook injects the token
303
+ const output = { headers: {} as Record<string, string> }
304
+ await (hooks['chat.headers'] as Function)({}, output)
305
+ expect(output.headers['Authorization']).toBe('Bearer mock-gcloud-token')
306
+ })
307
+
308
+ it('does not register chat.headers hook when LITELLM_GCLOUD_TOKEN_AUTH is unset', async () => {
309
+ delete process.env.LITELLM_GCLOUD_TOKEN_AUTH
310
+
311
+ const hooks = await LiteLLMPlugin(mockInput, {
312
+ url: 'https://litellm.example.com',
313
+ apiKey: 'test-api-key',
314
+ })
315
+
316
+ expect(hooks['chat.headers']).toBeUndefined()
317
+ })
315
318
  })
package/src/plugin.ts CHANGED
@@ -2,32 +2,28 @@ 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'
5
+ import { getGcloudToken } from './gcloud-token.js'
6
6
 
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
7
  export const LiteLLMPlugin: Plugin = async (
17
8
  input: PluginInput,
18
9
  options?: PluginOptions,
19
10
  ) => {
20
11
  const pluginConfig = resolvePluginConfig(options)
21
12
  if (pluginConfig === null) {
13
+ const isGcloudAuth = process.env.LITELLM_GCLOUD_TOKEN_AUTH &&
14
+ process.env.LITELLM_GCLOUD_TOKEN_AUTH !== '' &&
15
+ process.env.LITELLM_GCLOUD_TOKEN_AUTH !== '0'
16
+
22
17
  throw new Error(
23
- "Plugin config error: set 'url' and 'apiKey' in plugin options, " +
24
- "or set LITELLM_URL and LITELLM_KEY environment variables.",
18
+ isGcloudAuth
19
+ ? "Plugin config error: set LITELLM_URL (LITELLM_KEY is optional when LITELLM_GCLOUD_TOKEN_AUTH=1)."
20
+ : "Plugin config error: set 'url' and 'apiKey' in plugin options, " +
21
+ "or set LITELLM_URL and LITELLM_KEY environment variables.",
25
22
  )
26
23
  }
27
24
 
28
25
  const providerId = getProviderId()
29
26
 
30
- // Discover MCP tools with graceful error handling
31
27
  let mcpTools: Record<string, any> = {}
32
28
  try {
33
29
  mcpTools = await createMcpToolDefinitions(pluginConfig, pluginConfig.apiKey)
@@ -35,12 +31,8 @@ export const LiteLLMPlugin: Plugin = async (
35
31
  console.warn(`[opencode-provider-litellm] MCP tool discovery failed: ${e}`)
36
32
  }
37
33
 
38
- 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) => {
34
+ const result: Record<string, unknown> = {
35
+ config: async (config: Record<string, any>) => {
44
36
  try {
45
37
  const models = await discoverModels(
46
38
  pluginConfig,
@@ -55,23 +47,22 @@ export const LiteLLMPlugin: Plugin = async (
55
47
  message: 'No models discovered',
56
48
  },
57
49
  })
58
- return
50
+ } else {
51
+ injectModelsIntoConfig(
52
+ config as Parameters<typeof injectModelsIntoConfig>[0],
53
+ providerId,
54
+ pluginConfig.url,
55
+ pluginConfig.apiKey,
56
+ models,
57
+ )
58
+ await input.client.app.log({
59
+ body: {
60
+ service: providerId,
61
+ level: 'info',
62
+ message: `Discovered ${Object.keys(models).length} models`,
63
+ },
64
+ })
59
65
  }
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
66
  } catch (error) {
76
67
  await input.client.app.log({
77
68
  body: {
@@ -83,10 +74,6 @@ export const LiteLLMPlugin: Plugin = async (
83
74
  }
84
75
  },
85
76
 
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
77
  auth: {
91
78
  provider: providerId,
92
79
  methods: [
@@ -101,28 +88,32 @@ export const LiteLLMPlugin: Plugin = async (
101
88
  placeholder: 'sk-...',
102
89
  },
103
90
  ],
104
- async authorize(inputs) {
105
- if (!inputs?.apiKey || inputs.apiKey.length === 0) {
91
+ async authorize(inputs: Record<string, unknown> | undefined) {
92
+ const apiKey = inputs?.apiKey
93
+ if (!apiKey || typeof apiKey !== 'string' || apiKey.length === 0) {
106
94
  return { type: 'failed' as const }
107
95
  }
108
- return { type: 'success' as const, key: inputs.apiKey }
96
+ return { type: 'success' as const, key: apiKey }
109
97
  },
110
98
  },
111
99
  ],
112
100
  },
113
101
 
114
- /**
115
- * Tool hook — merges dynamically-discovered MCP tools with static
116
- * Skills CRUD tools.
117
- */
118
102
  tool: {
119
103
  ...mcpTools,
120
- ...createSkillToolDefinitions(pluginConfig, pluginConfig.apiKey),
121
104
  },
105
+ }
122
106
 
123
- /**
124
- * Chat message hook — injects active Skills as context into chat messages.
125
- */
126
- "chat.message": createSkillsInjector(pluginConfig, pluginConfig.apiKey),
107
+ if (process.env.LITELLM_GCLOUD_TOKEN_AUTH &&
108
+ process.env.LITELLM_GCLOUD_TOKEN_AUTH !== '' &&
109
+ process.env.LITELLM_GCLOUD_TOKEN_AUTH !== '0') {
110
+ result['chat.headers'] = async (_input: Record<string, unknown>, output: { headers: Record<string, string> }) => {
111
+ const token = await getGcloudToken()
112
+ if (token) {
113
+ output.headers['Authorization'] = `Bearer ${token}`
114
+ }
115
+ }
127
116
  }
117
+
118
+ return result
128
119
  }
package/src/types.ts CHANGED
@@ -15,29 +15,6 @@ export interface McpTool {
15
15
  input_schema: Record<string, unknown>
16
16
  }
17
17
 
18
- export interface SkillSource {
19
- source: string
20
- url: string
21
- path?: string
22
- }
23
-
24
- export interface Skill {
25
- id: string
26
- name: string
27
- version: string
28
- description: string | null
29
- source: SkillSource
30
- author: string | null
31
- homepage: string | null
32
- keywords: string | null
33
- category: string | null
34
- domain: string | null
35
- namespace: string | null
36
- enabled: boolean
37
- created_at: string
38
- updated_at: string
39
- }
40
-
41
18
  export interface OpencodeModelConfig {
42
19
  name: string
43
20
  tool_call?: boolean
package/src/utils.test.ts CHANGED
@@ -137,4 +137,55 @@ describe('resolvePluginConfig', () => {
137
137
  expect(resolvePluginConfig({})).toBeNull()
138
138
  })
139
139
  })
140
+
141
+ describe('gcloud token auth', () => {
142
+ beforeEach(() => {
143
+ delete process.env.LITELLM_GCLOUD_TOKEN_AUTH
144
+ })
145
+
146
+ it('allows missing LITELLM_KEY when LITELLM_GCLOUD_TOKEN_AUTH is set', () => {
147
+ process.env.LITELLM_URL = 'https://gcloud.example.com'
148
+ process.env.LITELLM_GCLOUD_TOKEN_AUTH = '1'
149
+ delete process.env.LITELLM_KEY
150
+
151
+ const config = resolvePluginConfig({})
152
+ expect(config).toEqual({ url: 'https://gcloud.example.com', apiKey: '' })
153
+ })
154
+
155
+ it('does not allow missing key when gcloud auth is disabled', () => {
156
+ process.env.LITELLM_URL = 'https://gcloud.example.com'
157
+ delete process.env.LITELLM_KEY
158
+ delete process.env.LITELLM_GCLOUD_TOKEN_AUTH
159
+
160
+ const config = resolvePluginConfig({})
161
+ expect(config).toBeNull()
162
+ })
163
+
164
+ it('does not allow missing key when gcloud auth is set to 0', () => {
165
+ process.env.LITELLM_URL = 'https://gcloud.example.com'
166
+ process.env.LITELLM_GCLOUD_TOKEN_AUTH = '0'
167
+ delete process.env.LITELLM_KEY
168
+
169
+ const config = resolvePluginConfig({})
170
+ expect(config).toBeNull()
171
+ })
172
+
173
+ it('does not allow missing key when gcloud auth is empty string', () => {
174
+ process.env.LITELLM_URL = 'https://gcloud.example.com'
175
+ process.env.LITELLM_GCLOUD_TOKEN_AUTH = ''
176
+ delete process.env.LITELLM_KEY
177
+
178
+ const config = resolvePluginConfig({})
179
+ expect(config).toBeNull()
180
+ })
181
+
182
+ it('prefers full env vars over gcloud fallback', () => {
183
+ process.env.LITELLM_URL = 'https://gcloud.example.com'
184
+ process.env.LITELLM_KEY = 'normal-key'
185
+ process.env.LITELLM_GCLOUD_TOKEN_AUTH = '1'
186
+
187
+ const config = resolvePluginConfig({})
188
+ expect(config).toEqual({ url: 'https://gcloud.example.com', apiKey: 'normal-key' })
189
+ })
190
+ })
140
191
  })
package/src/utils.ts CHANGED
@@ -34,14 +34,23 @@ export function mapLiteLLMModel(model: LiteLLMModel): OpencodeModelConfig {
34
34
  export function resolvePluginConfig(rawConfig: unknown): PluginConfig | null {
35
35
  const envUrl = typeof process !== 'undefined' ? process.env.LITELLM_URL : undefined
36
36
  const envKey = typeof process !== 'undefined' ? process.env.LITELLM_KEY : undefined
37
+ const envGcloudAuth = typeof process !== 'undefined'
38
+ ? process.env.LITELLM_GCLOUD_TOKEN_AUTH
39
+ : undefined
37
40
 
38
41
  const hasEnvVars = envUrl !== undefined && envUrl.length > 0 &&
39
- envKey !== undefined && envKey.length > 0
42
+ envKey !== undefined && envKey.length > 0
40
43
 
41
44
  if (hasEnvVars) {
42
45
  return { url: envUrl, apiKey: envKey }
43
46
  }
44
47
 
48
+ // Allow missing LITELLM_KEY when gcloud token auth is enabled
49
+ if (envUrl !== undefined && envUrl.length > 0 &&
50
+ envGcloudAuth !== undefined && envGcloudAuth !== '' && envGcloudAuth !== '0') {
51
+ return { url: envUrl, apiKey: envKey || '' }
52
+ }
53
+
45
54
  // Fall back to config options from opencode.json
46
55
  if (rawConfig && typeof rawConfig === 'object' && !Array.isArray(rawConfig)) {
47
56
  const obj = rawConfig as Record<string, unknown>