opencode-provider-litellm 0.5.1 → 0.6.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
@@ -21,34 +21,39 @@ All models and MCP tools from your LiteLLM proxy appear in OpenCode automaticall
21
21
 
22
22
  ### Environment variables
23
23
 
24
- | Variable | Description |
25
- |----------|-------------|
26
- | `LITELLM_URL` | Your LiteLLM proxy base URL |
27
- | `LITELLM_KEY` | API key for the proxy |
28
- | `LITELLM_PROVIDER_ID` | Provider ID in OpenCode (defaults to `LiteLLM`) |
29
- | `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`) |
24
+ Environment variables take precedence over all other configuration. Use them to keep secrets out of checked-in files.
31
25
 
32
- ### Inline config
26
+ | Variable | Required | Description |
27
+ |----------|----------|-------------|
28
+ | `LITELLM_URL` | Yes | Your LiteLLM proxy base URL |
29
+ | `LITELLM_KEY` | Yes* | API key for the proxy |
30
+ | `LITELLM_PROVIDER_ID` | No | Provider name in OpenCode (default: `LiteLLM`) |
31
+ | `LITELLM_GCLOUD_TOKEN_AUTH` | No | Set to `1` to use Google ADC for auth — makes `LITELLM_KEY` optional |
32
+ | `GOOGLE_APPLICATION_CREDENTIALS` | No | Path to a Google ADC JSON file (used when `LITELLM_GCLOUD_TOKEN_AUTH=1`) |
33
33
 
34
- Alternatively, provide `url` and `apiKey` directly in your `opencode.json`:
34
+ *`LITELLM_KEY` is optional when `LITELLM_GCLOUD_TOKEN_AUTH=1`.
35
+
36
+ ### Plugin options
37
+
38
+ All env vars have an equivalent in the plugin options block in `opencode.json`. **Env vars take precedence** — use options for defaults that can be overridden per environment.
35
39
 
36
40
  ```jsonc
37
41
  {
38
42
  "plugin": [
39
43
  ["opencode-provider-litellm", {
40
- "url": "https://your-litellm-proxy.example.com",
41
- "apiKey": "sk-..."
44
+ "url": "https://your-litellm-proxy.example.com", // LITELLM_URL
45
+ "apiKey": "sk-...", // LITELLM_KEY
46
+ "providerName": "MyLiteLLM", // LITELLM_PROVIDER_ID
47
+ "gcloudTokenAuth": true // LITELLM_GCLOUD_TOKEN_AUTH=1
48
+ // apiKey can be omitted when gcloudTokenAuth is true
42
49
  }]
43
50
  ]
44
51
  }
45
52
  ```
46
53
 
47
- > **Tip:** Environment variables take precedence over inline config. Use env vars to keep secrets out of checked-in files.
48
-
49
54
  ### Google Vertex AI (gcloud token auth)
50
55
 
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:
56
+ When your LiteLLM proxy is backed by Google Vertex AI, you can skip `LITELLM_KEY` and let the plugin automatically fetch a Google OAuth token:
52
57
 
53
58
  ```bash
54
59
  # 1. Authenticate with gcloud (creates an ADC JSON file)
@@ -62,9 +67,12 @@ export LITELLM_GCLOUD_TOKEN_AUTH=1
62
67
  opencode plugin opencode-provider-litellm
63
68
  ```
64
69
 
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.
70
+ 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. Tokens are cached for 50 minutes and used for both model discovery at startup and every LLM request.
66
71
 
67
- To use a custom credentials file, set `GOOGLE_APPLICATION_CREDENTIALS` to its path.
72
+ **ADC file locations searched (in order):**
73
+ 1. `GOOGLE_APPLICATION_CREDENTIALS` env var (all platforms)
74
+ 2. `~/.config/gcloud/application_default_credentials.json` (Linux/macOS)
75
+ 3. `%APPDATA%/gcloud/application_default_credentials.json` (Windows)
68
76
 
