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 +61 -14
- 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 +63 -60
- package/src/plugin.ts +42 -51
- package/src/types.ts +0 -23
- package/src/utils.test.ts +51 -0
- package/src/utils.ts +10 -1
- package/src/skills.test.ts +0 -725
- package/src/skills.ts +0 -375
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# opencode-provider-litellm
|
|
2
2
|
|
|
3
|
-
OpenCode plugin
|
|
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
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
112
|
+
The plugin uses three OpenCode hooks:
|
|
62
113
|
|
|
63
114
|
| Hook | Purpose |
|
|
64
115
|
|------|---------|
|
|
65
|
-
| `config` |
|
|
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
|
-
|
|
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
|
@@ -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
|
+
}
|