opencode-provider-litellm 0.1.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 ADDED
@@ -0,0 +1,90 @@
1
+ # opencode-provider-litellm
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.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ # 1. Set your LiteLLM proxy URL and API key
9
+ export LITELLM_URL="https://your-litellm-proxy.example.com"
10
+ export LITELLM_KEY="sk-..."
11
+
12
+ # 2. Install the plugin
13
+ opencode plugin opencode-provider-litellm
14
+
15
+ # 3. Restart OpenCode
16
+ ```
17
+
18
+ All models from your LiteLLM proxy appear in OpenCode's model picker automatically.
19
+
20
+ ## Configuration
21
+
22
+ ### Environment variables
23
+
24
+ | Variable | Description |
25
+ |----------|-------------|
26
+ | `LITELLM_URL` | Your LiteLLM proxy base URL |
27
+ | `LITELLM_KEY` | API key for the proxy |
28
+ | `LITELLM_PROVIDER_ID` | Provider ID in OpenCode (defaults to `LiteLLM`) |
29
+
30
+ ### Inline config
31
+
32
+ Alternatively, provide `url` and `apiKey` directly in your `opencode.json`:
33
+
34
+ ```jsonc
35
+ {
36
+ "plugin": [
37
+ ["opencode-provider-litellm", {
38
+ "url": "https://your-litellm-proxy.example.com",
39
+ "apiKey": "sk-..."
40
+ }]
41
+ ]
42
+ }
43
+ ```
44
+
45
+ :::tip
46
+ Environment variables take precedence over inline config. Use env vars to keep secrets out of checked-in files.
47
+ :::
48
+
49
+ ### /connect flow
50
+
51
+ You can also authenticate interactively via the OpenCode TUI:
52
+
53
+ 1. Run `/connect`
54
+ 2. Select **LiteLLM**
55
+ 3. Paste your API key
56
+
57
+ The key is stored in OpenCode's auth store.
58
+
59
+ ## How it works
60
+
61
+ The plugin uses two OpenCode hooks:
62
+
63
+ | Hook | Purpose |
64
+ |------|---------|
65
+ | `config` | Queries `/health` + `/model/info` on startup, discovers models with rich metadata (costs, limits, capabilities), and injects them into OpenCode |
66
+ | `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.
74
+
75
+ ## Troubleshooting
76
+
77
+ | Problem | Solution |
78
+ |---------|----------|
79
+ | "Plugin config error" | Set `LITELLM_URL` and `LITELLM_KEY`, or add `url`/`apiKey` to plugin options |
80
+ | "Access denied" (403) | Verify the API key has access to the LiteLLM proxy |
81
+ | "No models discovered" | Check that the proxy is reachable and the `/health` endpoint responds |
82
+
83
+ ## Development
84
+
85
+ ```bash
86
+ npm install
87
+ npm run typecheck
88
+ npm run test
89
+ npm run build
90
+ ```
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "opencode-provider-litellm",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin for any LiteLLM proxy — auto-discovers models, auth, and capabilities",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "scripts": {
13
+ "typecheck": "tsc --noEmit",
14
+ "test": "vitest",
15
+ "test:run": "vitest run",
16
+ "build": "tsc --noEmit && esbuild src/index.ts --bundle --outfile=dist/litellm.js --platform=node --target=node20 --format=esm --external:node:* --packages=external"
17
+ },
18
+ "keywords": [
19
+ "opencode",
20
+ "llm",
21
+ "plugin",
22
+ "litellm"
23
+ ],
24
+ "license": "MIT",
25
+ "author": "Daniel Cherubini",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/danielcherubini/opencode-provider-litellm.git"
29
+ },
30
+ "engines": {
31
+ "opencode": ">=1.14.0"
32
+ },
33
+ "dependencies": {
34
+ "@opencode-ai/plugin": "latest"
35
+ },
36
+ "devDependencies": {
37
+ "typescript": "^5.7.0",
38
+ "@types/node": "^22.0.0",
39
+ "vitest": "^3.0.0",
40
+ "@vitest/coverage-v8": "^3.0.0",
41
+ "esbuild": "^0.27.0"
42
+ }
43
+ }
@@ -0,0 +1,291 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import type { PluginConfig, OpencodeModelConfig } from './types.js'
3
+ import { discoverModels, injectModelsIntoConfig } from './discovery.js'
4
+
5
+ interface TestConfig {
6
+ provider?: Record<string, {
7
+ npm?: string
8
+ name?: string
9
+ options?: Record<string, unknown>
10
+ models?: Record<string, OpencodeModelConfig>
11
+ }>
12
+ }
13
+
14
+ describe('discoverModels', () => {
15
+ const config: PluginConfig = {
16
+ url: 'https://litellm.example.com',
17
+ apiKey: 'test-api-key',
18
+ }
19
+ const getToken = vi.fn(() => Promise.resolve('test-token'))
20
+
21
+ beforeEach(() => {
22
+ vi.restoreAllMocks()
23
+ })
24
+
25
+ it('returns mapped models from LiteLLM response', async () => {
26
+ const mockFetch = vi.fn()
27
+ // /health endpoint
28
+ .mockResolvedValueOnce({
29
+ ok: true,
30
+ status: 200,
31
+ json: async () => ({
32
+ healthy_endpoints: [
33
+ { model: 'gpt-4', model_id: 'uuid-1' },
34
+ { model: 'qwen3-32b', model_id: 'uuid-2' },
35
+ ],
36
+ }),
37
+ })
38
+ // /model/info for gpt-4
39
+ .mockResolvedValueOnce({
40
+ ok: true,
41
+ status: 200,
42
+ json: async () => ({
43
+ data: [{
44
+ model_name: 'gpt-4',
45
+ model_info: {
46
+ max_input_tokens: 8192,
47
+ max_output_tokens: 8192,
48
+ supports_function_calling: true,
49
+ supports_reasoning: false,
50
+ supports_vision: false,
51
+ input_cost_per_token: 0.0001,
52
+ output_cost_per_token: 0.0003,
53
+ },
54
+ }],
55
+ }),
56
+ })
57
+ // /model/info for qwen3-32b
58
+ .mockResolvedValueOnce({
59
+ ok: true,
60
+ status: 200,
61
+ json: async () => ({
62
+ data: [{
63
+ model_name: 'qwen3-32b',
64
+ model_info: {
65
+ max_input_tokens: 32768,
66
+ max_output_tokens: 32768,
67
+ supports_function_calling: true,
68
+ supports_reasoning: true,
69
+ supports_vision: false,
70
+ input_cost_per_token: 0.00005,
71
+ output_cost_per_token: 0.00015,
72
+ },
73
+ }],
74
+ }),
75
+ })
76
+ vi.stubGlobal('fetch', mockFetch)
77
+
78
+ const result = await discoverModels(config, getToken)
79
+
80
+ expect(result).toEqual({
81
+ 'gpt-4': {
82
+ name: 'gpt-4',
83
+ tool_call: true,
84
+ reasoning: false,
85
+ limit: { context: 8192, output: 8192 },
86
+ cost: { input: 0.0001, output: 0.0003 },
87
+ modalities: { input: ['text'], output: ['text'] },
88
+ },
89
+ 'qwen3-32b': {
90
+ name: 'qwen3-32b',
91
+ tool_call: true,
92
+ reasoning: true,
93
+ limit: { context: 32768, output: 32768 },
94
+ cost: { input: 0.00005, output: 0.00015 },
95
+ modalities: { input: ['text'], output: ['text'] },
96
+ },
97
+ })
98
+ expect(mockFetch).toHaveBeenCalledWith(
99
+ 'https://litellm.example.com/health',
100
+ expect.objectContaining({
101
+ headers: expect.objectContaining({
102
+ Authorization: 'Bearer test-token',
103
+ }),
104
+ signal: expect.any(AbortSignal),
105
+ })
106
+ )
107
+ expect(getToken).toHaveBeenCalled()
108
+ })
109
+
110
+ it('returns empty object on timeout', async () => {
111
+ vi.useFakeTimers()
112
+
113
+ // Simulate a fetch that rejects when the abort signal fires
114
+ const mockFetch = vi.fn().mockImplementation((_url: string, init: RequestInit) => {
115
+ return new Promise<Response>((_resolve, reject) => {
116
+ const signal = init?.signal
117
+ if (signal) {
118
+ signal.addEventListener('abort', () => {
119
+ reject(new DOMException('The operation was aborted.', 'AbortError'))
120
+ })
121
+ }
122
+ })
123
+ })
124
+ vi.stubGlobal('fetch', mockFetch)
125
+
126
+ // Run discoverModels
127
+ const promise = discoverModels(config, getToken)
128
+
129
+ // Advance timer past 15s timeout
130
+ await vi.advanceTimersByTimeAsync(15001)
131
+
132
+ const result = await promise
133
+
134
+ vi.useRealTimers()
135
+
136
+ expect(result).toEqual({})
137
+ })
138
+
139
+ it('throws descriptive error on 403', async () => {
140
+ const mockFetch = vi.fn().mockResolvedValue({
141
+ ok: false,
142
+ status: 403,
143
+ })
144
+ vi.stubGlobal('fetch', mockFetch)
145
+
146
+ await expect(discoverModels(config, getToken)).rejects.toThrow(
147
+ 'Access denied. Contact your admin to grant access to the LLM proxy.'
148
+ )
149
+ })
150
+
151
+ it('returns empty object on 500', async () => {
152
+ const mockFetch = vi.fn().mockResolvedValue({
153
+ ok: false,
154
+ status: 500,
155
+ })
156
+ vi.stubGlobal('fetch', mockFetch)
157
+
158
+ const result = await discoverModels(config, getToken)
159
+ expect(result).toEqual({})
160
+ })
161
+
162
+ it('returns empty object on network error', async () => {
163
+ const mockFetch = vi.fn().mockRejectedValue(new Error('network error'))
164
+ vi.stubGlobal('fetch', mockFetch)
165
+
166
+ const result = await discoverModels(config, getToken)
167
+ expect(result).toEqual({})
168
+ })
169
+ })
170
+
171
+ describe('injectModelsIntoConfig', () => {
172
+ it('creates provider entry with correct structure', async () => {
173
+ const config: TestConfig = {}
174
+ const models: Record<string, OpencodeModelConfig> = {
175
+ 'gpt-4': {
176
+ name: 'gpt-4',
177
+ tool_call: true,
178
+ reasoning: false,
179
+ limit: { context: 8192, output: 8192 },
180
+ modalities: { input: ['text' as const], output: ['text' as const] },
181
+ },
182
+ }
183
+
184
+ injectModelsIntoConfig(config, 'litellm', 'https://litellm.example.com', 'sk-test-key', models)
185
+
186
+ expect(config.provider).toEqual({
187
+ litellm: {
188
+ npm: '@ai-sdk/openai-compatible',
189
+ name: 'litellm',
190
+ options: { baseURL: 'https://litellm.example.com', apiKey: 'sk-test-key' },
191
+ models,
192
+ },
193
+ })
194
+ })
195
+
196
+ it('merges with existing provider config without overwriting options', async () => {
197
+ const config: TestConfig = {
198
+ provider: {
199
+ litellm: {
200
+ npm: '@ai-sdk/openai-compatible',
201
+ name: 'litellm',
202
+ options: { baseURL: 'https://old.example.com', apiKey: 'old-key', extra: 'value' },
203
+ models: {
204
+ 'existing-model': {
205
+ name: 'existing-model',
206
+ tool_call: true,
207
+ reasoning: false,
208
+ limit: { context: 4096, output: 4096 },
209
+ modalities: { input: ['text' as const], output: ['text' as const] },
210
+ },
211
+ },
212
+ },
213
+ },
214
+ }
215
+
216
+ const newModels: Record<string, OpencodeModelConfig> = {
217
+ 'new-model': {
218
+ name: 'new-model',
219
+ tool_call: true,
220
+ reasoning: false,
221
+ limit: { context: 8192, output: 8192 },
222
+ modalities: { input: ['text' as const], output: ['text' as const] },
223
+ },
224
+ }
225
+
226
+ injectModelsIntoConfig(config, 'litellm', 'https://litellm.example.com', 'sk-test-key', newModels)
227
+
228
+ // Existing options should be preserved
229
+ expect(config.provider!.litellm.options).toEqual({
230
+ baseURL: 'https://old.example.com',
231
+ apiKey: 'old-key',
232
+ extra: 'value',
233
+ })
234
+
235
+ // Models should be merged
236
+ expect(config.provider!.litellm.models).toEqual({
237
+ 'existing-model': {
238
+ name: 'existing-model',
239
+ tool_call: true,
240
+ reasoning: false,
241
+ limit: { context: 4096, output: 4096 },
242
+ modalities: { input: ['text' as const], output: ['text' as const] },
243
+ },
244
+ 'new-model': {
245
+ name: 'new-model',
246
+ tool_call: true,
247
+ reasoning: false,
248
+ limit: { context: 8192, output: 8192 },
249
+ modalities: { input: ['text' as const], output: ['text' as const] },
250
+ },
251
+ })
252
+ })
253
+
254
+ it('preserves existing provider options when merging', async () => {
255
+ const config: TestConfig = {
256
+ provider: {
257
+ litellm: {
258
+ npm: '@ai-sdk/openai-compatible',
259
+ name: 'litellm',
260
+ options: {
261
+ baseURL: 'https://preserved.example.com',
262
+ apiKey: 'preserved-key',
263
+ customHeader: 'custom-value',
264
+ },
265
+ models: {},
266
+ },
267
+ },
268
+ }
269
+
270
+ const newModels: Record<string, OpencodeModelConfig> = {
271
+ 'gpt-4': {
272
+ name: 'gpt-4',
273
+ tool_call: true,
274
+ reasoning: false,
275
+ limit: { context: 8192, output: 8192 },
276
+ modalities: { input: ['text' as const], output: ['text' as const] },
277
+ },
278
+ }
279
+
280
+ injectModelsIntoConfig(config, 'litellm', 'https://litellm.example.com', 'sk-test-key', newModels)
281
+
282
+ // All existing options should be preserved
283
+ const provider = config.provider!.litellm
284
+ expect(provider.options!.baseURL).toBe('https://preserved.example.com')
285
+ expect(provider.options!.apiKey).toBe('preserved-key')
286
+ expect(provider.options!.customHeader).toBe('custom-value')
287
+
288
+ // New models should be added
289
+ expect(Object.keys(provider.models!)).toContain('gpt-4')
290
+ })
291
+ })
@@ -0,0 +1,190 @@
1
+ import type { PluginConfig, OpencodeModelConfig } from './types.js'
2
+ import type { Config } from '@opencode-ai/plugin'
3
+
4
+ interface LiteLLMHealthModel {
5
+ model: string
6
+ model_id: string
7
+ }
8
+
9
+ interface LiteLLMModelInfo {
10
+ model_name?: string
11
+ max_tokens?: number
12
+ max_input_tokens?: number
13
+ max_output_tokens?: number
14
+ supports_function_calling?: boolean
15
+ supports_reasoning?: boolean
16
+ supports_vision?: boolean
17
+ supports_audio_input?: boolean
18
+ supports_pdf_input?: boolean
19
+ input_cost_per_token?: number
20
+ output_cost_per_token?: number
21
+ cache_read_input_token_cost?: number
22
+ cache_creation_input_token_cost?: number
23
+ }
24
+
25
+ /**
26
+ * Fetches available models from the LiteLLM proxy's /health endpoint,
27
+ * then fetches rich metadata from /model/info for each model.
28
+ * Maps the metadata to OpenCode's model config format.
29
+ */
30
+ export async function discoverModels(
31
+ config: PluginConfig,
32
+ getToken: () => Promise<string>,
33
+ ): Promise<Record<string, OpencodeModelConfig>> {
34
+ const token = await getToken()
35
+
36
+ const controller = new AbortController()
37
+ const timeoutId = setTimeout(() => controller.abort(), 15_000)
38
+
39
+ try {
40
+ // Step 1: Get model list with internal UUIDs from /health
41
+ const healthResponse = await fetch(`${config.url}/health`, {
42
+ method: 'GET',
43
+ headers: {
44
+ Authorization: `Bearer ${token}`,
45
+ },
46
+ signal: controller.signal,
47
+ })
48
+
49
+ if (healthResponse.status === 403) {
50
+ throw new Error(
51
+ 'Access denied. Contact your admin to grant access to the LLM proxy.',
52
+ )
53
+ }
54
+
55
+ if (!healthResponse.ok) {
56
+ return {}
57
+ }
58
+
59
+ const healthBody = await healthResponse.json()
60
+ const healthyEndpoints = healthBody.healthy_endpoints as LiteLLMHealthModel[] | undefined
61
+ if (!Array.isArray(healthyEndpoints)) return {}
62
+
63
+ // Step 2: Fetch rich metadata for each model in parallel
64
+ const modelInfos = await Promise.all(
65
+ healthyEndpoints.map(async (endpoint) => {
66
+ try {
67
+ const infoResponse = await fetch(
68
+ `${config.url}/model/info?litellm_model_id=${encodeURIComponent(endpoint.model_id)}`,
69
+ {
70
+ method: 'GET',
71
+ headers: {
72
+ Authorization: `Bearer ${token}`,
73
+ },
74
+ signal: controller.signal,
75
+ },
76
+ )
77
+
78
+ if (!infoResponse.ok) return null
79
+
80
+ const infoBody = await infoResponse.json()
81
+ const data = infoBody.data as Array<{ model_name?: string; model_info?: LiteLLMModelInfo }> | undefined
82
+ if (!Array.isArray(data) || !data[0]) return null
83
+
84
+ return { model_name: data[0].model_name, ...data[0].model_info }
85
+ } catch {
86
+ return null
87
+ }
88
+ }),
89
+ )
90
+
91
+ // Step 3: Map to OpenCode model config
92
+ const models: Record<string, OpencodeModelConfig> = {}
93
+
94
+ for (let i = 0; i < healthyEndpoints.length; i++) {
95
+ const info = modelInfos[i]
96
+ if (!info?.model_name) continue
97
+
98
+ const modelName = info.model_name
99
+
100
+ const inputModalities: Array<'text' | 'audio' | 'image' | 'video' | 'pdf'> = ['text']
101
+ if (info.supports_vision) inputModalities.push('image')
102
+ if (info.supports_audio_input) inputModalities.push('audio')
103
+ if (info.supports_pdf_input) inputModalities.push('pdf')
104
+
105
+ const modelConfig: OpencodeModelConfig = {
106
+ name: modelName,
107
+ tool_call: info.supports_function_calling ?? true,
108
+ reasoning: info.supports_reasoning ?? false,
109
+ limit: {
110
+ context: info.max_input_tokens ?? 32768,
111
+ output: info.max_output_tokens ?? info.max_tokens ?? 32768,
112
+ },
113
+ modalities: {
114
+ input: inputModalities,
115
+ output: ['text'],
116
+ },
117
+ }
118
+
119
+ // Add cost info if available
120
+ if (info.input_cost_per_token != null && info.output_cost_per_token != null) {
121
+ modelConfig.cost = {
122
+ input: info.input_cost_per_token,
123
+ output: info.output_cost_per_token,
124
+ }
125
+ if (info.cache_read_input_token_cost != null) {
126
+ modelConfig.cost.cache_read = info.cache_read_input_token_cost
127
+ }
128
+ if (info.cache_creation_input_token_cost != null) {
129
+ modelConfig.cost.cache_write = info.cache_creation_input_token_cost
130
+ }
131
+ }
132
+
133
+ models[modelName] = modelConfig
134
+ }
135
+
136
+ return models
137
+ } catch (error: unknown) {
138
+ // Timeout (AbortError) or network error → return empty object
139
+ if (error instanceof Error && error.name === 'AbortError') {
140
+ return {}
141
+ }
142
+ // Re-throw the 403 descriptive error
143
+ if (error instanceof Error && error.message.includes('Access denied')) {
144
+ throw error
145
+ }
146
+ // Network errors → return empty object
147
+ return {}
148
+ } finally {
149
+ clearTimeout(timeoutId)
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Injects discovered models into an OpenCode config under the given provider name.
155
+ * Merges with existing provider config without overwriting options.
156
+ */
157
+ export function injectModelsIntoConfig(
158
+ config: Config,
159
+ providerName: string,
160
+ baseUrl: string,
161
+ apiKey: string,
162
+ models: Record<string, OpencodeModelConfig>,
163
+ ): void {
164
+ if (!config.provider) {
165
+ config.provider = {}
166
+ }
167
+
168
+ const existing = config.provider[providerName]
169
+
170
+ if (existing) {
171
+ // Preserve existing options, merge models
172
+ // Set defaults for incomplete provider entries
173
+ if (!existing.npm) existing.npm = '@ai-sdk/openai-compatible'
174
+ if (!existing.name) existing.name = providerName
175
+ if (!existing.options) {
176
+ existing.options = { baseURL: baseUrl, apiKey }
177
+ }
178
+ existing.models = { ...existing.models, ...models }
179
+ } else {
180
+ config.provider[providerName] = {
181
+ npm: '@ai-sdk/openai-compatible',
182
+ name: providerName,
183
+ options: {
184
+ baseURL: baseUrl,
185
+ apiKey,
186
+ },
187
+ models,
188
+ }
189
+ }
190
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { LiteLLMPlugin } from './plugin.js'
@@ -0,0 +1,180 @@
1
+ import { vi, describe, it, expect, beforeEach } from 'vitest'
2
+ import type { PluginInput } from '@opencode-ai/plugin'
3
+ import type { OpencodeModelConfig } from './types.js'
4
+
5
+ // Mock the discovery module
6
+ vi.mock('./discovery.js', () => ({
7
+ discoverModels: vi.fn(),
8
+ injectModelsIntoConfig: vi.fn(),
9
+ }))
10
+
11
+ // Mock the utils module
12
+ vi.mock('./utils.js', () => ({
13
+ resolvePluginConfig: vi.fn(),
14
+ getProviderId: vi.fn(() => 'litellm'),
15
+ mapLiteLLMModel: vi.fn(),
16
+ }))
17
+
18
+ import { LiteLLMPlugin } from './plugin.js'
19
+ import { discoverModels, injectModelsIntoConfig } from './discovery.js'
20
+ import { resolvePluginConfig } from './utils.js'
21
+
22
+ function createMockInput(): PluginInput {
23
+ const logFn = vi.fn().mockResolvedValue(true)
24
+ return {
25
+ client: {
26
+ app: { log: logFn },
27
+ } as never,
28
+ $: vi.fn() as never,
29
+ project: {} as never,
30
+ directory: '/test',
31
+ worktree: '/test',
32
+ serverUrl: new URL('http://localhost'),
33
+ experimental_workspace: {
34
+ register: vi.fn(),
35
+ },
36
+ }
37
+ }
38
+
39
+ describe('LiteLLMPlugin', () => {
40
+ let mockInput: PluginInput
41
+ let logFn: ReturnType<typeof vi.fn>
42
+
43
+ beforeEach(() => {
44
+ vi.resetAllMocks()
45
+
46
+ mockInput = createMockInput()
47
+ logFn = mockInput.client.app.log as ReturnType<typeof vi.fn>
48
+
49
+ const models: Record<string, OpencodeModelConfig> = {
50
+ 'gpt-4': {
51
+ name: 'gpt-4',
52
+ tool_call: true,
53
+ reasoning: false,
54
+ limit: { context: 8192, output: 8192 },
55
+ modalities: { input: ['text'], output: ['text'] },
56
+ },
57
+ }
58
+ vi.mocked(discoverModels).mockResolvedValue(models)
59
+ vi.mocked(injectModelsIntoConfig).mockImplementation(() => {})
60
+ vi.mocked(resolvePluginConfig).mockReturnValue({
61
+ url: 'https://litellm.example.com',
62
+ apiKey: 'test-api-key',
63
+ })
64
+ })
65
+
66
+ it('throws on missing config', async () => {
67
+ vi.mocked(resolvePluginConfig).mockReturnValue(null)
68
+
69
+ await expect(
70
+ LiteLLMPlugin(mockInput, {})
71
+ ).rejects.toThrow(
72
+ "Plugin config error: set 'url' and 'apiKey'",
73
+ )
74
+ })
75
+
76
+ it('config hook calls discoverModels and injects models into config', async () => {
77
+ const hooks = await LiteLLMPlugin(mockInput, {
78
+ url: 'https://litellm.example.com',
79
+ apiKey: 'test-api-key',
80
+ })
81
+
82
+ const testConfig = {} as never
83
+ await hooks.config?.(testConfig)
84
+
85
+ expect(discoverModels).toHaveBeenCalledWith(
86
+ { url: 'https://litellm.example.com', apiKey: 'test-api-key' },
87
+ expect.any(Function),
88
+ )
89
+ expect(injectModelsIntoConfig).toHaveBeenCalledWith(
90
+ testConfig,
91
+ 'litellm',
92
+ 'https://litellm.example.com',
93
+ 'test-api-key',
94
+ expect.any(Object),
95
+ )
96
+ expect(logFn).toHaveBeenCalledWith(
97
+ expect.objectContaining({
98
+ body: expect.objectContaining({
99
+ service: 'litellm',
100
+ level: 'info',
101
+ message: expect.stringContaining('Discovered'),
102
+ }),
103
+ }),
104
+ )
105
+ })
106
+
107
+ it('config hook catches errors and does not throw', async () => {
108
+ vi.mocked(discoverModels).mockRejectedValue(new Error('Network error'))
109
+
110
+ const hooks = await LiteLLMPlugin(mockInput, {
111
+ url: 'https://litellm.example.com',
112
+ apiKey: 'test-api-key',
113
+ })
114
+
115
+ await expect(hooks.config?.({} as never)).resolves.not.toThrow()
116
+
117
+ expect(logFn).toHaveBeenCalledWith(
118
+ expect.objectContaining({
119
+ body: expect.objectContaining({
120
+ service: 'litellm',
121
+ level: 'warn',
122
+ message: expect.stringContaining('Model discovery failed'),
123
+ }),
124
+ }),
125
+ )
126
+ })
127
+
128
+ it('config hook logs warning and skips inject when no models discovered', async () => {
129
+ vi.mocked(discoverModels).mockResolvedValue({})
130
+
131
+ const hooks = await LiteLLMPlugin(mockInput, {
132
+ url: 'https://litellm.example.com',
133
+ apiKey: 'test-api-key',
134
+ })
135
+
136
+ await hooks.config?.({} as never)
137
+
138
+ expect(injectModelsIntoConfig).not.toHaveBeenCalled()
139
+ expect(logFn).toHaveBeenCalledWith(
140
+ expect.objectContaining({
141
+ body: expect.objectContaining({
142
+ service: 'litellm',
143
+ level: 'warn',
144
+ message: 'No models discovered',
145
+ }),
146
+ }),
147
+ )
148
+ })
149
+
150
+ it('auth hook returns success when API key is provided', async () => {
151
+ const hooks = await LiteLLMPlugin(mockInput, {
152
+ url: 'https://litellm.example.com',
153
+ apiKey: 'test-api-key',
154
+ })
155
+
156
+ const result = await hooks.auth?.methods[0].authorize?.({ apiKey: 'user-pasted-key' })
157
+
158
+ expect(result).toEqual({ type: 'success', key: 'user-pasted-key' })
159
+ })
160
+
161
+ it('auth hook returns failed when API key is empty', async () => {
162
+ const hooks = await LiteLLMPlugin(mockInput, {
163
+ url: 'https://litellm.example.com',
164
+ apiKey: 'test-api-key',
165
+ })
166
+
167
+ const result = await hooks.auth?.methods[0].authorize?.({ apiKey: '' })
168
+ expect(result).toEqual({ type: 'failed' })
169
+ })
170
+
171
+ it('auth hook returns failed when no inputs provided', async () => {
172
+ const hooks = await LiteLLMPlugin(mockInput, {
173
+ url: 'https://litellm.example.com',
174
+ apiKey: 'test-api-key',
175
+ })
176
+
177
+ const result = await hooks.auth?.methods[0].authorize?.()
178
+ expect(result).toEqual({ type: 'failed' })
179
+ })
180
+ })
package/src/plugin.ts ADDED
@@ -0,0 +1,104 @@
1
+ import type { Plugin, PluginInput, PluginOptions } from '@opencode-ai/plugin'
2
+ import { resolvePluginConfig, getProviderId } from './utils.js'
3
+ import { discoverModels, injectModelsIntoConfig } from './discovery.js'
4
+
5
+ /**
6
+ * Main plugin entry point for the LiteLLM provider.
7
+ *
8
+ * Wires together config (model discovery), auth (/connect API key flow),
9
+ * and chat.headers (per-request Bearer token injection).
10
+ *
11
+ * Auth: LITELLM_URL / LITELLM_KEY env vars take precedence,
12
+ * with fallback to values in opencode.json plugin options.
13
+ */
14
+ export const LiteLLMPlugin: Plugin = async (
15
+ input: PluginInput,
16
+ options?: PluginOptions,
17
+ ) => {
18
+ const pluginConfig = resolvePluginConfig(options)
19
+ if (pluginConfig === null) {
20
+ throw new Error(
21
+ "Plugin config error: set 'url' and 'apiKey' in plugin options, " +
22
+ "or set LITELLM_URL and LITELLM_KEY environment variables.",
23
+ )
24
+ }
25
+
26
+ const providerId = getProviderId()
27
+
28
+ return {
29
+ /**
30
+ * Config hook — discovers models from the LiteLLM proxy and injects
31
+ * them into the OpenCode config under the provider.
32
+ */
33
+ config: async (config) => {
34
+ try {
35
+ const models = await discoverModels(
36
+ pluginConfig,
37
+ () => Promise.resolve(pluginConfig.apiKey),
38
+ )
39
+
40
+ if (Object.keys(models).length === 0) {
41
+ await input.client.app.log({
42
+ body: {
43
+ service: providerId,
44
+ level: 'warn',
45
+ message: 'No models discovered',
46
+ },
47
+ })
48
+ return
49
+ }
50
+
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
+ })
65
+ } catch (error) {
66
+ await input.client.app.log({
67
+ body: {
68
+ service: providerId,
69
+ level: 'warn',
70
+ message: `Model discovery failed: ${error}`,
71
+ },
72
+ })
73
+ }
74
+ },
75
+
76
+ /**
77
+ * Auth hook — lets the user paste an API key via the /connect flow.
78
+ * The key is stored in OpenCode's auth store and used as the Bearer token.
79
+ */
80
+ auth: {
81
+ provider: providerId,
82
+ methods: [
83
+ {
84
+ type: 'api' as const,
85
+ label: 'LiteLLM API Key',
86
+ prompts: [
87
+ {
88
+ type: 'text' as const,
89
+ key: 'apiKey',
90
+ message: 'API key',
91
+ placeholder: 'sk-...',
92
+ },
93
+ ],
94
+ async authorize(inputs) {
95
+ if (!inputs?.apiKey || inputs.apiKey.length === 0) {
96
+ return { type: 'failed' as const }
97
+ }
98
+ return { type: 'success' as const, key: inputs.apiKey }
99
+ },
100
+ },
101
+ ],
102
+ },
103
+ }
104
+ }
package/src/types.ts ADDED
@@ -0,0 +1,33 @@
1
+ export interface PluginConfig {
2
+ url: string
3
+ apiKey: string
4
+ }
5
+
6
+ export interface LiteLLMModel {
7
+ id: string
8
+ max_model_len?: number
9
+ }
10
+
11
+ export interface OpencodeModelConfig {
12
+ name: string
13
+ tool_call?: boolean
14
+ reasoning?: boolean
15
+ limit?: {
16
+ context: number
17
+ output: number
18
+ }
19
+ cost?: {
20
+ input: number
21
+ output: number
22
+ cache_read?: number
23
+ cache_write?: number
24
+ context_over_200k?: {
25
+ input: number
26
+ output: number
27
+ }
28
+ }
29
+ modalities?: {
30
+ input: Array<"text" | "audio" | "image" | "video" | "pdf">
31
+ output: Array<"text" | "audio" | "image" | "video" | "pdf">
32
+ }
33
+ }
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { mapLiteLLMModel, resolvePluginConfig } from './utils.js'
3
+ import type { LiteLLMModel } from './types.js'
4
+
5
+ describe('mapLiteLLMModel', () => {
6
+ it('maps basic model correctly', () => {
7
+ const model: LiteLLMModel = { id: 'gpt-4o' }
8
+ const result = mapLiteLLMModel(model)
9
+
10
+ expect(result.name).toBe('gpt-4o')
11
+ expect(result.tool_call).toBe(true)
12
+ expect(result.reasoning).toBe(false)
13
+ expect(result.limit).toEqual({ context: 32768, output: 32768 })
14
+ expect(result.modalities).toEqual({ input: ['text'], output: ['text'] })
15
+ })
16
+
17
+ it('matches reasoning heuristic for qwen3 models', () => {
18
+ const model: LiteLLMModel = { id: 'qwen/qwen3.6-27b' }
19
+ expect(mapLiteLLMModel(model).reasoning).toBe(true)
20
+ })
21
+
22
+ it('matches reasoning heuristic for deepseek-r1', () => {
23
+ const model: LiteLLMModel = { id: 'deepseek-r1' }
24
+ expect(mapLiteLLMModel(model).reasoning).toBe(true)
25
+ })
26
+
27
+ it('matches reasoning heuristic for o3-mini', () => {
28
+ const model: LiteLLMModel = { id: 'o3-mini' }
29
+ expect(mapLiteLLMModel(model).reasoning).toBe(true)
30
+ })
31
+
32
+ it('does NOT match reasoning for gpt-4o', () => {
33
+ const model: LiteLLMModel = { id: 'gpt-4o' }
34
+ expect(mapLiteLLMModel(model).reasoning).toBe(false)
35
+ })
36
+
37
+ it('does NOT match reasoning for claude-sonnet-4', () => {
38
+ const model: LiteLLMModel = { id: 'claude-sonnet-4' }
39
+ expect(mapLiteLLMModel(model).reasoning).toBe(false)
40
+ })
41
+
42
+ it('uses custom max_model_len for limits', () => {
43
+ const model: LiteLLMModel = { id: 'gpt-4', max_model_len: 128000 }
44
+ const result = mapLiteLLMModel(model)
45
+
46
+ expect(result.limit).toEqual({ context: 128000, output: 128000 })
47
+ })
48
+ })
49
+
50
+ describe('resolvePluginConfig', () => {
51
+ const originalEnv = { ...process.env }
52
+
53
+ afterEach(() => {
54
+ process.env = { ...originalEnv }
55
+ })
56
+
57
+ describe('environment variable priority', () => {
58
+ it('returns config from env vars when both are set', () => {
59
+ process.env.LITELLM_URL = 'https://env.example.com'
60
+ process.env.LITELLM_KEY = 'env-key-123'
61
+
62
+ const config = resolvePluginConfig({ url: 'https://config.example.com', apiKey: 'config-key' })
63
+ expect(config).toEqual({ url: 'https://env.example.com', apiKey: 'env-key-123' })
64
+ })
65
+
66
+ it('ignores config options when env vars are set', () => {
67
+ process.env.LITELLM_URL = 'https://env.example.com'
68
+ process.env.LITELLM_KEY = 'env-key-123'
69
+
70
+ const config = resolvePluginConfig({ url: 'https://different.example.com', apiKey: 'different-key' })
71
+ expect(config).toEqual({ url: 'https://env.example.com', apiKey: 'env-key-123' })
72
+ })
73
+
74
+ it('falls back to config when only one env var is set', () => {
75
+ process.env.LITELLM_URL = 'https://env.example.com'
76
+ delete process.env.LITELLM_KEY
77
+
78
+ const config = resolvePluginConfig({ url: 'https://config.example.com', apiKey: 'config-key' })
79
+ expect(config).toEqual({ url: 'https://config.example.com', apiKey: 'config-key' })
80
+ })
81
+
82
+ it('falls back to config when env vars are empty strings', () => {
83
+ process.env.LITELLM_URL = ''
84
+ process.env.LITELLM_KEY = ''
85
+
86
+ const config = resolvePluginConfig({ url: 'https://config.example.com', apiKey: 'config-key' })
87
+ expect(config).toEqual({ url: 'https://config.example.com', apiKey: 'config-key' })
88
+ })
89
+ })
90
+
91
+ describe('config options fallback', () => {
92
+ beforeEach(() => {
93
+ delete process.env.LITELLM_URL
94
+ delete process.env.LITELLM_KEY
95
+ })
96
+
97
+ it('returns config for valid input', () => {
98
+ const config = resolvePluginConfig({ url: 'https://config.example.com', apiKey: 'my-api-key' })
99
+ expect(config).toEqual({ url: 'https://config.example.com', apiKey: 'my-api-key' })
100
+ })
101
+
102
+ it('returns null when url is missing', () => {
103
+ const config = resolvePluginConfig({ apiKey: 'my-api-key' })
104
+ expect(config).toBeNull()
105
+ })
106
+
107
+ it('returns null when apiKey is missing', () => {
108
+ const config = resolvePluginConfig({ url: 'https://config.example.com' })
109
+ expect(config).toBeNull()
110
+ })
111
+
112
+ it('returns null for null input', () => {
113
+ expect(resolvePluginConfig(null)).toBeNull()
114
+ })
115
+
116
+ it('returns null for undefined input', () => {
117
+ expect(resolvePluginConfig(undefined)).toBeNull()
118
+ })
119
+
120
+ it('returns null for non-object input', () => {
121
+ expect(resolvePluginConfig('string')).toBeNull()
122
+ expect(resolvePluginConfig(42)).toBeNull()
123
+ expect(resolvePluginConfig([])).toBeNull()
124
+ })
125
+
126
+ it('returns null for empty string url', () => {
127
+ expect(resolvePluginConfig({ url: '', apiKey: 'valid' })).toBeNull()
128
+ })
129
+
130
+ it('returns null for empty string apiKey', () => {
131
+ expect(resolvePluginConfig({ url: 'https://config.example.com', apiKey: '' })).toBeNull()
132
+ })
133
+
134
+ it('returns null when neither env vars nor config are available', () => {
135
+ delete process.env.LITELLM_URL
136
+ delete process.env.LITELLM_KEY
137
+ expect(resolvePluginConfig({})).toBeNull()
138
+ })
139
+ })
140
+ })
package/src/utils.ts ADDED
@@ -0,0 +1,64 @@
1
+ import type { LiteLLMModel, OpencodeModelConfig, PluginConfig } from './types.js'
2
+
3
+ /**
4
+ * Maps a LiteLLM model to OpenCode model config format.
5
+ */
6
+ export function mapLiteLLMModel(model: LiteLLMModel): OpencodeModelConfig {
7
+ const maxLen = model.max_model_len ?? 32768
8
+ const reasoning = /qwen3|deepseek-r1|o[134]/i.test(model.id)
9
+
10
+ return {
11
+ name: model.id,
12
+ tool_call: true,
13
+ reasoning,
14
+ limit: {
15
+ context: maxLen,
16
+ output: maxLen,
17
+ },
18
+ modalities: {
19
+ input: ['text'],
20
+ output: ['text'],
21
+ },
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Resolves plugin configuration from environment variables or config options.
27
+ *
28
+ * Priority:
29
+ * 1. LITELLM_URL / LITELLM_KEY environment variables
30
+ * 2. Values from opencode.json plugin options
31
+ *
32
+ * Returns null if no source provides both url and apiKey.
33
+ */
34
+ export function resolvePluginConfig(rawConfig: unknown): PluginConfig | null {
35
+ const envUrl = typeof process !== 'undefined' ? process.env.LITELLM_URL : undefined
36
+ const envKey = typeof process !== 'undefined' ? process.env.LITELLM_KEY : undefined
37
+
38
+ const hasEnvVars = envUrl !== undefined && envUrl.length > 0 &&
39
+ envKey !== undefined && envKey.length > 0
40
+
41
+ if (hasEnvVars) {
42
+ return { url: envUrl, apiKey: envKey }
43
+ }
44
+
45
+ // Fall back to config options from opencode.json
46
+ if (rawConfig && typeof rawConfig === 'object' && !Array.isArray(rawConfig)) {
47
+ const obj = rawConfig as Record<string, unknown>
48
+ const configUrl = typeof obj.url === 'string' ? obj.url : ''
49
+ const configKey = typeof obj.apiKey === 'string' ? obj.apiKey : ''
50
+
51
+ if (configUrl.length > 0 && configKey.length > 0) {
52
+ return { url: configUrl, apiKey: configKey }
53
+ }
54
+ }
55
+
56
+ return null
57
+ }
58
+
59
+ /**
60
+ * Gets the provider ID from environment variable or defaults to "LiteLLM".
61
+ */
62
+ export function getProviderId(): string {
63
+ return (typeof process !== 'undefined' ? process.env.LITELLM_PROVIDER_ID : undefined) || 'LiteLLM'
64
+ }