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 +1 -1
- package/src/model-cache.test.ts +115 -0
- package/src/model-cache.ts +49 -0
- package/src/plugin.test.ts +6 -0
- package/src/plugin.ts +18 -4
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/plugin.test.ts
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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],
|