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 +90 -0
- package/package.json +43 -0
- package/src/discovery.test.ts +291 -0
- package/src/discovery.ts +190 -0
- package/src/index.ts +1 -0
- package/src/plugin.test.ts +180 -0
- package/src/plugin.ts +104 -0
- package/src/types.ts +33 -0
- package/src/utils.test.ts +140 -0
- package/src/utils.ts +64 -0
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
|
+
})
|
package/src/discovery.ts
ADDED
|
@@ -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
|
+
}
|