opencode-provider-litellm 0.5.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-provider-litellm",
3
- "version": "0.5.2",
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": {
@@ -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],