69
77
  > **Note:** Only `authorized_user` credentials (from `gcloud auth application-default login`) are supported. Service account keys are not yet supported.
70
78
 
@@ -109,22 +117,23 @@ Skills appear in OpenCode's `/skills` menu and are loaded natively by the agent.
109
117
 
110
118
  ## How it works
111
119
 
112
- The plugin uses three OpenCode hooks:
120
+ The plugin uses these OpenCode hooks:
113
121
 
114
122
  | Hook | Purpose |
115
123
  |------|---------|
116
124
  | `config` | Discovers models from LiteLLM and injects them into OpenCode |
117
125
  | `auth` | Provides a `/connect` entry point for pasting an API key |
118
126
  | `tool` | Exposes discovered MCP tools as native OpenCode tools |
119
- | `chat.headers` | Injects `Authorization: Bearer <token>` when `LITELLM_GCLOUD_TOKEN_AUTH=1` |
127
+ | `chat.headers` | Injects `Authorization: Bearer <token>` when gcloud token auth is enabled |
120
128
 
121
129
  ## Troubleshooting
122
130
 
123
131
  | Problem | Solution |
124
132
  |---------|----------|
125
- | "Plugin config error" | Set `LITELLM_URL` and `LITELLM_KEY`, or add `url`/`apiKey` to plugin options |
133
+ | "Plugin config error" | Set `LITELLM_URL` and `LITELLM_KEY` (or `LITELLM_GCLOUD_TOKEN_AUTH=1`) |
126
134
  | "Access denied" (403) | Verify the API key has access to the LiteLLM proxy |
127
- | "No models discovered" | Check that the proxy is reachable and the `/health` endpoint responds |
135
+ | "No models discovered" | Check the proxy is reachable and `/health` responds |
136
+ | No models with gcloud auth | Verify `gcloud auth application-default login` has been run and the ADC file exists |
128
137
  | Skills not showing | Verify the proxy-sidecar is running and the skills URL is in `opencode.json` |
129
138
 
130
139
  ## Development
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-provider-litellm",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "OpenCode plugin for any LiteLLM proxy — auto-discovers models, auth, and capabilities",
5
5
  "type": "module",
