opencode-provider-litellm 0.4.0 → 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
@@ -26,6 +26,8 @@ All models and MCP tools from your LiteLLM proxy appear in OpenCode automaticall
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
 
@@ -94,6 +116,7 @@ The plugin uses three OpenCode hooks:
94
116
  | `config` | Discovers models from LiteLLM and injects them into OpenCode |
95
117
  | `auth` | Provides a `/connect` entry point for pasting an API key |
96
118
  | `tool` | Exposes discovered MCP tools as native OpenCode tools |
119
+ | `chat.headers` | Injects `Authorization: Bearer <token>` when `LITELLM_GCLOUD_TOKEN_AUTH=1` |
97
120
 
98
121
  ## Troubleshooting
99
122
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-provider-litellm",
3
- "version": "0.4.0",
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
+ }
@@ -1,4 +1,4 @@
1
- import { vi, describe, it, expect, beforeEach } from 'vitest'
1
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
2
2
  import type { PluginInput } from '@opencode-ai/plugin'
3
3
  import type { OpencodeModelConfig } from './types.js'
4
4
 
@@ -20,6 +20,12 @@ vi.mock('./mcp-tools.js', () => ({
20
20
  createMcpToolDefinitions: vi.fn(),
21
21
  }))
22
22
 
23
+ // Mock the gcloud token module
24
+ vi.mock('./gcloud-token.js', () => ({
25
+ getGcloudToken: vi.fn(),
26
+ resetTokenCache: vi.fn(),
27
+ }))
28
+
23
29
  import { LiteLLMPlugin } from './plugin.js'
24
30
  import { discoverModels, injectModelsIntoConfig } from './discovery.js'
25
31
  import { resolvePluginConfig } from './utils.js'
@@ -74,6 +80,10 @@ describe('LiteLLMPlugin', () => {
74
80
  })
75
81
  })
76
82
 
