kimaki 0.26.0 → 0.27.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/dist/agent-model.e2e.test.js +25 -122
- package/dist/anthropic-account-identity.js +1 -0
- package/dist/anthropic-auth-plugin.js +1 -0
- package/dist/anthropic-auth-state.js +1 -0
- package/dist/bin.js +73 -19
- package/dist/cli-commands/maintenance.js +46 -7
- package/dist/cli-commands/send.js +24 -0
- package/dist/cli-commands/session.js +183 -8
- package/dist/cli-commands/task.js +13 -1
- package/dist/cli-parsing.test.js +59 -3
- package/dist/cli-runner.js +28 -4
- package/dist/cli-runner.test.js +27 -0
- package/dist/cli-send-thread.e2e.test.js +1 -1
- package/dist/cli.js +32 -0
- package/dist/commands/abort.js +5 -1
- package/dist/commands/agent.js +6 -8
- package/dist/commands/ask-question.js +21 -32
- package/dist/commands/ask-question.test.js +43 -1
- package/dist/commands/login.js +6 -0
- package/dist/commands/merge-worktree.js +36 -10
- package/dist/commands/model-variant.js +7 -1
- package/dist/commands/model.js +14 -8
- package/dist/commands/multioauth.js +1 -0
- package/dist/commands/new-worktree.js +170 -102
- package/dist/commands/user-command.js +6 -8
- package/dist/commands/worktrees.js +5 -13
- package/dist/config.js +16 -1
- package/dist/database.js +124 -3
- package/dist/db.js +22 -0
- package/dist/db.test.js +167 -1
- package/dist/discord-bot.js +34 -32
- package/dist/discord-command-registration.js +9 -1
- package/dist/discord-utils.js +13 -5
- package/dist/discord-utils.test.js +17 -1
- package/dist/errors.js +5 -0
- package/dist/exec-async.js +17 -5
- package/dist/external-directory-permissions.e2e.test.js +2 -2
- package/dist/file-edit-log.js +197 -0
- package/dist/file-edit-log.test.js +258 -0
- package/dist/format-tables.js +52 -0
- package/dist/format-tables.test.js +93 -0
- package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
- package/dist/gateway-proxy.e2e.test.js +5 -5
- package/dist/git-worktree-core.js +182 -17
- package/dist/ipc-tools-plugin.js +63 -3
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
- package/dist/kimaki-opencode-plugin.js +7 -3
- package/dist/kimaki-workspace-adaptor.js +33 -10
- package/dist/logger.js +10 -1
- package/dist/markdown.js +3 -1
- package/dist/markdown.test.js +11 -3
- package/dist/message-formatting.js +18 -7
- package/dist/message-formatting.test.js +56 -9
- package/dist/oauth-rotation-shared.js +10 -0
- package/dist/openai-auth-plugin.js +1 -0
- package/dist/openai-auth-state.js +1 -0
- package/dist/opencode-command.test.js +60 -0
- package/dist/opencode.js +53 -12
- package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
- package/dist/question-text-before-dropdown.e2e.test.js +52 -0
- package/dist/queue-advanced-abort.e2e.test.js +1 -1
- package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
- package/dist/queue-advanced-e2e-setup.js +78 -5
- package/dist/queue-advanced-footer.e2e.test.js +40 -246
- package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
- package/dist/queue-advanced-permissions-typing.e2e.test.js +7 -9
- package/dist/queue-advanced-question.e2e.test.js +31 -26
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +2 -2
- package/dist/queue-delete-message.e2e.test.js +1 -1
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
- package/dist/queue-interrupt-drain.e2e.test.js +112 -2
- package/dist/queue-question-select-drain.e2e.test.js +30 -128
- package/dist/runtime-lifecycle.e2e.test.js +10 -37
- package/dist/schema.js +34 -0
- package/dist/session-handler/event-stream-state.js +267 -50
- package/dist/session-handler/event-stream-state.test.js +415 -8
- package/dist/session-handler/model-utils.js +159 -5
- package/dist/session-handler/model-utils.test.js +345 -0
- package/dist/session-handler/thread-session-runtime.js +252 -182
- package/dist/session-sleep.test.js +224 -0
- package/dist/session-title-rename.test.js +7 -39
- package/dist/sleep.e2e.test.js +491 -0
- package/dist/startup-time.e2e.test.js +2 -1
- package/dist/store.js +3 -0
- package/dist/system-message.js +59 -6
- package/dist/system-message.test.js +76 -6
- package/dist/task-runner.js +94 -4
- package/dist/task-schedule.js +56 -0
- package/dist/task-schedule.test.js +71 -1
- package/dist/thread-message-queue.e2e.test.js +10 -169
- package/dist/undo-redo.e2e.test.js +2 -2
- package/dist/voice-attachment.js +23 -1
- package/dist/voice-message.e2e.test.js +10 -10
- package/dist/voice.test.js +55 -29
- package/dist/worktree-clone-isolation.e2e.test.js +188 -0
- package/dist/worktree-lifecycle.e2e.test.js +29 -10
- package/dist/worktrees.js +226 -443
- package/dist/worktrees.test.js +435 -58
- package/dist/xai-auth-plugin.js +1 -0
- package/dist/xai-auth-state.js +1 -0
- package/package.json +8 -6
- package/skills/errore/SKILL.md +18 -0
- package/skills/goke/SKILL.md +150 -10
- package/skills/holocron/SKILL.md +37 -0
- package/skills/npm-package/SKILL.md +55 -35
- package/skills/profano/SKILL.md +17 -2
- package/skills/spiceflow/SKILL.md +39 -3
- package/skills/strada/SKILL.md +39 -0
- package/skills/zele/SKILL.md +19 -0
- package/src/agent-model.e2e.test.ts +25 -151
- package/src/anthropic-account-identity.ts +1 -0
- package/src/anthropic-auth-plugin.ts +1 -0
- package/src/anthropic-auth-state.ts +1 -0
- package/src/bin.ts +78 -26
- package/src/cli-commands/maintenance.ts +59 -13
- package/src/cli-commands/send.ts +25 -0
- package/src/cli-commands/session.ts +203 -9
- package/src/cli-commands/task.ts +13 -1
- package/src/cli-parsing.test.ts +69 -3
- package/src/cli-runner.test.ts +49 -0
- package/src/cli-runner.ts +28 -4
- package/src/cli-send-thread.e2e.test.ts +1 -1
- package/src/cli.ts +53 -0
- package/src/commands/abort.ts +6 -1
- package/src/commands/agent.ts +6 -6
- package/src/commands/ask-question.test.ts +48 -1
- package/src/commands/ask-question.ts +21 -32
- package/src/commands/login.ts +6 -0
- package/src/commands/merge-worktree.ts +43 -12
- package/src/commands/model-variant.ts +8 -1
- package/src/commands/model.ts +18 -8
- package/src/commands/multioauth.ts +1 -0
- package/src/commands/new-worktree.ts +138 -55
- package/src/commands/user-command.ts +6 -6
- package/src/commands/worktrees.ts +5 -12
- package/src/config.ts +18 -1
- package/src/database.ts +195 -3
- package/src/db.test.ts +188 -0
- package/src/db.ts +26 -0
- package/src/discord-bot.ts +35 -29
- package/src/discord-command-registration.ts +15 -1
- package/src/discord-utils.test.ts +19 -0
- package/src/discord-utils.ts +21 -6
- package/src/errors.ts +5 -0
- package/src/exec-async.ts +20 -11
- package/src/external-directory-permissions.e2e.test.ts +2 -2
- package/src/file-edit-log.test.ts +287 -0
- package/src/file-edit-log.ts +283 -0
- package/src/format-tables.test.ts +101 -0
- package/src/format-tables.ts +75 -0
- package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
- package/src/gateway-proxy.e2e.test.ts +5 -5
- package/src/git-worktree-core.ts +247 -20
- package/src/ipc-tools-plugin.ts +75 -5
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
- package/src/kimaki-opencode-plugin.ts +7 -3
- package/src/kimaki-workspace-adaptor.ts +40 -11
- package/src/logger.ts +13 -4
- package/src/markdown.test.ts +13 -3
- package/src/markdown.ts +4 -1
- package/src/message-formatting.test.ts +86 -12
- package/src/message-formatting.ts +19 -7
- package/src/oauth-rotation-shared.ts +10 -0
- package/src/openai-auth-plugin.ts +1 -0
- package/src/openai-auth-state.ts +1 -0
- package/src/opencode-command.test.ts +81 -0
- package/src/opencode.ts +71 -11
- package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
- package/src/question-text-before-dropdown.e2e.test.ts +67 -0
- package/src/queue-advanced-abort.e2e.test.ts +1 -1
- package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
- package/src/queue-advanced-e2e-setup.ts +83 -4
- package/src/queue-advanced-footer.e2e.test.ts +47 -294
- package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
- package/src/queue-advanced-permissions-typing.e2e.test.ts +7 -9
- package/src/queue-advanced-question.e2e.test.ts +35 -29
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +2 -2
- package/src/queue-delete-message.e2e.test.ts +1 -1
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
- package/src/queue-interrupt-drain.e2e.test.ts +123 -2
- package/src/queue-question-select-drain.e2e.test.ts +32 -157
- package/src/runtime-lifecycle.e2e.test.ts +10 -41
- package/src/schema.sql +14 -1
- package/src/schema.ts +36 -0
- package/src/session-handler/event-stream-state.test.ts +437 -7
- package/src/session-handler/event-stream-state.ts +389 -53
- package/src/session-handler/model-utils.test.ts +415 -0
- package/src/session-handler/model-utils.ts +249 -5
- package/src/session-handler/thread-session-runtime.ts +300 -215
- package/src/session-sleep.test.ts +294 -0
- package/src/session-title-rename.test.ts +9 -48
- package/src/sleep.e2e.test.ts +562 -0
- package/src/startup-time.e2e.test.ts +5 -1
- package/src/store.ts +21 -0
- package/src/system-message.test.ts +86 -6
- package/src/system-message.ts +66 -7
- package/src/task-runner.ts +136 -2
- package/src/task-schedule.test.ts +76 -0
- package/src/task-schedule.ts +95 -0
- package/src/thread-message-queue.e2e.test.ts +10 -202
- package/src/undo-redo.e2e.test.ts +2 -2
- package/src/voice-attachment.ts +29 -1
- package/src/voice-message.e2e.test.ts +10 -10
- package/src/voice.test.ts +63 -29
- package/src/worktree-clone-isolation.e2e.test.ts +213 -0
- package/src/worktree-lifecycle.e2e.test.ts +29 -9
- package/src/worktrees.test.ts +505 -64
- package/src/worktrees.ts +257 -603
- package/src/xai-auth-plugin.ts +1 -0
- package/src/xai-auth-state.ts +1 -0
- package/dist/message-finish-field.e2e.test.js +0 -169
- package/dist/subagent-rate-limit-plugin.js +0 -184
- package/src/message-finish-field.e2e.test.ts +0 -196
- package/src/subagent-rate-limit-plugin.ts +0 -228
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
// Tests for model ID parsing, cached provider.list wrapping, and validation.
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, test } from 'vitest'
|
|
4
|
+
import { InvalidModelError } from '../errors.js'
|
|
5
|
+
import {
|
|
6
|
+
clearModelListCache,
|
|
7
|
+
formatDisplayedModelId,
|
|
8
|
+
getProviderModelName,
|
|
9
|
+
listModels,
|
|
10
|
+
parseModelId,
|
|
11
|
+
resolveDisplayedModelId,
|
|
12
|
+
validateCliModelOption,
|
|
13
|
+
validateModelId,
|
|
14
|
+
validateModelIdAgainstList,
|
|
15
|
+
type ListedModel,
|
|
16
|
+
} from './model-utils.js'
|
|
17
|
+
|
|
18
|
+
const listed: ListedModel[] = [
|
|
19
|
+
{
|
|
20
|
+
providerID: 'anthropic',
|
|
21
|
+
modelID: 'claude-opus-4-6',
|
|
22
|
+
name: 'Claude Opus 4.6',
|
|
23
|
+
connected: true,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
providerID: 'anthropic',
|
|
27
|
+
modelID: 'claude-sonnet-4-6',
|
|
28
|
+
name: 'Claude Sonnet 4.6',
|
|
29
|
+
connected: true,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
providerID: 'openai',
|
|
33
|
+
modelID: 'gpt-5.4',
|
|
34
|
+
name: 'GPT-5.4',
|
|
35
|
+
connected: false,
|
|
36
|
+
},
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
clearModelListCache()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('getProviderModelName', () => {
|
|
44
|
+
const providers = [
|
|
45
|
+
{
|
|
46
|
+
id: 'subrouter',
|
|
47
|
+
models: {
|
|
48
|
+
build: { name: 'build (claude-opus-4-6)' },
|
|
49
|
+
default: { name: 'default' },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'deterministic-provider',
|
|
54
|
+
models: {
|
|
55
|
+
'deterministic-v2': { name: 'deterministic-v2' },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
test('uses the sdk display name when subrouter adds the live model', () => {
|
|
61
|
+
expect(
|
|
62
|
+
getProviderModelName({
|
|
63
|
+
providers,
|
|
64
|
+
providerID: 'subrouter',
|
|
65
|
+
modelID: 'build',
|
|
66
|
+
}),
|
|
67
|
+
).toMatchInlineSnapshot(`"build (claude-opus-4-6)"`)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('falls back to the model id when name is missing', () => {
|
|
71
|
+
expect(
|
|
72
|
+
getProviderModelName({
|
|
73
|
+
providers: [{ id: 'subrouter', models: { build: {} } }],
|
|
74
|
+
providerID: 'subrouter',
|
|
75
|
+
modelID: 'build',
|
|
76
|
+
}),
|
|
77
|
+
).toMatchInlineSnapshot(`"build"`)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('returns undefined when provider or model is missing', () => {
|
|
81
|
+
expect(
|
|
82
|
+
getProviderModelName({
|
|
83
|
+
providers,
|
|
84
|
+
providerID: 'subrouter',
|
|
85
|
+
modelID: 'missing',
|
|
86
|
+
}),
|
|
87
|
+
).toBeUndefined()
|
|
88
|
+
expect(
|
|
89
|
+
getProviderModelName({
|
|
90
|
+
providers,
|
|
91
|
+
providerID: 'openai',
|
|
92
|
+
modelID: 'gpt-5.4',
|
|
93
|
+
}),
|
|
94
|
+
).toBeUndefined()
|
|
95
|
+
expect(
|
|
96
|
+
getProviderModelName({
|
|
97
|
+
providers,
|
|
98
|
+
}),
|
|
99
|
+
).toBeUndefined()
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('formatDisplayedModelId', () => {
|
|
104
|
+
test('keeps provider/id when the sdk name matches the model id', () => {
|
|
105
|
+
expect(
|
|
106
|
+
formatDisplayedModelId({
|
|
107
|
+
providerID: 'deterministic-provider',
|
|
108
|
+
modelID: 'deterministic-v2',
|
|
109
|
+
name: 'deterministic-v2',
|
|
110
|
+
}),
|
|
111
|
+
).toMatchInlineSnapshot(`"deterministic-provider/deterministic-v2"`)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('uses the sdk name so subrouter live model shows in parentheses', () => {
|
|
115
|
+
expect(
|
|
116
|
+
formatDisplayedModelId({
|
|
117
|
+
providerID: 'subrouter',
|
|
118
|
+
modelID: 'build',
|
|
119
|
+
name: 'build (claude-opus-4-6)',
|
|
120
|
+
}),
|
|
121
|
+
).toMatchInlineSnapshot(`"subrouter/build (claude-opus-4-6)"`)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('falls back to provider/id when name is missing', () => {
|
|
125
|
+
expect(
|
|
126
|
+
formatDisplayedModelId({
|
|
127
|
+
providerID: 'subrouter',
|
|
128
|
+
modelID: 'build',
|
|
129
|
+
}),
|
|
130
|
+
).toMatchInlineSnapshot(`"subrouter/build"`)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('keeps provider/id when sdk name is a pretty label', () => {
|
|
134
|
+
expect(
|
|
135
|
+
formatDisplayedModelId({
|
|
136
|
+
providerID: 'anthropic',
|
|
137
|
+
modelID: 'claude-opus-4-6',
|
|
138
|
+
name: 'Claude Opus 4.6',
|
|
139
|
+
}),
|
|
140
|
+
).toMatchInlineSnapshot(`"anthropic/claude-opus-4-6"`)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('resolveDisplayedModelId', () => {
|
|
145
|
+
test('uses subrouter sdk name with parentheses', () => {
|
|
146
|
+
expect(
|
|
147
|
+
resolveDisplayedModelId({
|
|
148
|
+
providers: [
|
|
149
|
+
{
|
|
150
|
+
id: 'subrouter',
|
|
151
|
+
models: { build: { name: 'build (claude-opus-4-6)' } },
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
providerID: 'subrouter',
|
|
155
|
+
modelID: 'build',
|
|
156
|
+
}),
|
|
157
|
+
).toMatchInlineSnapshot(`"subrouter/build (claude-opus-4-6)"`)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('parseModelId', () => {
|
|
162
|
+
test('splits provider/model', () => {
|
|
163
|
+
expect(parseModelId('anthropic/claude-opus-4-6')).toMatchInlineSnapshot(`
|
|
164
|
+
{
|
|
165
|
+
"modelID": "claude-opus-4-6",
|
|
166
|
+
"providerID": "anthropic",
|
|
167
|
+
}
|
|
168
|
+
`)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('keeps slashes inside the model id', () => {
|
|
172
|
+
expect(parseModelId('opencode/glm-5/free')).toMatchInlineSnapshot(`
|
|
173
|
+
{
|
|
174
|
+
"modelID": "glm-5/free",
|
|
175
|
+
"providerID": "opencode",
|
|
176
|
+
}
|
|
177
|
+
`)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('rejects missing slash, empty provider, and empty model', () => {
|
|
181
|
+
expect(parseModelId('claude-opus-4-6')).toBeUndefined()
|
|
182
|
+
expect(parseModelId('/claude-opus-4-6')).toBeUndefined()
|
|
183
|
+
expect(parseModelId('anthropic/')).toBeUndefined()
|
|
184
|
+
expect(parseModelId('')).toBeUndefined()
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('validateModelIdAgainstList', () => {
|
|
189
|
+
test('accepts a connected listed model', () => {
|
|
190
|
+
expect(
|
|
191
|
+
validateModelIdAgainstList({
|
|
192
|
+
model: 'anthropic/claude-opus-4-6',
|
|
193
|
+
models: listed,
|
|
194
|
+
}),
|
|
195
|
+
).toMatchInlineSnapshot(`
|
|
196
|
+
{
|
|
197
|
+
"modelID": "claude-opus-4-6",
|
|
198
|
+
"providerID": "anthropic",
|
|
199
|
+
}
|
|
200
|
+
`)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('rejects bad format', () => {
|
|
204
|
+
const result = validateModelIdAgainstList({
|
|
205
|
+
model: 'claude-opus-4-6',
|
|
206
|
+
models: listed,
|
|
207
|
+
})
|
|
208
|
+
expect(result).toBeInstanceOf(InvalidModelError)
|
|
209
|
+
if (result instanceof InvalidModelError) {
|
|
210
|
+
expect(result.model).toBe('claude-opus-4-6')
|
|
211
|
+
expect(result.reason).toContain('provider/model')
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('rejects unknown model and lists siblings', () => {
|
|
216
|
+
const result = validateModelIdAgainstList({
|
|
217
|
+
model: 'anthropic/does-not-exist',
|
|
218
|
+
models: listed,
|
|
219
|
+
})
|
|
220
|
+
expect(result).toBeInstanceOf(InvalidModelError)
|
|
221
|
+
if (result instanceof InvalidModelError) {
|
|
222
|
+
expect(result.message).toContain('anthropic/does-not-exist')
|
|
223
|
+
expect(result.message).toContain('anthropic/claude-opus-4-6')
|
|
224
|
+
expect(result.message).toContain('anthropic/claude-sonnet-4-6')
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('rejects disconnected provider', () => {
|
|
229
|
+
const result = validateModelIdAgainstList({
|
|
230
|
+
model: 'openai/gpt-5.4',
|
|
231
|
+
models: listed,
|
|
232
|
+
})
|
|
233
|
+
expect(result).toBeInstanceOf(InvalidModelError)
|
|
234
|
+
if (result instanceof InvalidModelError) {
|
|
235
|
+
expect(result.message).toContain('not connected')
|
|
236
|
+
expect(result.message).toContain('openai')
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
function fakeGetClient(options?: {
|
|
242
|
+
calls?: { count: number }
|
|
243
|
+
failOnce?: boolean
|
|
244
|
+
}) {
|
|
245
|
+
let failed = false
|
|
246
|
+
return () => ({
|
|
247
|
+
provider: {
|
|
248
|
+
list: async () => {
|
|
249
|
+
if (options?.calls) options.calls.count += 1
|
|
250
|
+
if (options?.failOnce && !failed) {
|
|
251
|
+
failed = true
|
|
252
|
+
throw new Error('provider.list failed')
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
data: {
|
|
256
|
+
all: [
|
|
257
|
+
{
|
|
258
|
+
id: 'anthropic',
|
|
259
|
+
models: {
|
|
260
|
+
'claude-opus-4-6': { name: 'Claude Opus 4.6' },
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
connected: ['anthropic'],
|
|
265
|
+
default: {},
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
describe('listModels', () => {
|
|
274
|
+
test('returns listed models from provider.list', async () => {
|
|
275
|
+
const models = await listModels({
|
|
276
|
+
getClient: fakeGetClient() as never,
|
|
277
|
+
directory: '/tmp/project-a',
|
|
278
|
+
})
|
|
279
|
+
expect(models).not.toBeInstanceOf(Error)
|
|
280
|
+
if (models instanceof Error) throw models
|
|
281
|
+
expect(models).toMatchInlineSnapshot(`
|
|
282
|
+
[
|
|
283
|
+
{
|
|
284
|
+
"connected": true,
|
|
285
|
+
"modelID": "claude-opus-4-6",
|
|
286
|
+
"name": "Claude Opus 4.6",
|
|
287
|
+
"providerID": "anthropic",
|
|
288
|
+
},
|
|
289
|
+
]
|
|
290
|
+
`)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test('memoizes provider.list per directory', async () => {
|
|
294
|
+
const calls = { count: 0 }
|
|
295
|
+
const getClient = fakeGetClient({ calls }) as never
|
|
296
|
+
const first = await listModels({ getClient, directory: '/tmp/project-a' })
|
|
297
|
+
const second = await listModels({ getClient, directory: '/tmp/project-a' })
|
|
298
|
+
expect(first).not.toBeInstanceOf(Error)
|
|
299
|
+
expect(second).not.toBeInstanceOf(Error)
|
|
300
|
+
expect(calls.count).toBe(1)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test('does not share cache across directories', async () => {
|
|
304
|
+
const calls = { count: 0 }
|
|
305
|
+
const getClient = fakeGetClient({ calls }) as never
|
|
306
|
+
const first = await listModels({ getClient, directory: '/tmp/project-a' })
|
|
307
|
+
const second = await listModels({ getClient, directory: '/tmp/project-b' })
|
|
308
|
+
expect(first).not.toBeInstanceOf(Error)
|
|
309
|
+
expect(second).not.toBeInstanceOf(Error)
|
|
310
|
+
expect(calls.count).toBe(2)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
test('does not cache failed fetches', async () => {
|
|
314
|
+
const calls = { count: 0 }
|
|
315
|
+
const getClient = fakeGetClient({ calls, failOnce: true }) as never
|
|
316
|
+
const first = await listModels({ getClient, directory: '/tmp/project-a' })
|
|
317
|
+
expect(first).toBeInstanceOf(Error)
|
|
318
|
+
const second = await listModels({ getClient, directory: '/tmp/project-a' })
|
|
319
|
+
expect(second).not.toBeInstanceOf(Error)
|
|
320
|
+
expect(calls.count).toBe(2)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
test('does not restore a pending result after the cache is cleared', async () => {
|
|
324
|
+
const calls = { count: 0 }
|
|
325
|
+
const firstResponse = Promise.withResolvers<{
|
|
326
|
+
data: {
|
|
327
|
+
all: Array<{ id: string; models: Record<string, { name: string }> }>
|
|
328
|
+
connected: string[]
|
|
329
|
+
default: Record<string, string>
|
|
330
|
+
}
|
|
331
|
+
}>()
|
|
332
|
+
const getClient = (() => ({
|
|
333
|
+
provider: {
|
|
334
|
+
list: () => {
|
|
335
|
+
calls.count += 1
|
|
336
|
+
if (calls.count === 1) return firstResponse.promise
|
|
337
|
+
return Promise.resolve({
|
|
338
|
+
data: {
|
|
339
|
+
all: [{ id: 'openai', models: { 'gpt-5.5': { name: 'GPT-5.5' } } }],
|
|
340
|
+
connected: ['openai'],
|
|
341
|
+
default: {},
|
|
342
|
+
},
|
|
343
|
+
})
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
})) as never
|
|
347
|
+
|
|
348
|
+
const pending = listModels({ getClient, directory: '/tmp/project-a' })
|
|
349
|
+
clearModelListCache()
|
|
350
|
+
firstResponse.resolve({
|
|
351
|
+
data: {
|
|
352
|
+
all: [
|
|
353
|
+
{
|
|
354
|
+
id: 'anthropic',
|
|
355
|
+
models: { 'claude-opus-4-6': { name: 'Claude Opus 4.6' } },
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
connected: ['anthropic'],
|
|
359
|
+
default: {},
|
|
360
|
+
},
|
|
361
|
+
})
|
|
362
|
+
await pending
|
|
363
|
+
|
|
364
|
+
const refreshed = await listModels({ getClient, directory: '/tmp/project-a' })
|
|
365
|
+
expect({ calls: calls.count, refreshed }).toMatchInlineSnapshot(`
|
|
366
|
+
{
|
|
367
|
+
"calls": 2,
|
|
368
|
+
"refreshed": [
|
|
369
|
+
{
|
|
370
|
+
"connected": true,
|
|
371
|
+
"modelID": "gpt-5.5",
|
|
372
|
+
"name": "GPT-5.5",
|
|
373
|
+
"providerID": "openai",
|
|
374
|
+
},
|
|
375
|
+
],
|
|
376
|
+
}
|
|
377
|
+
`)
|
|
378
|
+
})
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
describe('validateCliModelOption', () => {
|
|
382
|
+
test('skips empty model', async () => {
|
|
383
|
+
expect(await validateCliModelOption({ model: undefined })).toBeUndefined()
|
|
384
|
+
expect(await validateCliModelOption({ model: '' })).toBeUndefined()
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
test('rejects bad format without starting opencode', async () => {
|
|
388
|
+
const result = await validateCliModelOption({ model: 'claude-opus-4-6' })
|
|
389
|
+
expect(result).toBeInstanceOf(InvalidModelError)
|
|
390
|
+
})
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
describe('validateModelId', () => {
|
|
394
|
+
test('validates against the cached list', async () => {
|
|
395
|
+
const getClient = fakeGetClient() as never
|
|
396
|
+
const ok = await validateModelId({
|
|
397
|
+
model: 'anthropic/claude-opus-4-6',
|
|
398
|
+
getClient,
|
|
399
|
+
directory: '/tmp/project-a',
|
|
400
|
+
})
|
|
401
|
+
expect(ok).toMatchInlineSnapshot(`
|
|
402
|
+
{
|
|
403
|
+
"modelID": "claude-opus-4-6",
|
|
404
|
+
"providerID": "anthropic",
|
|
405
|
+
}
|
|
406
|
+
`)
|
|
407
|
+
|
|
408
|
+
const bad = await validateModelId({
|
|
409
|
+
model: 'anthropic/missing',
|
|
410
|
+
getClient,
|
|
411
|
+
directory: '/tmp/project-a',
|
|
412
|
+
})
|
|
413
|
+
expect(bad).toBeInstanceOf(InvalidModelError)
|
|
414
|
+
})
|
|
415
|
+
})
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
// Model resolution utilities.
|
|
2
2
|
// getDefaultModel resolves the default model from OpenCode when no user preference is set.
|
|
3
|
+
// listModels wraps provider.list() with a per-directory memoized cache.
|
|
3
4
|
|
|
4
5
|
import fs from 'node:fs'
|
|
5
6
|
import path from 'node:path'
|
|
6
7
|
import { xdgState } from 'xdg-basedir'
|
|
7
8
|
import * as errore from 'errore'
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
9
|
+
import type { OpencodeClient, Provider } from '@opencode-ai/sdk/v2'
|
|
10
|
+
import { InvalidModelError, OpenCodeSdkError } from '../errors.js'
|
|
11
|
+
import {
|
|
12
|
+
initializeOpencodeForDirectory,
|
|
13
|
+
subscribeOpencodeServerLifecycle,
|
|
14
|
+
} from '../opencode.js'
|
|
10
15
|
import { createLogger, LogPrefix } from '../logger.js'
|
|
11
16
|
import type { ScheduledTaskScheduleKind } from '../database.js'
|
|
12
17
|
|
|
@@ -58,7 +63,7 @@ function getRecentModelsFromTuiState(): Array<{
|
|
|
58
63
|
/**
|
|
59
64
|
* Parse a model string in format "provider/model" into providerID and modelID.
|
|
60
65
|
*/
|
|
61
|
-
function
|
|
66
|
+
export function parseModelId(
|
|
62
67
|
model: string,
|
|
63
68
|
): { providerID: string; modelID: string } | undefined {
|
|
64
69
|
const [providerID, ...modelParts] = model.split('/')
|
|
@@ -85,7 +90,7 @@ function getModelFromProjectConfig({
|
|
|
85
90
|
if (!parsed.model) {
|
|
86
91
|
return undefined
|
|
87
92
|
}
|
|
88
|
-
return
|
|
93
|
+
return parseModelId(parsed.model)
|
|
89
94
|
})
|
|
90
95
|
if (result instanceof Error) return undefined
|
|
91
96
|
return result
|
|
@@ -107,6 +112,245 @@ function isModelValid(
|
|
|
107
112
|
return isConnected && !!modelExists
|
|
108
113
|
}
|
|
109
114
|
|
|
115
|
+
export type ListedModel = {
|
|
116
|
+
providerID: string
|
|
117
|
+
modelID: string
|
|
118
|
+
name: string
|
|
119
|
+
connected: boolean
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
type ProviderModelLookup = {
|
|
123
|
+
id: string
|
|
124
|
+
models?: Record<string, { name?: string } | undefined>
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** SDK display name from provider.list. Subrouter sets this to `build (claude-opus-4-6)`. */
|
|
128
|
+
export function getProviderModelName({
|
|
129
|
+
providers,
|
|
130
|
+
providerID,
|
|
131
|
+
modelID,
|
|
132
|
+
}: {
|
|
133
|
+
providers: ProviderModelLookup[]
|
|
134
|
+
providerID?: string
|
|
135
|
+
modelID?: string
|
|
136
|
+
}): string | undefined {
|
|
137
|
+
if (!providerID || !modelID) return undefined
|
|
138
|
+
const provider = providers.find((p) => p.id === providerID)
|
|
139
|
+
const model = provider?.models?.[modelID]
|
|
140
|
+
if (!model) return undefined
|
|
141
|
+
if (typeof model.name === 'string' && model.name.trim()) return model.name
|
|
142
|
+
return modelID
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Use the SDK name only when it is the model id, or the model id plus `(live-model)`. */
|
|
146
|
+
export function displayedModelLabel({
|
|
147
|
+
modelID,
|
|
148
|
+
name,
|
|
149
|
+
}: {
|
|
150
|
+
modelID: string
|
|
151
|
+
name?: string
|
|
152
|
+
}): string {
|
|
153
|
+
if (name && (name === modelID || name.startsWith(`${modelID} (`))) return name
|
|
154
|
+
return modelID
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function formatDisplayedModelId({
|
|
158
|
+
providerID,
|
|
159
|
+
modelID,
|
|
160
|
+
name,
|
|
161
|
+
}: {
|
|
162
|
+
providerID?: string
|
|
163
|
+
modelID?: string
|
|
164
|
+
name?: string
|
|
165
|
+
}): string | undefined {
|
|
166
|
+
if (!providerID || !modelID) return undefined
|
|
167
|
+
return `${providerID}/${displayedModelLabel({ modelID, name })}`
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function resolveDisplayedModelId({
|
|
171
|
+
providers,
|
|
172
|
+
providerID,
|
|
173
|
+
modelID,
|
|
174
|
+
}: {
|
|
175
|
+
providers: ProviderModelLookup[]
|
|
176
|
+
providerID?: string
|
|
177
|
+
modelID?: string
|
|
178
|
+
}): string | undefined {
|
|
179
|
+
return formatDisplayedModelId({
|
|
180
|
+
providerID,
|
|
181
|
+
modelID,
|
|
182
|
+
name: getProviderModelName({ providers, providerID, modelID }),
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
type ModelListCacheEntry =
|
|
187
|
+
| { status: 'pending'; promise: Promise<ListedModel[] | OpenCodeSdkError> }
|
|
188
|
+
| { status: 'ready'; value: ListedModel[] }
|
|
189
|
+
|
|
190
|
+
const modelListCache = new Map<string, ModelListCacheEntry>()
|
|
191
|
+
|
|
192
|
+
export function clearModelListCache() {
|
|
193
|
+
modelListCache.clear()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
subscribeOpencodeServerLifecycle(() => {
|
|
197
|
+
clearModelListCache()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
function flattenProviderModels({
|
|
201
|
+
providers,
|
|
202
|
+
connected,
|
|
203
|
+
}: {
|
|
204
|
+
providers: Provider[]
|
|
205
|
+
connected: string[]
|
|
206
|
+
}): ListedModel[] {
|
|
207
|
+
const connectedSet = new Set(connected)
|
|
208
|
+
const models: ListedModel[] = []
|
|
209
|
+
for (const provider of providers) {
|
|
210
|
+
const isConnected = connectedSet.has(provider.id)
|
|
211
|
+
for (const [modelID, model] of Object.entries(provider.models)) {
|
|
212
|
+
models.push({
|
|
213
|
+
providerID: provider.id,
|
|
214
|
+
modelID,
|
|
215
|
+
name: model.name || modelID,
|
|
216
|
+
connected: isConnected,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return models
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function listModels({
|
|
224
|
+
getClient,
|
|
225
|
+
directory,
|
|
226
|
+
}: {
|
|
227
|
+
getClient: () => OpencodeClient
|
|
228
|
+
directory?: string
|
|
229
|
+
}): Promise<ListedModel[] | OpenCodeSdkError> {
|
|
230
|
+
const cacheKey = directory ?? ''
|
|
231
|
+
const cached = modelListCache.get(cacheKey)
|
|
232
|
+
if (cached?.status === 'ready') return cached.value
|
|
233
|
+
if (cached?.status === 'pending') return cached.promise
|
|
234
|
+
|
|
235
|
+
const promise = (async () => {
|
|
236
|
+
const providersResponse = await getClient()
|
|
237
|
+
.provider.list({ directory })
|
|
238
|
+
.catch((e) => new OpenCodeSdkError({ operation: 'provider.list', cause: e }))
|
|
239
|
+
if (providersResponse instanceof Error) return providersResponse
|
|
240
|
+
if (!providersResponse.data) {
|
|
241
|
+
return new OpenCodeSdkError({ operation: 'provider.list' })
|
|
242
|
+
}
|
|
243
|
+
return flattenProviderModels({
|
|
244
|
+
providers: providersResponse.data.all,
|
|
245
|
+
connected: providersResponse.data.connected,
|
|
246
|
+
})
|
|
247
|
+
})()
|
|
248
|
+
|
|
249
|
+
modelListCache.set(cacheKey, { status: 'pending', promise })
|
|
250
|
+
const result = await promise
|
|
251
|
+
const current = modelListCache.get(cacheKey)
|
|
252
|
+
const ownsCacheEntry = current?.status === 'pending' && current.promise === promise
|
|
253
|
+
if (result instanceof Error) {
|
|
254
|
+
if (ownsCacheEntry) modelListCache.delete(cacheKey)
|
|
255
|
+
return result
|
|
256
|
+
}
|
|
257
|
+
if (ownsCacheEntry) modelListCache.set(cacheKey, { status: 'ready', value: result })
|
|
258
|
+
return result
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function validateModelIdAgainstList({
|
|
262
|
+
model,
|
|
263
|
+
models,
|
|
264
|
+
}: {
|
|
265
|
+
model: string
|
|
266
|
+
models: ListedModel[]
|
|
267
|
+
}): { providerID: string; modelID: string } | InvalidModelError {
|
|
268
|
+
const parsed = parseModelId(model)
|
|
269
|
+
if (!parsed) {
|
|
270
|
+
return new InvalidModelError({
|
|
271
|
+
model,
|
|
272
|
+
reason: 'expected provider/model, for example anthropic/claude-opus-4-6',
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const match = models.find((candidate) => {
|
|
277
|
+
return (
|
|
278
|
+
candidate.providerID === parsed.providerID &&
|
|
279
|
+
candidate.modelID === parsed.modelID
|
|
280
|
+
)
|
|
281
|
+
})
|
|
282
|
+
if (!match) {
|
|
283
|
+
const siblings = models
|
|
284
|
+
.filter((candidate) => candidate.providerID === parsed.providerID)
|
|
285
|
+
.map((candidate) => `${candidate.providerID}/${candidate.modelID}`)
|
|
286
|
+
if (siblings.length > 0) {
|
|
287
|
+
return new InvalidModelError({
|
|
288
|
+
model,
|
|
289
|
+
reason: `unknown model. Similar: ${siblings.slice(0, 8).join(', ')}`,
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
const connectedProviders = [
|
|
293
|
+
...new Set(
|
|
294
|
+
models.filter((candidate) => candidate.connected).map((candidate) => {
|
|
295
|
+
return candidate.providerID
|
|
296
|
+
}),
|
|
297
|
+
),
|
|
298
|
+
]
|
|
299
|
+
const connectedHint = connectedProviders.length
|
|
300
|
+
? ` Connected providers: ${connectedProviders.join(', ')}`
|
|
301
|
+
: ''
|
|
302
|
+
return new InvalidModelError({
|
|
303
|
+
model,
|
|
304
|
+
reason: `unknown provider ${parsed.providerID}.${connectedHint}`,
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
if (!match.connected) {
|
|
308
|
+
return new InvalidModelError({
|
|
309
|
+
model,
|
|
310
|
+
reason: `provider ${parsed.providerID} is not connected`,
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
return parsed
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function validateModelId({
|
|
317
|
+
model,
|
|
318
|
+
getClient,
|
|
319
|
+
directory,
|
|
320
|
+
}: {
|
|
321
|
+
model: string
|
|
322
|
+
getClient: () => OpencodeClient
|
|
323
|
+
directory?: string
|
|
324
|
+
}): Promise<
|
|
325
|
+
{ providerID: string; modelID: string } | InvalidModelError | OpenCodeSdkError
|
|
326
|
+
> {
|
|
327
|
+
const models = await listModels({ getClient, directory })
|
|
328
|
+
if (models instanceof Error) return models
|
|
329
|
+
return validateModelIdAgainstList({ model, models })
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function validateCliModelOption({
|
|
333
|
+
model,
|
|
334
|
+
directory,
|
|
335
|
+
}: {
|
|
336
|
+
model: string | undefined
|
|
337
|
+
directory?: string
|
|
338
|
+
}) {
|
|
339
|
+
if (!model) return
|
|
340
|
+
const parsed = parseModelId(model)
|
|
341
|
+
if (!parsed) {
|
|
342
|
+
return new InvalidModelError({
|
|
343
|
+
model,
|
|
344
|
+
reason: 'expected provider/model, for example anthropic/claude-opus-4-6',
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
if (!directory) return parsed
|
|
348
|
+
|
|
349
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
350
|
+
if (getClient instanceof Error) return getClient
|
|
351
|
+
return validateModelId({ model, getClient, directory })
|
|
352
|
+
}
|
|
353
|
+
|
|
110
354
|
/**
|
|
111
355
|
* Get the default model from OpenCode when no user preference is set.
|
|
112
356
|
* Priority (matches OpenCode TUI behavior):
|
|
@@ -163,7 +407,7 @@ export async function getDefaultModel({
|
|
|
163
407
|
const configResponse = await getClient().config.get({ directory })
|
|
164
408
|
.catch((e) => new OpenCodeSdkError({ operation: 'config.get', cause: e }))
|
|
165
409
|
if (!(configResponse instanceof Error) && configResponse.data?.model) {
|
|
166
|
-
const configModel =
|
|
410
|
+
const configModel = parseModelId(configResponse.data.model)
|
|
167
411
|
if (configModel && isModelValid(configModel, connected, providers)) {
|
|
168
412
|
sessionLogger.log(
|
|
169
413
|
`[MODEL] Using config model: ${configModel.providerID}/${configModel.modelID}`,
|