6
6
  "exports": {
@@ -83,7 +83,7 @@ describe('discoverModels', () => {
83
83
  tool_call: true,
84
84
  reasoning: false,
85
85
  limit: { context: 8192, output: 8192 },
86
- cost: { input: 0.0001, output: 0.0003 },
86
+ cost: { input: 100, output: 300 },
87
87
  modalities: { input: ['text'], output: ['text'] },
88
88
  },
89
89
  'qwen3-32b': {
@@ -91,7 +91,7 @@ describe('discoverModels', () => {
91
91
  tool_call: true,
92
92
  reasoning: true,
93
93
  limit: { context: 32768, output: 32768 },
94
- cost: { input: 0.00005, output: 0.00015 },
94
+ cost: { input: 50, output: 150 },
95
95
  modalities: { input: ['text'], output: ['text'] },
96
96
  },
97
97
  })
@@ -107,6 +107,51 @@ describe('discoverModels', () => {
107
107
  expect(getToken).toHaveBeenCalled()
108
108
  })
109
109
 
110
+ it('converts per-token cost to per-1M tokens with cache costs', async () => {
111
+ const mockFetch = vi.fn()
112
+ .mockResolvedValueOnce({
113
+ ok: true,
114
+ status: 200,
115
+ json: async () => ({
116
+ healthy_endpoints: [
117
+ { model: 'anthropic/claude-sonnet', model_id: 'uuid-1' },
118
+ ],
119
+ }),
120
+ })
121
+ .mockResolvedValueOnce({
122
+ ok: true,
123
+ status: 200,
124
+ json: async () => ({
125
+ data: [{
126
+ model_name: 'anthropic/claude-sonnet',
127
+ model_info: {
128
+ max_input_tokens: 1_000_000,
129
+ max_output_tokens: 64_000,
130
+ supports_function_calling: true,
131
+ supports_reasoning: true,
132
+ supports_vision: true,
133
+ supports_pdf_input: true,
134
+ // Per-token costs (LiteLLM format)
135
+ input_cost_per_token: 0.000005,
136
+ output_cost_per_token: 0.000025,
137
+ cache_read_input_token_cost: 0.0000005,
138
+ cache_creation_input_token_cost: 0.00000375,
139
+ },
140
+ }],
141
+ }),
142
+ })
143
+ vi.stubGlobal('fetch', mockFetch)
144
+
145
+ const result = await discoverModels(config, getToken)
146
+
147
+ expect(result['anthropic/claude-sonnet']?.cost).toEqual({
148
+ input: 5, // 0.000005 * 1M
149
+ output: 25, // 0.000025 * 1M
150
+ cache_read: 0.5, // 0.0000005 * 1M
151
+ cache_write: 3.75, // 0.00000375 * 1M
152
+ })
153
+ })
154
+
110
155
  it('returns empty object on timeout', async () => {
111
156
  vi.useFakeTimers()
112
157
 
package/src/discovery.ts CHANGED
@@ -117,16 +117,17 @@ export async function discoverModels(
117
117
  }
118
118
 
119
119
  // Add cost info if available
120
+ // LiteLLM returns cost per single token; opencode expects cost per 1M tokens
120
121
  if (info.input_cost_per_token != null && info.output_cost_per_token != null) {
121
122
  modelConfig.cost = {
122
- input: info.input_cost_per_token,
123
- output: info.output_cost_per_token,
123
+ input: info.input_cost_per_token * 1_000_000,
124
+ output: info.output_cost_per_token * 1_000_000,
124
125
  }
125
126
  if (info.cache_read_input_token_cost != null) {
126
- modelConfig.cost.cache_read = info.cache_read_input_token_cost
127
+ modelConfig.cost.cache_read = info.cache_read_input_token_cost * 1_000_000
127
128
  }
128
129
  if (info.cache_creation_input_token_cost != null) {
129
- modelConfig.cost.cache_write = info.cache_creation_input_token_cost
130
+ modelConfig.cost.cache_write = info.cache_creation_input_token_cost * 1_000_000
130
131
  }
131
132
  }
132
133
 
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import type { OpencodeModelConfig } from './types.js'
3
+
4
+ const mockReadFileSync = vi.hoisted(() => vi.fn())
5
+ const mockWriteFileSync = vi.hoisted(() => vi.fn())
6
+
7
+ vi.mock('fs', () => ({
8
+ existsSync: vi.fn(() => true),
9
+ readFileSync: mockReadFileSync,
10
+ writeFileSync: mockWriteFileSync,
11
+ }))
12
+
13
+ vi.mock('os', () => ({
14
+ homedir: () => '/home/test',
15
+ }))
16
+
17
+ const { loadModelCache, saveModelCache } = await import('./model-cache.js')
18
+
19
+ const sampleModels: Record<string, OpencodeModelConfig> = {
20
+ 'anthropic/claude-sonnet': {
21
+ name: 'anthropic/claude-sonnet',
22
+ tool_call: true,
23
+ reasoning: true,
24
+ limit: { context: 1_000_000, output: 64_000 },
25
+ cost: { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 },
26
+ modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
27
+ },
28
+ 'qwen/qwen3.6-27b': {
29
+ name: 'qwen/qwen3.6-27b',
30
+ tool_call: true,
31
+ reasoning: false,
32
+ limit: { context: 262144, output: 32768 },
33
+ modalities: { input: ['text'], output: ['text'] },
34
+ },
35
+ }
36
+
37
+ describe('loadModelCache', () => {
38
+ afterEach(() => vi.clearAllMocks())
39
+
40
+ it('returns null when file does not exist', () => {
41
+ mockReadFileSync.mockImplementation(() => { throw new Error('ENOENT') })
42
+ expect(loadModelCache('protector')).toBeNull()
43
+ })
44
+
45
+ it('returns null when file contains invalid JSON', () => {
46
+ mockReadFileSync.mockReturnValue('not valid json{{{')
47
+ expect(loadModelCache('protector')).toBeNull()
48
+ })
49
+
50
+ it('returns null when providerId does not match', () => {
51
+ mockReadFileSync.mockReturnValue(JSON.stringify({
52
+ savedAt: Date.now(), providerId: 'other', models: sampleModels,
53
+ }))
54
+ expect(loadModelCache('protector')).toBeNull()
55
+ })
56
+
57
+ it('returns null when models field is missing', () => {
58
+ mockReadFileSync.mockReturnValue(JSON.stringify({
59
+ savedAt: Date.now(), providerId: 'protector', models: null,
60
+ }))
61
+ expect(loadModelCache('protector')).toBeNull()
62
+ })
63
+
64
+ it('returns models when cache is valid', () => {
65
+ mockReadFileSync.mockReturnValue(JSON.stringify({
66
+ savedAt: Date.now(), providerId: 'protector', models: sampleModels,
67
+ }))
68
+ expect(loadModelCache('protector')).toEqual(sampleModels)
69
+ })
70
+
71
+ it('reads from the correct path', () => {
72
+ mockReadFileSync.mockReturnValue(JSON.stringify({
73
+ savedAt: Date.now(), providerId: 'protector', models: sampleModels,
74
+ }))
75
+ loadModelCache('protector')
76
+ expect(mockReadFileSync).toHaveBeenCalledWith(
77
+ expect.stringContaining('opencode-provider-litellm-cache.json'),
78
+ 'utf-8',
79
+ )
80
+ })
81
+ })
82
+
83
+ describe('saveModelCache', () => {
84
+ afterEach(() => vi.clearAllMocks())
85
+
86
+ it('writes a valid cache file', () => {
87
+ saveModelCache('protector', sampleModels)
88
+ expect(mockWriteFileSync).toHaveBeenCalledOnce()
89
+ const [filePath, content] = mockWriteFileSync.mock.calls[0] as [string, string, string]
90
+ expect(filePath).toContain('opencode-provider-litellm-cache.json')
91
+ const parsed = JSON.parse(content)
92
+ expect(parsed.providerId).toBe('protector')
93
+ expect(parsed.models).toEqual(sampleModels)
94
+ expect(typeof parsed.savedAt).toBe('number')
95
+ })
96
+
97
+ it('writes to the correct path under ~/.local/share/opencode/', () => {
98
+ saveModelCache('protector', sampleModels)
99
+ const [filePath] = mockWriteFileSync.mock.calls[0] as [string, string, string]
100
+ expect(filePath).toMatch(/\.local[/\\]share[/\\]opencode[/\\]opencode-provider-litellm-cache\.json/)
101
+ })
102
+
103
+ it('does not throw when writeFileSync fails', () => {
104
+ mockWriteFileSync.mockImplementation(() => { throw new Error('EACCES') })
105
+ expect(() => saveModelCache('protector', sampleModels)).not.toThrow()
106
+ })
107
+
108
+ it('round-trips correctly with loadModelCache', () => {
109
+ let written = ''
110
+ mockWriteFileSync.mockImplementation((_p: string, content: string) => { written = content })
111
+ mockReadFileSync.mockImplementation(() => written)
112
+ saveModelCache('protector', sampleModels)
113
+ expect(loadModelCache('protector')).toEqual(sampleModels)
114
+ })
115
+ })
@@ -0,0 +1,49 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
2
+ import { join } from 'path'
3
+ import { homedir } from 'os'
4
+ import type { OpencodeModelConfig } from './types.js'
5
+
6
+ const CACHE_FILENAME = 'opencode-provider-litellm-cache.json'
7
+
8
+ interface ModelCache {
9
+ savedAt: number
10
+ providerId: string
11
+ models: Record<string, OpencodeModelConfig>
12
+ }
13
+
14
+ function getCachePath(): string {
15
+ return join(homedir(), '.local', 'share', 'opencode', CACHE_FILENAME)
16
+ }
17
+
18
+ /**
19
+ * Loads the model cache from disk. Returns null if the file does not exist,
20
+ * cannot be parsed, or belongs to a different provider.
21
+ */
22
+ export function loadModelCache(providerId: string): Record<string, OpencodeModelConfig> | null {
23
+ try {
24
+ const raw = readFileSync(getCachePath(), 'utf-8')
25
+ const cache = JSON.parse(raw) as ModelCache
26
+ if (cache.providerId !== providerId) return null
27
+ if (!cache.models || typeof cache.models !== 'object') return null
28
+ return cache.models
29
+ } catch {
30
+ return null
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Saves the discovered models to the cache file on disk. Failures are
36
+ * non-fatal — discovery already succeeded.
37
+ */
38
+ export function saveModelCache(providerId: string, models: Record<string, OpencodeModelConfig>): void {
39
+ try {
40
+ const cache: ModelCache = {
41
+ savedAt: Date.now(),
42
+ providerId,
43
+ models,
44
+ }
45
+ writeFileSync(getCachePath(), JSON.stringify(cache, null, 2), 'utf-8')
46
+ } catch {
47
+ // Non-fatal — cache will be written next time
48
+ }
49
+ }
@@ -26,6 +26,12 @@ vi.mock('./gcloud-token.js', () => ({
26
26
  resetTokenCache: vi.fn(),
27
27
  }))
28
28
 
29
+ // Mock the model cache module — no cache by default
30
+ vi.mock('./model-cache.js', () => ({
31
+ loadModelCache: vi.fn().mockReturnValue(null),
32
+ saveModelCache: vi.fn(),
33
+ }))
34
+
29
35
  import { LiteLLMPlugin } from './plugin.js'
30
36
  import { discoverModels, injectModelsIntoConfig } from './discovery.js'
31
37
  import { resolvePluginConfig } from './utils.js'
package/src/plugin.ts CHANGED
@@ -3,6 +3,7 @@ import { resolvePluginConfig, getProviderId } from './utils.js'
3
3
  import { discoverModels, injectModelsIntoConfig } from './discovery.js'
4
4
  import { createMcpToolDefinitions } from './mcp-tools.js'
5
5
  import { getGcloudToken } from './gcloud-token.js'
6
+ import { loadModelCache, saveModelCache } from './model-cache.js'
6
7
 
7
8
  export const LiteLLMPlugin: Plugin = async (
8
9
  input: PluginInput,
@@ -45,11 +46,23 @@ export const LiteLLMPlugin: Plugin = async (
45
46
 
46
47
  const result: Record<string, unknown> = {
47
48
  config: async (config: Record<string, any>) => {
48
- try {
49
- const models = await discoverModels(
50
- pluginConfig,
51
- getToken,
49
+ // Inject cached models immediately so opencode has something to work
50
+ // with while live discovery runs.
51
+ const cachedModels = loadModelCache(providerId)
52
+ if (cachedModels) {
53
+ const token = await getToken()
54
+ injectModelsIntoConfig(
55
+ config as Parameters<typeof injectModelsIntoConfig>[0],
56
+ providerId,
57
+ pluginConfig.url,
58
+ token,
59
+ cachedModels,
52
60
  )
61
+ }
62
+
63
+ // Discover live models, update cache, and re-inject with fresh data.
64
+ try {
65
+ const models = await discoverModels(pluginConfig, getToken)
53
66
 
54
67
  if (Object.keys(models).length === 0) {
55
68
  await input.client.app.log({
@@ -60,6 +73,7 @@ export const LiteLLMPlugin: Plugin = async (
60
73
  },
61
74
  })
62
75
  } else {
76
+ saveModelCache(providerId, models)
63
77
  const token = await getToken()
64
78
  injectModelsIntoConfig(
65
79
  config as Parameters<typeof injectModelsIntoConfig>[0],