83
+ afterEach(() => {
84
+ delete process.env.LITELLM_GCLOUD_TOKEN_AUTH
85
+ })
86
+
77
87
  it('throws on missing config', async () => {
78
88
  vi.mocked(resolvePluginConfig).mockReturnValue(null)
79
89
 
@@ -84,6 +94,28 @@ describe('LiteLLMPlugin', () => {
84
94
  )
85
95
  })
86
96
 
97
+ it('throws gcloud-specific error when LITELLM_GCLOUD_TOKEN_AUTH is set but config is missing', async () => {
98
+ vi.mocked(resolvePluginConfig).mockReturnValue(null)
99
+ process.env.LITELLM_GCLOUD_TOKEN_AUTH = '1'
100
+
101
+ await expect(
102
+ LiteLLMPlugin(mockInput, {})
103
+ ).rejects.toThrow(
104
+ 'LITELLM_KEY is optional when LITELLM_GCLOUD_TOKEN_AUTH=1',
105
+ )
106
+ })
107
+
108
+ it('throws generic error when LITELLM_GCLOUD_TOKEN_AUTH is not set and config is missing', async () => {
109
+ vi.mocked(resolvePluginConfig).mockReturnValue(null)
110
+ delete process.env.LITELLM_GCLOUD_TOKEN_AUTH
111
+
112
+ await expect(
113
+ LiteLLMPlugin(mockInput, {})
114
+ ).rejects.toThrow(
115
+ "Plugin config error: set 'url' and 'apiKey'",
116
+ )
117
+ })
118
+
87
119
  it('config hook calls discoverModels and injects models into config', async () => {
88
120
  const hooks = await LiteLLMPlugin(mockInput, {
89
121
  url: 'https://litellm.example.com',
@@ -252,4 +284,35 @@ describe('LiteLLMPlugin', () => {
252
284
  delete process.env.LITELLM_URL
253
285
  delete process.env.LITELLM_KEY
254
286
  })
287
+
288
+ it('registers chat.headers hook when LITELLM_GCLOUD_TOKEN_AUTH is set', async () => {
289
+ process.env.LITELLM_GCLOUD_TOKEN_AUTH = '1'
290
+
291
+ const { getGcloudToken } = await import('./gcloud-token.js')
292
+ vi.mocked(getGcloudToken).mockResolvedValue('mock-gcloud-token')
293
+
294
+ const hooks = await LiteLLMPlugin(mockInput, {
295
+ url: 'https://litellm.example.com',
296
+ apiKey: 'test-api-key',
297
+ })
298
+
299
+ expect(hooks['chat.headers']).toBeDefined()
300
+ expect(typeof hooks['chat.headers']).toBe('function')
301
+
302
+ // Verify the hook injects the token
303
+ const output = { headers: {} as Record<string, string> }
304
+ await (hooks['chat.headers'] as Function)({}, output)
305
+ expect(output.headers['Authorization']).toBe('Bearer mock-gcloud-token')
306
+ })
307
+
308
+ it('does not register chat.headers hook when LITELLM_GCLOUD_TOKEN_AUTH is unset', async () => {
309
+ delete process.env.LITELLM_GCLOUD_TOKEN_AUTH
310
+
311
+ const hooks = await LiteLLMPlugin(mockInput, {
312
+ url: 'https://litellm.example.com',
313
+ apiKey: 'test-api-key',
314
+ })
315
+
316
+ expect(hooks['chat.headers']).toBeUndefined()
317
+ })
255
318
  })
package/src/plugin.ts CHANGED
@@ -2,6 +2,7 @@ import type { Plugin, PluginInput, PluginOptions } from '@opencode-ai/plugin'
2
2
  import { resolvePluginConfig, getProviderId } from './utils.js'
3
3
  import { discoverModels, injectModelsIntoConfig } from './discovery.js'
4
4
  import { createMcpToolDefinitions } from './mcp-tools.js'
5
+ import { getGcloudToken } from './gcloud-token.js'
5
6
 
6
7
  export const LiteLLMPlugin: Plugin = async (
7
8
  input: PluginInput,
@@ -9,9 +10,15 @@ export const LiteLLMPlugin: Plugin = async (
9
10
  ) => {
10
11
  const pluginConfig = resolvePluginConfig(options)
11
12
  if (pluginConfig === null) {
13
+ const isGcloudAuth = process.env.LITELLM_GCLOUD_TOKEN_AUTH &&
14
+ process.env.LITELLM_GCLOUD_TOKEN_AUTH !== '' &&
15
+ process.env.LITELLM_GCLOUD_TOKEN_AUTH !== '0'
16
+
12
17
  throw new Error(
13
- "Plugin config error: set 'url' and 'apiKey' in plugin options, " +
14
- "or set LITELLM_URL and LITELLM_KEY environment variables.",
18
+ isGcloudAuth
19
+ ? "Plugin config error: set LITELLM_URL (LITELLM_KEY is optional when LITELLM_GCLOUD_TOKEN_AUTH=1)."
20
+ : "Plugin config error: set 'url' and 'apiKey' in plugin options, " +
21
+ "or set LITELLM_URL and LITELLM_KEY environment variables.",
15
22
  )
16
23
  }
17
24
 
@@ -24,7 +31,7 @@ export const LiteLLMPlugin: Plugin = async (
24
31
  console.warn(`[opencode-provider-litellm] MCP tool discovery failed: ${e}`)
25
32
  }
26
33
 
27
- return {
34
+ const result: Record<string, unknown> = {
28
35
  config: async (config: Record<string, any>) => {
29
36
  try {
30
37
  const models = await discoverModels(
@@ -81,11 +88,12 @@ export const LiteLLMPlugin: Plugin = async (
81
88
  placeholder: 'sk-...',
82
89
  },
83
90
  ],
84
- async authorize(inputs) {
85
- if (!inputs?.apiKey || inputs.apiKey.length === 0) {
91
+ async authorize(inputs: Record<string, unknown> | undefined) {
92
+ const apiKey = inputs?.apiKey
93
+ if (!apiKey || typeof apiKey !== 'string' || apiKey.length === 0) {
86
94
  return { type: 'failed' as const }
87
95
  }
88
- return { type: 'success' as const, key: inputs.apiKey }
96
+ return { type: 'success' as const, key: apiKey }
89
97
  },
90
98
  },
91
99
  ],
@@ -95,4 +103,17 @@ export const LiteLLMPlugin: Plugin = async (
95
103
  ...mcpTools,
96
104
  },
97
105
  }
106
+
107
+ if (process.env.LITELLM_GCLOUD_TOKEN_AUTH &&
108
+ process.env.LITELLM_GCLOUD_TOKEN_AUTH !== '' &&
109
+ process.env.LITELLM_GCLOUD_TOKEN_AUTH !== '0') {
110
+ result['chat.headers'] = async (_input: Record<string, unknown>, output: { headers: Record<string, string> }) => {
111
+ const token = await getGcloudToken()
112
+ if (token) {
113
+ output.headers['Authorization'] = `Bearer ${token}`
114
+ }
115
+ }
116
+ }
117
+
118
+ return result
98
119
  }
package/src/utils.test.ts CHANGED
@@ -137,4 +137,55 @@ describe('resolvePluginConfig', () => {
137
137
  expect(resolvePluginConfig({})).toBeNull()
138
138
  })
139
139
  })
140
+
141
+ describe('gcloud token auth', () => {
142
+ beforeEach(() => {
143
+ delete process.env.LITELLM_GCLOUD_TOKEN_AUTH
144
+ })
145
+
146
+ it('allows missing LITELLM_KEY when LITELLM_GCLOUD_TOKEN_AUTH is set', () => {
147
+ process.env.LITELLM_URL = 'https://gcloud.example.com'
148
+ process.env.LITELLM_GCLOUD_TOKEN_AUTH = '1'
149
+ delete process.env.LITELLM_KEY
150
+
151
+ const config = resolvePluginConfig({})
152
+ expect(config).toEqual({ url: 'https://gcloud.example.com', apiKey: '' })
153
+ })
154
+
155
+ it('does not allow missing key when gcloud auth is disabled', () => {
156
+ process.env.LITELLM_URL = 'https://gcloud.example.com'
157
+ delete process.env.LITELLM_KEY
158
+ delete process.env.LITELLM_GCLOUD_TOKEN_AUTH
159
+
160
+ const config = resolvePluginConfig({})
161
+ expect(config).toBeNull()
162
+ })
163
+
164
+ it('does not allow missing key when gcloud auth is set to 0', () => {
165
+ process.env.LITELLM_URL = 'https://gcloud.example.com'
166
+ process.env.LITELLM_GCLOUD_TOKEN_AUTH = '0'
167
+ delete process.env.LITELLM_KEY
168
+
169
+ const config = resolvePluginConfig({})
170
+ expect(config).toBeNull()
171
+ })
172
+
173
+ it('does not allow missing key when gcloud auth is empty string', () => {
174
+ process.env.LITELLM_URL = 'https://gcloud.example.com'
175
+ process.env.LITELLM_GCLOUD_TOKEN_AUTH = ''
176
+ delete process.env.LITELLM_KEY
177
+
178
+ const config = resolvePluginConfig({})
179
+ expect(config).toBeNull()
180
+ })
181
+
182
+ it('prefers full env vars over gcloud fallback', () => {
183
+ process.env.LITELLM_URL = 'https://gcloud.example.com'
184
+ process.env.LITELLM_KEY = 'normal-key'
185
+ process.env.LITELLM_GCLOUD_TOKEN_AUTH = '1'
186
+
187
+ const config = resolvePluginConfig({})
188
+ expect(config).toEqual({ url: 'https://gcloud.example.com', apiKey: 'normal-key' })
189
+ })
190
+ })
140
191
  })
package/src/utils.ts CHANGED
@@ -34,14 +34,23 @@ export function mapLiteLLMModel(model: LiteLLMModel): OpencodeModelConfig {
34
34
  export function resolvePluginConfig(rawConfig: unknown): PluginConfig | null {
35
35
  const envUrl = typeof process !== 'undefined' ? process.env.LITELLM_URL : undefined
36
36
  const envKey = typeof process !== 'undefined' ? process.env.LITELLM_KEY : undefined
37
+ const envGcloudAuth = typeof process !== 'undefined'
38
+ ? process.env.LITELLM_GCLOUD_TOKEN_AUTH
39
+ : undefined
37
40
 
38
41
  const hasEnvVars = envUrl !== undefined && envUrl.length > 0 &&
39
- envKey !== undefined && envKey.length > 0
42
+ envKey !== undefined && envKey.length > 0
40
43
 
41
44
  if (hasEnvVars) {
42
45
  return { url: envUrl, apiKey: envKey }
43
46
  }
44
47
 
48
+ // Allow missing LITELLM_KEY when gcloud token auth is enabled
49
+ if (envUrl !== undefined && envUrl.length > 0 &&
50
+ envGcloudAuth !== undefined && envGcloudAuth !== '' && envGcloudAuth !== '0') {
51
+ return { url: envUrl, apiKey: envKey || '' }
52
+ }
53
+
45
54
  // Fall back to config options from opencode.json
46
55
  if (rawConfig && typeof rawConfig === 'object' && !Array.isArray(rawConfig)) {
47
56
  const obj = rawConfig as Record<string, unknown>