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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # opencode-provider-litellm
2
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.
3
+ OpenCode plugin for any [LiteLLM](https://github.com/BerriAI/litellm) proxy — auto-discovers models, MCP tools, and auth. Zero config, zero hand-maintained model lists.
4
4
 
5
5
  ## Quick start
6
6
 
@@ -15,7 +15,7 @@ opencode plugin opencode-provider-litellm
15
15
  # 3. Restart OpenCode
16
16
  ```
17
17
 
18
- All models from your LiteLLM proxy appear in OpenCode's model picker automatically.
18
+ All models and MCP tools from your LiteLLM proxy appear in OpenCode automatically.
19
19
 
20
20
  ## Configuration
21
21
 
@@ -26,6 +26,8 @@ All models from your LiteLLM proxy appear in OpenCode's model picker automatical
26
26
  | `LITELLM_URL` | Your LiteLLM proxy base URL |
27
27
  | `LITELLM_KEY` | API key for the proxy |
28
28
  | `LITELLM_PROVIDER_ID` | Provider ID in OpenCode (defaults to `LiteLLM`) |
29
+ | `LITELLM_GCLOUD_TOKEN_AUTH` | Set to `1` to use Google ADC for auth (makes `LITELLM_KEY` optional) |
30
+ | `GOOGLE_APPLICATION_CREDENTIALS` | Path to a Google ADC JSON file (used when `LITELLM_GCLOUD_TOKEN_AUTH=1`) |
29
31
 
30
32
  ### Inline config
31
33
 
@@ -42,9 +44,29 @@ Alternatively, provide `url` and `apiKey` directly in your `opencode.json`:
42
44
  }
43
45
  ```
44
46
 
45
- :::tip
46
- Environment variables take precedence over inline config. Use env vars to keep secrets out of checked-in files.
47
- :::
47
+ > **Tip:** Environment variables take precedence over inline config. Use env vars to keep secrets out of checked-in files.
48
+
49
+ ### Google Vertex AI (gcloud token auth)
50
+
51
+ When your LiteLLM proxy is backed by Google Vertex AI, you can skip `LITELLM_KEY` and let the plugin automatically fetch a gcloud OAuth token:
52
+
53
+ ```bash
54
+ # 1. Authenticate with gcloud (creates an ADC JSON file)
55
+ gcloud auth application-default login
56
+
57
+ # 2. Set env vars (LITELLM_KEY is optional)
58
+ export LITELLM_URL="https://your-litellm-proxy.example.com"
59
+ export LITELLM_GCLOUD_TOKEN_AUTH=1
60
+
61
+ # 3. Install and restart OpenCode
62
+ opencode plugin opencode-provider-litellm
63
+ ```
64
+
65
+ The plugin reads your [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials) JSON file and exchanges the refresh token for an access token before every LLM request. Tokens are cached for 50 minutes.
66
+
67
+ To use a custom credentials file, set `GOOGLE_APPLICATION_CREDENTIALS` to its path.
68
+
69
+ > **Note:** Only `authorized_user` credentials (from `gcloud auth application-default login`) are supported. Service account keys are not yet supported.
48
70
 
49
71
  ### /connect flow
50
72
 
@@ -56,21 +78,45 @@ You can also authenticate interactively via the OpenCode TUI:
56
78
 
57
79
  The key is stored in OpenCode's auth store.
58
80
 
81
+ ## Features
82
+
83
+ ### Model discovery
84
+
85
+ Queries LiteLLM on startup and injects all models with rich metadata into OpenCode:
86
+
87
+ - `/health` — model list with internal UUIDs
88
+ - `/model/info?litellm_model_id={uuid}` — costs, context limits, vision, tool calling, reasoning, etc.
89
+
90
+ Custom `model_info` updates via `/model/update` are respected — no hardcoded fallbacks.
91
+
92
+ ### MCP tools
93
+
94
+ Discovers tools registered on LiteLLM's MCP servers at startup and exposes them as native OpenCode tools. Each tool keeps its original description and parameter schema.
95
+
96
+ ### Skills
97
+
98
+ Skills registered in LiteLLM's [Skills Gateway](https://docs.litellm.ai/docs/skills_gateway) are made available to OpenCode via the [proxy-sidecar](../llm-server/proxy-sidecar/), which serves skills in OpenCode's native format. Add the sidecar URL to your config:
99
+
100
+ ```jsonc
101
+ {
102
+ "skills": {
103
+ "urls": ["https://your-litellm-proxy.example.com/opencode/skills"]
104
+ }
105
+ }
106
+ ```
107
+
108
+ Skills appear in OpenCode's `/skills` menu and are loaded natively by the agent.
109
+
59
110
  ## How it works
60
111
 
61
- The plugin uses two OpenCode hooks:
112
+ The plugin uses three OpenCode hooks:
62
113
 
63
114
  | Hook | Purpose |
64
115
  |------|---------|
65
- | `config` | Queries `/health` + `/model/info` on startup, discovers models with rich metadata (costs, limits, capabilities), and injects them into OpenCode |
116
+ | `config` | Discovers models from LiteLLM and injects them into OpenCode |
66
117
  | `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.
118
+ | `tool` | Exposes discovered MCP tools as native OpenCode tools |
119
+ | `chat.headers` | Injects `Authorization: Bearer <token>` when `LITELLM_GCLOUD_TOKEN_AUTH=1` |
74
120
 
75
121
  ## Troubleshooting
76
122
 
@@ -79,6 +125,7 @@ Custom model_info updates via `/model/update` are respected — no hardcoded fal
79
125
  | "Plugin config error" | Set `LITELLM_URL` and `LITELLM_KEY`, or add `url`/`apiKey` to plugin options |
80
126
  | "Access denied" (403) | Verify the API key has access to the LiteLLM proxy |
81
127
  | "No models discovered" | Check that the proxy is reachable and the `/health` endpoint responds |
128
+ | Skills not showing | Verify the proxy-sidecar is running and the skills URL is in `opencode.json` |
82
129
 
83
130
  ## Development
84
131
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-provider-litellm",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "OpenCode plugin for any LiteLLM proxy — auto-discovers models, auth, and capabilities",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,255 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { getGcloudToken, resetTokenCache, CACHE_TTL } from './gcloud-token.js'
3
+
4
+ const mockReadFileSync = vi.hoisted(() => vi.fn())
5
+ const mockExistsSync = vi.hoisted(() => vi.fn())
6
+ const mockFetch = vi.hoisted(() => vi.fn())
7
+
8
+ vi.mock('fs', () => ({
9
+ get readFileSync() { return mockReadFileSync },
10
+ get existsSync() { return mockExistsSync },
11
+ }))
12
+
13
+ // Mock global fetch
14
+ beforeEach(() => {
15
+ vi.stubGlobal('fetch', mockFetch)
16
+ })
17
+
18
+ afterEach(() => {
19
+ vi.unstubAllGlobals()
20
+ vi.unstubAllEnvs()
21
+ })
22
+
23
+ const authorizedUserCredentials = {
24
+ type: 'authorized_user',
25
+ client_id: 'test-client-id.apps.googleusercontent.com',
26
+ client_secret: 'test-client-secret',
27
+ refresh_token: 'test-refresh-token',
28
+ account: 'test@example.com',
29
+ }
30
+
31
+ const serviceAccountCredentials = {
32
+ type: 'service_account',
33
+ private_key_id: 'key123',
34
+ private_key: '-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----\n',
35
+ client_email: 'test@project.iam.gserviceaccount.com',
36
+ client_id: '123456789',
37
+ auth_uri: 'https://accounts.google.com/o/oauth2/auth',
38
+ token_uri: 'https://oauth2.googleapis.com/token',
39
+ }
40
+
41
+ describe('getGcloudToken', () => {
42
+ afterEach(() => {
43
+ vi.clearAllMocks()
44
+ vi.unstubAllEnvs()
45
+ resetTokenCache()
46
+ })
47
+
48
+ it('returns token from authorized_user credentials', async () => {
49
+ vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', '/tmp/adc.json')
50
+ vi.stubEnv('HOME', '/home/test')
51
+
52
+ mockReadFileSync.mockReturnValue(JSON.stringify(authorizedUserCredentials))
53
+
54
+ mockFetch.mockResolvedValue({
55
+ ok: true,
56
+ json: async () => ({ access_token: 'exchanged-access-token' }),
57
+ })
58
+
59
+ const token = await getGcloudToken()
60
+ expect(token).toBe('exchanged-access-token')
61
+
62
+ expect(mockFetch).toHaveBeenCalledWith(
63
+ 'https://oauth2.googleapis.com/token',
64
+ expect.objectContaining({
65
+ method: 'POST',
66
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
67
+ }),
68
+ )
69
+ })
70
+
71
+ it('caches token within TTL', async () => {
72
+ vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', '/tmp/adc.json')
73
+ vi.stubEnv('HOME', '/home/test')
74
+
75
+ mockReadFileSync.mockReturnValue(JSON.stringify(authorizedUserCredentials))
76
+ mockFetch.mockResolvedValue({
77
+ ok: true,
78
+ json: async () => ({ access_token: 'cached-token' }),
79
+ })
80
+
81
+ await getGcloudToken()
82
+ const cached = await getGcloudToken()
83
+ expect(cached).toBe('cached-token')
84
+ expect(mockFetch).toHaveBeenCalledTimes(1)
85
+ })
86
+
87
+ it('returns null when ADC file not found (no env, no default)', async () => {
88
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
89
+
90
+ vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', undefined)
91
+ vi.stubEnv('HOME', undefined)
92
+ vi.stubEnv('USERPROFILE', undefined)
93
+ vi.stubEnv('APPDATA', undefined)
94
+
95
+ const token = await getGcloudToken()
96
+ expect(token).toBeNull()
97
+ expect(warnSpy).toHaveBeenCalled()
98
+
99
+ warnSpy.mockRestore()
100
+ })
101
+
102
+ it('returns null when ADC file is invalid JSON', async () => {
103
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
104
+
105
+ vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', '/tmp/adc.json')
106
+ vi.stubEnv('HOME', '/home/test')
107
+
108
+ mockReadFileSync.mockReturnValue('not valid json{{{')
109
+
110
+ const token = await getGcloudToken()
111
+ expect(token).toBeNull()
112
+ expect(warnSpy).toHaveBeenCalled()
113
+
114
+ warnSpy.mockRestore()
115
+ })
116
+
117
+ it('returns null and warns for service_account type', async () => {
118
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
119
+
120
+ vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', '/tmp/adc.json')
121
+ vi.stubEnv('HOME', '/home/test')
122
+
123
+ mockReadFileSync.mockReturnValue(JSON.stringify(serviceAccountCredentials))
124
+
125
+ const token = await getGcloudToken()
126
+ expect(token).toBeNull()
127
+ expect(warnSpy).toHaveBeenCalledWith(
128
+ '[opencode-provider-litellm] Service account credentials are not yet supported. Use an authorized_user credential or set GOOGLE_APPLICATION_CREDENTIALS to an authorized_user JSON file.',
129
+ )
130
+
131
+ warnSpy.mockRestore()
132
+ })
133
+
134
+ it('returns null on token exchange failure', async () => {
135
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
136
+
137
+ vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', '/tmp/adc.json')
138
+ vi.stubEnv('HOME', '/home/test')
139
+
140
+ mockReadFileSync.mockReturnValue(JSON.stringify(authorizedUserCredentials))
141
+ mockFetch.mockResolvedValue({
142
+ ok: false,
143
+ status: 400,
144
+ text: async () => '{"error":"invalid_grant"}',
145
+ })
146
+
147
+ const token = await getGcloudToken()
148
+ expect(token).toBeNull()
149
+ expect(warnSpy).toHaveBeenCalled()
150
+
151
+ warnSpy.mockRestore()
152
+ })
153
+
154
+ it('returns null on token exchange network error', async () => {
155
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
156
+
157
+ vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', '/tmp/adc.json')
158
+ vi.stubEnv('HOME', '/home/test')
159
+
160
+ mockReadFileSync.mockReturnValue(JSON.stringify(authorizedUserCredentials))
161
+ mockFetch.mockRejectedValue(new Error('network error'))
162
+
163
+ const token = await getGcloudToken()
164
+ expect(token).toBeNull()
165
+ expect(warnSpy).toHaveBeenCalled()
166
+
167
+ warnSpy.mockRestore()
168
+ })
169
+
170
+ it('reads default ADC location when GOOGLE_APPLICATION_CREDENTIALS is not set', async () => {
171
+ vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', undefined)
172
+ vi.stubEnv('HOME', '/home/test')
173
+ vi.stubEnv('APPDATA', undefined)
174
+
175
+ mockExistsSync.mockReturnValue(true)
176
+ mockReadFileSync.mockReturnValue(JSON.stringify(authorizedUserCredentials))
177
+ mockFetch.mockResolvedValue({
178
+ ok: true,
179
+ json: async () => ({ access_token: 'default-loc-token' }),
180
+ })
181
+
182
+ const token = await getGcloudToken()
183
+ expect(token).toBe('default-loc-token')
184
+ expect(mockReadFileSync).toHaveBeenCalledWith(
185
+ expect.stringContaining('application_default_credentials.json'),
186
+ 'utf-8',
187
+ )
188
+ })
189
+
190
+ it('reads Windows APPDATA ADC location', async () => {
191
+ vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', undefined)
192
+ vi.stubEnv('HOME', undefined)
193
+ vi.stubEnv('APPDATA', 'C:\\Users\\test\\AppData\\Roaming')
194
+
195
+ mockExistsSync.mockImplementation((path: string) => {
196
+ return typeof path === 'string' && path.includes('AppData') && path.includes('gcloud')
197
+ })
198
+ mockReadFileSync.mockReturnValue(JSON.stringify(authorizedUserCredentials))
199
+ mockFetch.mockResolvedValue({
200
+ ok: true,
201
+ json: async () => ({ access_token: 'windows-token' }),
202
+ })
203
+
204
+ const token = await getGcloudToken()
205
+ expect(token).toBe('windows-token')
206
+ // On Linux, path.join mixes slashes; just check it contains the key parts
207
+ expect(mockReadFileSync).toHaveBeenCalledWith(
208
+ expect.stringContaining('gcloud'),
209
+ 'utf-8',
210
+ )
211
+ })
212
+
213
+ it('respects GOOGLE_APPLICATION_CREDENTIALS path over default', async () => {
214
+ vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', '/custom/path/creds.json')
215
+ vi.stubEnv('HOME', '/home/test')
216
+
217
+ mockReadFileSync.mockReturnValue(JSON.stringify(authorizedUserCredentials))
218
+ mockFetch.mockResolvedValue({
219
+ ok: true,
220
+ json: async () => ({ access_token: 'custom-path-token' }),
221
+ })
222
+
223
+ const token = await getGcloudToken()
224
+ expect(token).toBe('custom-path-token')
225
+ expect(mockReadFileSync).toHaveBeenCalledWith('/custom/path/creds.json', 'utf-8')
226
+ })
227
+
228
+ it('stale cache triggers new token fetch', async () => {
229
+ vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', '/tmp/adc.json')
230
+ vi.stubEnv('HOME', '/home/test')
231
+
232
+ mockReadFileSync.mockReturnValue(JSON.stringify(authorizedUserCredentials))
233
+ mockFetch
234
+ .mockResolvedValueOnce({
235
+ ok: true,
236
+ json: async () => ({ access_token: 'token-v1' }),
237
+ })
238
+ .mockResolvedValueOnce({
239
+ ok: true,
240
+ json: async () => ({ access_token: 'token-v2' }),
241
+ })
242
+
243
+ const first = await getGcloudToken()
244
+ expect(first).toBe('token-v1')
245
+
246
+ // Stub the cache to be stale
247
+ vi.spyOn(Date, 'now').mockReturnValue(Date.now() + CACHE_TTL + 1000)
248
+
249
+ const second = await getGcloudToken()
250
+ expect(second).toBe('token-v2')
251
+ expect(mockFetch).toHaveBeenCalledTimes(2)
252
+
253
+ vi.restoreAllMocks()
254
+ })
255
+ })
@@ -0,0 +1,145 @@
1
+ import { existsSync, readFileSync } from 'fs'
2
+ import { join } from 'path'
3
+
4
+ let cachedToken: string | null = null
5
+ let cachedAt: number = 0
6
+ export const CACHE_TTL = 50 * 60 * 1000 // 50 minutes in ms
7
+
8
+ interface AuthorizedUserCredentials {
9
+ type: 'authorized_user'
10
+ client_id: string
11
+ client_secret: string
12
+ refresh_token: string
13
+ account?: string
14
+ universe_domain?: string
15
+ }
16
+
17
+ interface ServiceAccountCredentials {
18
+ type: 'service_account'
19
+ }
20
+
21
+ type GoogleCredentials = AuthorizedUserCredentials | ServiceAccountCredentials
22
+
23
+ const ADC_FILENAME = 'application_default_credentials.json'
24
+
25
+ function getAdcPath(): string | null {
26
+ // 1. GOOGLE_APPLICATION_CREDENTIALS env var (all platforms)
27
+ const envPath = typeof process !== 'undefined' ? process.env.GOOGLE_APPLICATION_CREDENTIALS : undefined
28
+ if (envPath) {
29
+ return envPath
30
+ }
31
+
32
+ // 2. Default ADC locations (Google's official search order)
33
+ const candidates: string[] = []
34
+
35
+ // Linux / macOS: ~/.config/gcloud/
36
+ const home = typeof process !== 'undefined' ? process.env.HOME : undefined
37
+ if (home) {
38
+ candidates.push(join(home, '.config', 'gcloud', ADC_FILENAME))
39
+ }
40
+
41
+ // Windows: %APPDATA%/gcloud/
42
+ const appData = typeof process !== 'undefined' ? process.env.APPDATA : undefined
43
+ if (appData) {
44
+ candidates.push(join(appData, 'gcloud', ADC_FILENAME))
45
+ }
46
+
47
+ for (const path of candidates) {
48
+ if (existsSync(path)) {
49
+ return path
50
+ }
51
+ }
52
+
53
+ return null
54
+ }
55
+
56
+ function readCredentials(path: string): GoogleCredentials | null {
57
+ try {
58
+ const content = readFileSync(path, 'utf-8')
59
+ return JSON.parse(content) as GoogleCredentials
60
+ } catch {
61
+ return null
62
+ }
63
+ }
64
+
65
+ async function exchangeRefreshToken(credentials: AuthorizedUserCredentials): Promise<string | null> {
66
+ const body = new URLSearchParams({
67
+ grant_type: 'refresh_token',
68
+ client_id: credentials.client_id,
69
+ client_secret: credentials.client_secret,
70
+ refresh_token: credentials.refresh_token,
71
+ }).toString()
72
+
73
+ try {
74
+ const response = await fetch('https://oauth2.googleapis.com/token', {
75
+ method: 'POST',
76
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
77
+ body,
78
+ signal: AbortSignal.timeout(10_000),
79
+ })
80
+
81
+ if (!response.ok) {
82
+ const text = await response.text()
83
+ console.warn(`[opencode-provider-litellm] Token exchange failed (${response.status}): ${text}`)
84
+ return null
85
+ }
86
+
87
+ const data = await response.json()
88
+ return data.access_token || null
89
+ } catch (error) {
90
+ console.warn(`[opencode-provider-litellm] Token exchange failed: ${error}`)
91
+ return null
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Gets a Google OAuth access token from the ADC JSON file, cached with a 50-minute TTL.
97
+ * Returns null if credentials are not available or the token cannot be fetched.
98
+ * Logs a warning on failure.
99
+ */
100
+ export async function getGcloudToken(): Promise<string | null> {
101
+ // Return cached token if still valid
102
+ if (cachedToken && (Date.now() - cachedAt) < CACHE_TTL) {
103
+ return cachedToken
104
+ }
105
+
106
+ const adcPath = getAdcPath()
107
+ if (!adcPath) {
108
+ console.warn(
109
+ '[opencode-provider-litellm] No Google ADC file found. Set GOOGLE_APPLICATION_CREDENTIALS or run `gcloud auth application-default login`.',
110
+ )
111
+ return null
112
+ }
113
+
114
+ const credentials = readCredentials(adcPath)
115
+ if (!credentials) {
116
+ console.warn(`[opencode-provider-litellm] Failed to read ADC file: ${adcPath}`)
117
+ return null
118
+ }
119
+
120
+ if (credentials.type === 'authorized_user') {
121
+ const token = await exchangeRefreshToken(credentials)
122
+ if (token) {
123
+ cachedToken = token
124
+ cachedAt = Date.now()
125
+ }
126
+ return token
127
+ }
128
+
129
+ if (credentials.type === 'service_account') {
130
+ console.warn('[opencode-provider-litellm] Service account credentials are not yet supported. Use an authorized_user credential or set GOOGLE_APPLICATION_CREDENTIALS to an authorized_user JSON file.')
131
+ return null
132
+ }
133
+
134
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
135
+ console.warn(`[opencode-provider-litellm] Unknown credential type: ${(credentials as { type: string }).type}`)
136
+ return null
137
+ }
138
+
139
+ /**
140
+ * Resets the token cache. Exported for testing purposes.
141
+ */
142
+ export function resetTokenCache(): void {
143
+ cachedToken = null
144
+ cachedAt = 0
145
+ }