opencode-provider-litellm 0.4.0 → 0.5.1
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 +26 -3
- package/package.json +1 -1
- package/src/gcloud-token.test.ts +255 -0
- package/src/gcloud-token.ts +145 -0
- package/src/plugin.test.ts +65 -1
- package/src/plugin.ts +41 -9
- package/src/utils.test.ts +55 -0
- package/src/utils.ts +10 -1
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
|
-
|
|
46
|
-
|
|
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
|
@@ -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
|
+
}
|
package/src/plugin.test.ts
CHANGED
|
@@ -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'
|
|
@@ -48,6 +54,7 @@ describe('LiteLLMPlugin', () => {
|
|
|
48
54
|
|
|
49
55
|
beforeEach(() => {
|
|
50
56
|
vi.resetAllMocks()
|
|
57
|
+
delete process.env.LITELLM_GCLOUD_TOKEN_AUTH
|
|
51
58
|
|
|
52
59
|
mockInput = createMockInput()
|
|
53
60
|
logFn = mockInput.client.app.log as ReturnType<typeof vi.fn>
|
|
@@ -74,6 +81,10 @@ describe('LiteLLMPlugin', () => {
|
|
|
74
81
|
})
|
|
75
82
|
})
|
|
76
83
|
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
delete process.env.LITELLM_GCLOUD_TOKEN_AUTH
|
|
86
|
+
})
|
|
87
|
+
|
|
77
88
|
it('throws on missing config', async () => {
|
|
78
89
|
vi.mocked(resolvePluginConfig).mockReturnValue(null)
|
|
79
90
|
|
|
@@ -84,6 +95,28 @@ describe('LiteLLMPlugin', () => {
|
|
|
84
95
|
)
|
|
85
96
|
})
|
|
86
97
|
|
|
98
|
+
it('throws gcloud-specific error when LITELLM_GCLOUD_TOKEN_AUTH is set but config is missing', async () => {
|
|
99
|
+
vi.mocked(resolvePluginConfig).mockReturnValue(null)
|
|
100
|
+
process.env.LITELLM_GCLOUD_TOKEN_AUTH = '1'
|
|
101
|
+
|
|
102
|
+
await expect(
|
|
103
|
+
LiteLLMPlugin(mockInput, {})
|
|
104
|
+
).rejects.toThrow(
|
|
105
|
+
'LITELLM_KEY is optional when LITELLM_GCLOUD_TOKEN_AUTH=1',
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('throws generic error when LITELLM_GCLOUD_TOKEN_AUTH is not set and config is missing', async () => {
|
|
110
|
+
vi.mocked(resolvePluginConfig).mockReturnValue(null)
|
|
111
|
+
delete process.env.LITELLM_GCLOUD_TOKEN_AUTH
|
|
112
|
+
|
|
113
|
+
await expect(
|
|
114
|
+
LiteLLMPlugin(mockInput, {})
|
|
115
|
+
).rejects.toThrow(
|
|
116
|
+
"Plugin config error: set 'url' and 'apiKey'",
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
|
|
87
120
|
it('config hook calls discoverModels and injects models into config', async () => {
|
|
88
121
|
const hooks = await LiteLLMPlugin(mockInput, {
|
|
89
122
|
url: 'https://litellm.example.com',
|
|
@@ -252,4 +285,35 @@ describe('LiteLLMPlugin', () => {
|
|
|
252
285
|
delete process.env.LITELLM_URL
|
|
253
286
|
delete process.env.LITELLM_KEY
|
|
254
287
|
})
|
|
288
|
+
|
|
289
|
+
it('registers chat.headers hook when LITELLM_GCLOUD_TOKEN_AUTH is set', async () => {
|
|
290
|
+
process.env.LITELLM_GCLOUD_TOKEN_AUTH = '1'
|
|
291
|
+
|
|
292
|
+
const { getGcloudToken } = await import('./gcloud-token.js')
|
|
293
|
+
vi.mocked(getGcloudToken).mockResolvedValue('mock-gcloud-token')
|
|
294
|
+
|
|
295
|
+
const hooks = await LiteLLMPlugin(mockInput, {
|
|
296
|
+
url: 'https://litellm.example.com',
|
|
297
|
+
apiKey: 'test-api-key',
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
expect(hooks['chat.headers']).toBeDefined()
|
|
301
|
+
expect(typeof hooks['chat.headers']).toBe('function')
|
|
302
|
+
|
|
303
|
+
// Verify the hook injects the token
|
|
304
|
+
const output = { headers: {} as Record<string, string> }
|
|
305
|
+
await (hooks['chat.headers'] as Function)({}, output)
|
|
306
|
+
expect(output.headers['Authorization']).toBe('Bearer mock-gcloud-token')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('does not register chat.headers hook when LITELLM_GCLOUD_TOKEN_AUTH is unset', async () => {
|
|
310
|
+
delete process.env.LITELLM_GCLOUD_TOKEN_AUTH
|
|
311
|
+
|
|
312
|
+
const hooks = await LiteLLMPlugin(mockInput, {
|
|
313
|
+
url: 'https://litellm.example.com',
|
|
314
|
+
apiKey: 'test-api-key',
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
expect(hooks['chat.headers']).toBeUndefined()
|
|
318
|
+
})
|
|
255
319
|
})
|
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,27 +10,45 @@ 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
|
-
|
|
14
|
-
|
|
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
|
|
|
18
25
|
const providerId = getProviderId()
|
|
19
26
|
|
|
27
|
+
const isGcloudAuth = !!(process.env.LITELLM_GCLOUD_TOKEN_AUTH &&
|
|
28
|
+
process.env.LITELLM_GCLOUD_TOKEN_AUTH !== '' &&
|
|
29
|
+
process.env.LITELLM_GCLOUD_TOKEN_AUTH !== '0')
|
|
30
|
+
|
|
31
|
+
// When gcloud token auth is enabled, fetch a live token instead of using the static apiKey
|
|
32
|
+
const getToken = async (): Promise<string> => {
|
|
33
|
+
if (isGcloudAuth) {
|
|
34
|
+
return (await getGcloudToken()) ?? ''
|
|
35
|
+
}
|
|
36
|
+
return pluginConfig.apiKey
|
|
37
|
+
}
|
|
38
|
+
|
|
20
39
|
let mcpTools: Record<string, any> = {}
|
|
21
40
|
try {
|
|
22
|
-
mcpTools = await createMcpToolDefinitions(pluginConfig,
|
|
41
|
+
mcpTools = await createMcpToolDefinitions(pluginConfig, await getToken())
|
|
23
42
|
} catch (e) {
|
|
24
43
|
console.warn(`[opencode-provider-litellm] MCP tool discovery failed: ${e}`)
|
|
25
44
|
}
|
|
26
45
|
|
|
27
|
-
|
|
46
|
+
const result: Record<string, unknown> = {
|
|
28
47
|
config: async (config: Record<string, any>) => {
|
|
29
48
|
try {
|
|
30
49
|
const models = await discoverModels(
|
|
31
50
|
pluginConfig,
|
|
32
|
-
|
|
51
|
+
getToken,
|
|
33
52
|
)
|
|
34
53
|
|
|
35
54
|
if (Object.keys(models).length === 0) {
|
|
@@ -41,11 +60,12 @@ export const LiteLLMPlugin: Plugin = async (
|
|
|
41
60
|
},
|
|
42
61
|
})
|
|
43
62
|
} else {
|
|
63
|
+
const token = await getToken()
|
|
44
64
|
injectModelsIntoConfig(
|
|
45
65
|
config as Parameters<typeof injectModelsIntoConfig>[0],
|
|
46
66
|
providerId,
|
|
47
67
|
pluginConfig.url,
|
|
48
|
-
|
|
68
|
+
token,
|
|
49
69
|
models,
|
|
50
70
|
)
|
|
51
71
|
await input.client.app.log({
|
|
@@ -81,11 +101,12 @@ export const LiteLLMPlugin: Plugin = async (
|
|
|
81
101
|
placeholder: 'sk-...',
|
|
82
102
|
},
|
|
83
103
|
],
|
|
84
|
-
async authorize(inputs) {
|
|
85
|
-
|
|
104
|
+
async authorize(inputs: Record<string, unknown> | undefined) {
|
|
105
|
+
const apiKey = inputs?.apiKey
|
|
106
|
+
if (!apiKey || typeof apiKey !== 'string' || apiKey.length === 0) {
|
|
86
107
|
return { type: 'failed' as const }
|
|
87
108
|
}
|
|
88
|
-
return { type: 'success' as const, key:
|
|
109
|
+
return { type: 'success' as const, key: apiKey }
|
|
89
110
|
},
|
|
90
111
|
},
|
|
91
112
|
],
|
|
@@ -95,4 +116,15 @@ export const LiteLLMPlugin: Plugin = async (
|
|
|
95
116
|
...mcpTools,
|
|
96
117
|
},
|
|
97
118
|
}
|
|
119
|
+
|
|
120
|
+
if (isGcloudAuth) {
|
|
121
|
+
result['chat.headers'] = async (_input: Record<string, unknown>, output: { headers: Record<string, string> }) => {
|
|
122
|
+
const token = await getGcloudToken()
|
|
123
|
+
if (token) {
|
|
124
|
+
output.headers['Authorization'] = `Bearer ${token}`
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return result
|
|
98
130
|
}
|
package/src/utils.test.ts
CHANGED
|
@@ -55,6 +55,10 @@ describe('resolvePluginConfig', () => {
|
|
|
55
55
|
})
|
|
56
56
|
|
|
57
57
|
describe('environment variable priority', () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
delete process.env.LITELLM_GCLOUD_TOKEN_AUTH
|
|
60
|
+
})
|
|
61
|
+
|
|
58
62
|
it('returns config from env vars when both are set', () => {
|
|
59
63
|
process.env.LITELLM_URL = 'https://env.example.com'
|
|
60
64
|
process.env.LITELLM_KEY = 'env-key-123'
|
|
@@ -137,4 +141,55 @@ describe('resolvePluginConfig', () => {
|
|
|
137
141
|
expect(resolvePluginConfig({})).toBeNull()
|
|
138
142
|
})
|
|
139
143
|
})
|
|
144
|
+
|
|
145
|
+
describe('gcloud token auth', () => {
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
delete process.env.LITELLM_GCLOUD_TOKEN_AUTH
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('allows missing LITELLM_KEY when LITELLM_GCLOUD_TOKEN_AUTH is set', () => {
|
|
151
|
+
process.env.LITELLM_URL = 'https://gcloud.example.com'
|
|
152
|
+
process.env.LITELLM_GCLOUD_TOKEN_AUTH = '1'
|
|
153
|
+
delete process.env.LITELLM_KEY
|
|
154
|
+
|
|
155
|
+
const config = resolvePluginConfig({})
|
|
156
|
+
expect(config).toEqual({ url: 'https://gcloud.example.com', apiKey: '' })
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('does not allow missing key when gcloud auth is disabled', () => {
|
|
160
|
+
process.env.LITELLM_URL = 'https://gcloud.example.com'
|
|
161
|
+
delete process.env.LITELLM_KEY
|
|
162
|
+
delete process.env.LITELLM_GCLOUD_TOKEN_AUTH
|
|
163
|
+
|
|
164
|
+
const config = resolvePluginConfig({})
|
|
165
|
+
expect(config).toBeNull()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('does not allow missing key when gcloud auth is set to 0', () => {
|
|
169
|
+
process.env.LITELLM_URL = 'https://gcloud.example.com'
|
|
170
|
+
process.env.LITELLM_GCLOUD_TOKEN_AUTH = '0'
|
|
171
|
+
delete process.env.LITELLM_KEY
|
|
172
|
+
|
|
173
|
+
const config = resolvePluginConfig({})
|
|
174
|
+
expect(config).toBeNull()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('does not allow missing key when gcloud auth is empty string', () => {
|
|
178
|
+
process.env.LITELLM_URL = 'https://gcloud.example.com'
|
|
179
|
+
process.env.LITELLM_GCLOUD_TOKEN_AUTH = ''
|
|
180
|
+
delete process.env.LITELLM_KEY
|
|
181
|
+
|
|
182
|
+
const config = resolvePluginConfig({})
|
|
183
|
+
expect(config).toBeNull()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('prefers full env vars over gcloud fallback', () => {
|
|
187
|
+
process.env.LITELLM_URL = 'https://gcloud.example.com'
|
|
188
|
+
process.env.LITELLM_KEY = 'normal-key'
|
|
189
|
+
process.env.LITELLM_GCLOUD_TOKEN_AUTH = '1'
|
|
190
|
+
|
|
191
|
+
const config = resolvePluginConfig({})
|
|
192
|
+
expect(config).toEqual({ url: 'https://gcloud.example.com', apiKey: 'normal-key' })
|
|
193
|
+
})
|
|
194
|
+
})
|
|
140
195
|
})
|
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
|
-
|
|
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>
|