@cloudcli-ai/cloudcli 1.37.0 → 1.37.1

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.
Files changed (112) hide show
  1. package/dist/assets/index-D3jfzuKN.css +32 -0
  2. package/dist/assets/{index-eGqPeIEu.js → index-DTMWjJj8.js} +324 -305
  3. package/dist/index.html +2 -2
  4. package/dist-server/server/modules/commands/commands.routes.js +3 -3
  5. package/dist-server/server/modules/commands/commands.routes.js.map +1 -1
  6. package/dist-server/server/modules/commands/tests/commands.test.js +2 -7
  7. package/dist-server/server/modules/commands/tests/commands.test.js.map +1 -1
  8. package/dist-server/server/modules/database/index.js +2 -0
  9. package/dist-server/server/modules/database/index.js.map +1 -1
  10. package/dist-server/server/modules/database/migrations.js +18 -1
  11. package/dist-server/server/modules/database/migrations.js.map +1 -1
  12. package/dist-server/server/modules/database/repositories/provider-models.js +112 -0
  13. package/dist-server/server/modules/database/repositories/provider-models.js.map +1 -0
  14. package/dist-server/server/modules/database/repositories/sessions.db.js +63 -7
  15. package/dist-server/server/modules/database/repositories/sessions.db.js.map +1 -1
  16. package/dist-server/server/modules/database/schema.js +28 -3
  17. package/dist-server/server/modules/database/schema.js.map +1 -1
  18. package/dist-server/server/modules/database/tests/provider-models.db.integration.test.js +102 -0
  19. package/dist-server/server/modules/database/tests/provider-models.db.integration.test.js.map +1 -0
  20. package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js +2 -1
  21. package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js.map +1 -1
  22. package/dist-server/server/modules/database/tests/sessions.db.integration.test.js +22 -0
  23. package/dist-server/server/modules/database/tests/sessions.db.integration.test.js.map +1 -1
  24. package/dist-server/server/modules/file-tree/file-tree.module.js +5 -1
  25. package/dist-server/server/modules/file-tree/file-tree.module.js.map +1 -1
  26. package/dist-server/server/modules/file-tree/file-tree.service.js +50 -18
  27. package/dist-server/server/modules/file-tree/file-tree.service.js.map +1 -1
  28. package/dist-server/server/modules/file-tree/tests/file-tree.service.test.js +83 -7
  29. package/dist-server/server/modules/file-tree/tests/file-tree.service.test.js.map +1 -1
  30. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js +46 -14
  31. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js.map +1 -1
  32. package/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js +8 -15
  33. package/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js.map +1 -1
  34. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js +58 -71
  35. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js.map +1 -1
  36. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js +1 -54
  37. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js.map +1 -1
  38. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js +7 -7
  39. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js.map +1 -1
  40. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js +32 -669
  41. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js.map +1 -1
  42. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js +105 -328
  43. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js.map +1 -1
  44. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js +0 -12
  45. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js.map +1 -1
  46. package/dist-server/server/modules/providers/provider.routes.js +139 -31
  47. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  48. package/dist-server/server/modules/providers/services/provider-models.service.js +167 -199
  49. package/dist-server/server/modules/providers/services/provider-models.service.js.map +1 -1
  50. package/dist-server/server/modules/providers/services/provider-runtime.service.js +2 -2
  51. package/dist-server/server/modules/providers/services/provider-runtime.service.js.map +1 -1
  52. package/dist-server/server/modules/providers/services/session-conversations-search.service.js +12 -4
  53. package/dist-server/server/modules/providers/services/session-conversations-search.service.js.map +1 -1
  54. package/dist-server/server/modules/providers/services/sessions.service.js +62 -3
  55. package/dist-server/server/modules/providers/services/sessions.service.js.map +1 -1
  56. package/dist-server/server/modules/providers/tests/codex-sessions.test.js +53 -17
  57. package/dist-server/server/modules/providers/tests/codex-sessions.test.js.map +1 -1
  58. package/dist-server/server/modules/providers/tests/opencode-models.test.js +15 -126
  59. package/dist-server/server/modules/providers/tests/opencode-models.test.js.map +1 -1
  60. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js +5 -5
  61. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js.map +1 -1
  62. package/dist-server/server/modules/providers/tests/provider-models.service.test.js +175 -292
  63. package/dist-server/server/modules/providers/tests/provider-models.service.test.js.map +1 -1
  64. package/dist-server/server/modules/providers/tests/provider-runtime.service.test.js +2 -6
  65. package/dist-server/server/modules/providers/tests/provider-runtime.service.test.js.map +1 -1
  66. package/dist-server/server/modules/providers/tests/provider-token-usage.service.test.js +1 -0
  67. package/dist-server/server/modules/providers/tests/provider-token-usage.service.test.js.map +1 -1
  68. package/dist-server/server/modules/providers/tests/provider.routes.test.js +126 -0
  69. package/dist-server/server/modules/providers/tests/provider.routes.test.js.map +1 -0
  70. package/dist-server/server/modules/providers/tests/sessions.service.test.js +87 -0
  71. package/dist-server/server/modules/providers/tests/sessions.service.test.js.map +1 -0
  72. package/dist-server/server/modules/websocket/services/chat-websocket.service.js +5 -1
  73. package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
  74. package/package.json +3 -3
  75. package/server/modules/commands/commands.routes.ts +3 -3
  76. package/server/modules/commands/tests/commands.test.ts +2 -7
  77. package/server/modules/database/index.ts +2 -0
  78. package/server/modules/database/migrations.ts +20 -0
  79. package/server/modules/database/repositories/provider-models.ts +168 -0
  80. package/server/modules/database/repositories/sessions.db.ts +85 -8
  81. package/server/modules/database/schema.ts +29 -3
  82. package/server/modules/database/tests/provider-models.db.integration.test.ts +125 -0
  83. package/server/modules/database/tests/sessions-provider-mapping.test.ts +2 -1
  84. package/server/modules/database/tests/sessions.db.integration.test.ts +32 -0
  85. package/server/modules/file-tree/file-tree.module.ts +5 -1
  86. package/server/modules/file-tree/file-tree.service.ts +68 -20
  87. package/server/modules/file-tree/tests/file-tree.service.test.ts +104 -7
  88. package/server/modules/providers/list/claude/claude-models.provider.ts +46 -14
  89. package/server/modules/providers/list/claude/claude-runtime.provider.js +8 -16
  90. package/server/modules/providers/list/codex/codex-models.provider.ts +58 -99
  91. package/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +1 -60
  92. package/server/modules/providers/list/codex/codex-sessions.provider.ts +7 -7
  93. package/server/modules/providers/list/cursor/cursor-models.provider.ts +32 -710
  94. package/server/modules/providers/list/opencode/opencode-models.provider.ts +105 -413
  95. package/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +0 -13
  96. package/server/modules/providers/provider.routes.ts +185 -33
  97. package/server/modules/providers/services/provider-models.service.ts +223 -288
  98. package/server/modules/providers/services/provider-runtime.service.ts +2 -3
  99. package/server/modules/providers/services/session-conversations-search.service.ts +12 -4
  100. package/server/modules/providers/services/sessions.service.ts +87 -3
  101. package/server/modules/providers/tests/codex-sessions.test.ts +57 -18
  102. package/server/modules/providers/tests/opencode-models.test.ts +34 -135
  103. package/server/modules/providers/tests/opencode-sessions.test.ts +5 -5
  104. package/server/modules/providers/tests/provider-models.service.test.ts +353 -450
  105. package/server/modules/providers/tests/provider-runtime.service.test.ts +2 -6
  106. package/server/modules/providers/tests/provider-token-usage.service.test.ts +1 -0
  107. package/server/modules/providers/tests/provider.routes.test.ts +183 -0
  108. package/server/modules/providers/tests/sessions.service.test.ts +127 -0
  109. package/server/modules/websocket/services/chat-websocket.service.ts +5 -1
  110. package/server/shared/interfaces.ts +4 -6
  111. package/server/shared/types.ts +29 -17
  112. package/dist/assets/index-DSlds633.css +0 -32
@@ -1,450 +1,353 @@
1
- import assert from 'node:assert/strict';
2
- import { mkdtemp, rm } from 'node:fs/promises';
3
- import os from 'node:os';
4
- import path from 'node:path';
5
- import test from 'node:test';
6
-
7
- import {
8
- createProviderModelsService,
9
- PROVIDER_MODELS_CACHE_TTL_MS,
10
- } from '@/modules/providers/services/provider-models.service.js';
11
- import type {
12
- LLMProvider,
13
- ProviderCurrentActiveModel,
14
- ProviderModelsDefinition,
15
- } from '@/shared/types.js';
16
-
17
- const createModels = (value: string): ProviderModelsDefinition => ({
18
- OPTIONS: [{ value, label: value }],
19
- DEFAULT: value,
20
- });
21
-
22
- const createCurrentActiveModel = (model: string): ProviderCurrentActiveModel => ({
23
- model,
24
- });
25
-
26
- /** In-memory stand-in for the `sessions` table rows the service reads and writes. */
27
- const createSessionStore = (rows: Record<string, string | null> = {}) => {
28
- const sessions = new Map(Object.entries(rows));
29
- return {
30
- sessions,
31
- getSessionById: (sessionId: string) =>
32
- (sessions.has(sessionId) ? { model: sessions.get(sessionId) ?? null } : null),
33
- setSessionModel: (sessionId: string, model: string) => {
34
- sessions.set(sessionId, model);
35
- },
36
- };
37
- };
38
-
39
- const createEphemeralCachePath = (): string => path.join(
40
- os.tmpdir(),
41
- `provider-model-cache-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
42
- );
43
-
44
- test('provider models service delegates to the resolved provider model adapter', async () => {
45
- const calls: LLMProvider[] = [];
46
- const service = createProviderModelsService({
47
- cachePath: createEphemeralCachePath(),
48
- resolveProvider: (provider) => {
49
- calls.push(provider);
50
- return {
51
- models: {
52
- getSupportedModels: async () => createModels(`${provider}-models`),
53
- getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
54
- },
55
- };
56
- },
57
- });
58
-
59
- const models = await service.getProviderModels('codex', { bypassCache: true });
60
-
61
- assert.deepEqual(calls, ['codex']);
62
- assert.equal(models.models.DEFAULT, 'codex-models');
63
- assert.equal(models.cache.source, 'fresh');
64
- });
65
-
66
- test('provider models service returns each provider adapter result without rewriting it', async () => {
67
- const expectedModels: ProviderModelsDefinition = {
68
- OPTIONS: [
69
- { value: 'cursor-a', label: 'Cursor A' },
70
- { value: 'cursor-b', label: 'Cursor B' },
71
- ],
72
- DEFAULT: 'cursor-b',
73
- };
74
-
75
- const service = createProviderModelsService({
76
- cachePath: createEphemeralCachePath(),
77
- resolveProvider: () => ({
78
- models: {
79
- getSupportedModels: async () => expectedModels,
80
- getCurrentActiveModel: async () => createCurrentActiveModel('cursor-active'),
81
- },
82
- }),
83
- });
84
-
85
- const models = await service.getProviderModels('cursor', { bypassCache: true });
86
-
87
- assert.deepEqual(models.models, expectedModels);
88
- });
89
-
90
- test('provider models are cached for the three-day ttl', async () => {
91
- const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-ttl-'));
92
- let currentTime = 1_000;
93
- let loadCount = 0;
94
-
95
- try {
96
- const service = createProviderModelsService({
97
- cachePath: path.join(tempRoot, 'models-cache.json'),
98
- now: () => currentTime,
99
- resolveProvider: (provider) => ({
100
- models: {
101
- getSupportedModels: async () => {
102
- loadCount += 1;
103
- return createModels(`${provider}-${loadCount}`);
104
- },
105
- getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
106
- },
107
- }),
108
- });
109
-
110
- const first = await service.getProviderModels('codex');
111
- const cached = await service.getProviderModels('codex');
112
- assert.equal(loadCount, 1);
113
- assert.equal(cached.models.DEFAULT, first.models.DEFAULT);
114
- assert.equal(cached.cache.source, 'memory');
115
-
116
- currentTime += PROVIDER_MODELS_CACHE_TTL_MS - 1;
117
- await service.getProviderModels('codex');
118
- assert.equal(loadCount, 1);
119
-
120
- currentTime += 2;
121
- const refreshed = await service.getProviderModels('codex');
122
- assert.equal(loadCount, 2);
123
- assert.equal(refreshed.models.DEFAULT, 'codex-2');
124
- } finally {
125
- await rm(tempRoot, { recursive: true, force: true });
126
- }
127
- });
128
-
129
- test('claude provider models are always loaded directly from the provider', async () => {
130
- const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-claude-direct-'));
131
- let loadCount = 0;
132
-
133
- try {
134
- const service = createProviderModelsService({
135
- cachePath: path.join(tempRoot, 'models-cache.json'),
136
- resolveProvider: (provider) => ({
137
- models: {
138
- getSupportedModels: async () => {
139
- loadCount += 1;
140
- return createModels(`${provider}-${loadCount}`);
141
- },
142
- getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
143
- },
144
- }),
145
- });
146
-
147
- const first = await service.getProviderModels('claude');
148
- const second = await service.getProviderModels('claude');
149
-
150
- assert.equal(loadCount, 2);
151
- assert.equal(first.models.DEFAULT, 'claude-1');
152
- assert.equal(second.models.DEFAULT, 'claude-2');
153
- assert.equal(second.cache.source, 'fresh');
154
- } finally {
155
- await rm(tempRoot, { recursive: true, force: true });
156
- }
157
- });
158
-
159
- test('provider model cache is persisted across service instances', async () => {
160
- const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-file-'));
161
- const cachePath = path.join(tempRoot, 'models-cache.json');
162
-
163
- try {
164
- const writer = createProviderModelsService({
165
- cachePath,
166
- resolveProvider: () => ({
167
- models: {
168
- getSupportedModels: async () => createModels('cursor-cached'),
169
- getCurrentActiveModel: async () => createCurrentActiveModel('cursor-active'),
170
- },
171
- }),
172
- });
173
- await writer.getProviderModels('cursor');
174
-
175
- const reader = createProviderModelsService({
176
- cachePath,
177
- resolveProvider: () => ({
178
- models: {
179
- getSupportedModels: async () => {
180
- throw new Error('loader should not be called for persisted cache hits');
181
- },
182
- getCurrentActiveModel: async () => createCurrentActiveModel('cursor-active'),
183
- },
184
- }),
185
- });
186
- const models = await reader.getProviderModels('cursor');
187
- assert.equal(models.models.DEFAULT, 'cursor-cached');
188
- assert.equal(models.cache.source, 'disk');
189
- } finally {
190
- await rm(tempRoot, { recursive: true, force: true });
191
- }
192
- });
193
-
194
- test('concurrent provider model requests share one load operation', async () => {
195
- const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-pending-'));
196
- let loadCount = 0;
197
-
198
- try {
199
- const service = createProviderModelsService({
200
- cachePath: path.join(tempRoot, 'models-cache.json'),
201
- resolveProvider: () => ({
202
- models: {
203
- getSupportedModels: async () => {
204
- loadCount += 1;
205
- await new Promise((resolve) => setTimeout(resolve, 20));
206
- return createModels('claude-cached');
207
- },
208
- getCurrentActiveModel: async () => createCurrentActiveModel('claude-active'),
209
- },
210
- }),
211
- });
212
-
213
- const [first, second] = await Promise.all([
214
- service.getProviderModels('claude'),
215
- service.getProviderModels('claude'),
216
- ]);
217
-
218
- assert.equal(loadCount, 1);
219
- assert.equal(first.models.DEFAULT, 'claude-cached');
220
- assert.equal(second.models.DEFAULT, 'claude-cached');
221
- } finally {
222
- await rm(tempRoot, { recursive: true, force: true });
223
- }
224
- });
225
-
226
- test('bypassCache forces a fresh provider fetch and updates cache metadata', async () => {
227
- const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-refresh-'));
228
- let currentTime = 1_000;
229
- let loadCount = 0;
230
-
231
- try {
232
- const service = createProviderModelsService({
233
- cachePath: path.join(tempRoot, 'models-cache.json'),
234
- now: () => currentTime,
235
- resolveProvider: (provider) => ({
236
- models: {
237
- getSupportedModels: async () => {
238
- loadCount += 1;
239
- return createModels(`${provider}-${loadCount}`);
240
- },
241
- getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active-${loadCount}`),
242
- },
243
- }),
244
- });
245
-
246
- const first = await service.getProviderModels('claude');
247
- currentTime += 50;
248
- const refreshed = await service.getProviderModels('claude', { bypassCache: true });
249
-
250
- assert.equal(first.models.DEFAULT, 'claude-1');
251
- assert.equal(refreshed.models.DEFAULT, 'claude-2');
252
- assert.equal(refreshed.cache.source, 'fresh');
253
- assert.notEqual(refreshed.cache.updatedAt, first.cache.updatedAt);
254
- assert.equal(loadCount, 2);
255
- } finally {
256
- await rm(tempRoot, { recursive: true, force: true });
257
- }
258
- });
259
-
260
- test('resolveSessionModel asks the provider adapter for the session it was given', async () => {
261
- const calls: Array<{ provider: LLMProvider; sessionId?: string }> = [];
262
- const service = createProviderModelsService({
263
- sessions: createSessionStore({ 'session-123': null }),
264
- resolveProvider: (provider) => ({
265
- models: {
266
- getSupportedModels: async () => createModels(`${provider}-models`),
267
- getCurrentActiveModel: async (sessionId) => {
268
- calls.push({ provider, sessionId });
269
- return createCurrentActiveModel(`${provider}-${sessionId}`);
270
- },
271
- },
272
- }),
273
- });
274
-
275
- const resolved = await service.resolveSessionModel('opencode', { sessionId: 'session-123' });
276
-
277
- assert.deepEqual(calls, [{ provider: 'opencode', sessionId: 'session-123' }]);
278
- assert.equal(resolved.model, 'opencode-session-123');
279
- });
280
-
281
- const sessions = createSessionStore({ 'session-1': null });
282
- const service = createProviderModelsService({
283
- sessions,
284
- resolveProvider: (provider) => ({
285
- models: {
286
- getSupportedModels: async () => createModels(`${provider}-models`),
287
- getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
288
- },
289
- }),
290
- });
291
-
292
- const stored = service.setSessionModel('claude', 'session-1', 'opus');
293
-
294
- assert.deepEqual(stored, {
295
- provider: 'claude',
296
- sessionId: 'session-1',
297
- model: 'opus',
298
- source: 'session',
299
- });
300
- assert.equal(sessions.sessions.get('session-1'), 'opus');
301
- });
302
-
303
- test('setSessionModel ignores sessions that have no row yet', async () => {
304
- const sessions = createSessionStore();
305
- const service = createProviderModelsService({
306
- sessions,
307
- resolveProvider: (provider) => ({
308
- models: {
309
- getSupportedModels: async () => createModels(`${provider}-models`),
310
- getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
311
- },
312
- }),
313
- });
314
-
315
- assert.equal(service.setSessionModel('claude', 'missing-session', 'opus'), null);
316
- assert.equal(sessions.sessions.size, 0);
317
- });
318
-
319
- test('resolveSessionModel prefers the recorded session model over everything else', async () => {
320
- const service = createProviderModelsService({
321
- sessions: createSessionStore({ 'session-1': 'haiku' }),
322
- resolveProvider: (provider) => ({
323
- models: {
324
- getSupportedModels: async () => createModels(`${provider}-models`),
325
- getCurrentActiveModel: async () => createCurrentActiveModel('provider-reported'),
326
- },
327
- }),
328
- });
329
-
330
- const resolved = await service.resolveSessionModel('claude', {
331
- sessionId: 'session-1',
332
- requestedModel: 'sonnet',
333
- });
334
-
335
- assert.equal(resolved.model, 'haiku');
336
- assert.equal(resolved.source, 'session');
337
- });
338
-
339
- test('resolveSessionModel falls back to provider session state for sessions the app never recorded', async () => {
340
- const service = createProviderModelsService({
341
- sessions: createSessionStore({ 'session-1': null }),
342
- resolveProvider: (provider) => ({
343
- models: {
344
- getSupportedModels: async () => createModels(`${provider}-models`),
345
- getCurrentActiveModel: async () => createCurrentActiveModel('provider-reported'),
346
- },
347
- }),
348
- });
349
-
350
- const resolved = await service.resolveSessionModel('opencode', {
351
- sessionId: 'session-1',
352
- requestedModel: 'requested',
353
- });
354
-
355
- assert.equal(resolved.model, 'provider-reported');
356
- assert.equal(resolved.source, 'provider');
357
- });
358
-
359
- test('resolveSessionModel uses the requested model when the provider only reports its catalog default', async () => {
360
- const service = createProviderModelsService({
361
- cachePath: createEphemeralCachePath(),
362
- sessions: createSessionStore({ 'session-1': null }),
363
- resolveProvider: () => ({
364
- models: {
365
- getSupportedModels: async () => createModels('default'),
366
- getCurrentActiveModel: async () => createCurrentActiveModel('default'),
367
- },
368
- }),
369
- });
370
-
371
- const resolved = await service.resolveSessionModel('claude', {
372
- sessionId: 'session-1',
373
- requestedModel: 'haiku',
374
- });
375
-
376
- assert.equal(resolved.model, 'haiku');
377
- assert.equal(resolved.source, 'session');
378
- });
379
-
380
- test('resolveSessionModel answers with the requested model for a chat that has no session yet', async () => {
381
- const service = createProviderModelsService({
382
- sessions: createSessionStore(),
383
- resolveProvider: (provider) => ({
384
- models: {
385
- getSupportedModels: async () => createModels(`${provider}-models`),
386
- getCurrentActiveModel: async () => createCurrentActiveModel('provider-reported'),
387
- },
388
- }),
389
- });
390
-
391
- const resolved = await service.resolveSessionModel('codex', { requestedModel: 'gpt-5.5' });
392
-
393
- assert.equal(resolved.model, 'gpt-5.5');
394
- assert.equal(resolved.sessionId, null);
395
- assert.equal(resolved.source, 'session');
396
- });
397
-
398
- test('resolveSessionModel falls back to the catalog default with nothing else to go on', async () => {
399
- const service = createProviderModelsService({
400
- cachePath: createEphemeralCachePath(),
401
- sessions: createSessionStore(),
402
- resolveProvider: (provider) => ({
403
- models: {
404
- getSupportedModels: async () => createModels(`${provider}-models`),
405
- getCurrentActiveModel: async () => createCurrentActiveModel('provider-reported'),
406
- },
407
- }),
408
- });
409
-
410
- const resolved = await service.resolveSessionModel('codex');
411
-
412
- assert.equal(resolved.model, 'codex-models');
413
- assert.equal(resolved.source, 'default');
414
- });
415
-
416
- test('resolveResumeModel prefers the recorded session model over the requested one', async () => {
417
- const service = createProviderModelsService({
418
- sessions: createSessionStore({ 'session-456': 'composer-2' }),
419
- resolveProvider: (provider) => ({
420
- models: {
421
- getSupportedModels: async () => createModels(`${provider}-models`),
422
- getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
423
- },
424
- }),
425
- });
426
-
427
- const model = await service.resolveResumeModel('cursor', 'session-456', 'composer-2-fast');
428
- assert.equal(model, 'composer-2');
429
- });
430
-
431
- test('resolveResumeModel never lets provider session state override the requested model', async () => {
432
- let providerLookups = 0;
433
- const service = createProviderModelsService({
434
- sessions: createSessionStore({ 'session-456': null }),
435
- resolveProvider: (provider) => ({
436
- models: {
437
- getSupportedModels: async () => createModels(`${provider}-models`),
438
- getCurrentActiveModel: async () => {
439
- providerLookups += 1;
440
- return createCurrentActiveModel('global-config-model');
441
- },
442
- },
443
- }),
444
- });
445
-
446
- const model = await service.resolveResumeModel('codex', 'session-456', 'gpt-5.5');
447
-
448
- assert.equal(model, 'gpt-5.5');
449
- assert.equal(providerLookups, 0);
450
- });
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import { createProviderModelsService } from '@/modules/providers/services/provider-models.service.js';
5
+ import type {
6
+ CustomProviderModelInput,
7
+ CustomProviderModelRecord,
8
+ LLMProvider,
9
+ ProviderCurrentActiveModel,
10
+ ProviderModelsDefinition,
11
+ } from '@/shared/types.js';
12
+ import { AppError } from '@/shared/utils.js';
13
+
14
+ const createModels = (value: string): ProviderModelsDefinition => ({
15
+ OPTIONS: [{ value, label: value }],
16
+ DEFAULT: value,
17
+ });
18
+
19
+ const createCurrentActiveModel = (model: string): ProviderCurrentActiveModel => ({ model });
20
+
21
+ /** In-memory stand-in for the `sessions` table rows the service reads and writes. */
22
+ const createSessionStore = (
23
+ rows: Record<string, string | null> = {},
24
+ efforts: Record<string, string | null> = {},
25
+ ) => {
26
+ const sessions = new Map(Object.entries(rows).map(([sessionId, model]) => [
27
+ sessionId,
28
+ { model, effort: efforts[sessionId] ?? null },
29
+ ]));
30
+ return {
31
+ sessions,
32
+ getSessionById: (sessionId: string) =>
33
+ sessions.get(sessionId) ?? null,
34
+ setSessionModel: (sessionId: string, model: string) => {
35
+ const session = sessions.get(sessionId);
36
+ if (session) {
37
+ session.model = model;
38
+ }
39
+ },
40
+ setSessionEffort: (sessionId: string, effort: string) => {
41
+ const session = sessions.get(sessionId);
42
+ if (session) {
43
+ session.effort = effort;
44
+ }
45
+ },
46
+ };
47
+ };
48
+
49
+ const createCatalogStore = () => {
50
+ const rows = new Map<LLMProvider, CustomProviderModelRecord[]>();
51
+ let nextRecordId = 1;
52
+ const readRows = (provider: LLMProvider) => rows.get(provider) ?? [];
53
+
54
+ return {
55
+ rows,
56
+ listCustomProviderModels(provider: LLMProvider) {
57
+ return [...readRows(provider)];
58
+ },
59
+ getCustomProviderModel(provider: LLMProvider, recordId: number) {
60
+ return readRows(provider).find((record) => record.recordId === recordId) ?? null;
61
+ },
62
+ findCustomProviderModelByModelId(provider: LLMProvider, modelId: string) {
63
+ return readRows(provider).find((record) => record.modelId === modelId) ?? null;
64
+ },
65
+ createCustomProviderModel(provider: LLMProvider, input: CustomProviderModelInput) {
66
+ const record: CustomProviderModelRecord = {
67
+ recordId: nextRecordId++,
68
+ provider,
69
+ modelId: input.id,
70
+ model: input.model,
71
+ sortOrder: readRows(provider).length,
72
+ };
73
+ rows.set(provider, [...readRows(provider), record]);
74
+ return record;
75
+ },
76
+ updateCustomProviderModel(
77
+ provider: LLMProvider,
78
+ recordId: number,
79
+ input: CustomProviderModelInput,
80
+ ) {
81
+ const existing = readRows(provider).find((record) => record.recordId === recordId);
82
+ if (!existing) {
83
+ return null;
84
+ }
85
+ const updated = { ...existing, modelId: input.id, model: input.model };
86
+ rows.set(provider, readRows(provider).map((record) => (
87
+ record.recordId === recordId ? updated : record
88
+ )));
89
+ return updated;
90
+ },
91
+ deleteCustomProviderModel(provider: LLMProvider, recordId: number, _fallbackModelId: string) {
92
+ const existing = readRows(provider).find((record) => record.recordId === recordId);
93
+ if (!existing) {
94
+ return null;
95
+ }
96
+ rows.set(provider, readRows(provider).filter((record) => record.recordId !== recordId));
97
+ return existing;
98
+ },
99
+ };
100
+ };
101
+
102
+ const createTestService = (options: {
103
+ catalog?: ReturnType<typeof createCatalogStore>;
104
+ sessions?: ReturnType<typeof createSessionStore>;
105
+ activeModel?: (provider: LLMProvider, sessionId?: string) => string;
106
+ onCatalogRead?: (provider: LLMProvider) => void;
107
+ } = {}) => {
108
+ const catalog = options.catalog ?? createCatalogStore();
109
+ const sessions = options.sessions ?? createSessionStore();
110
+ const service = createProviderModelsService({
111
+ catalog,
112
+ sessions,
113
+ resolveProvider: (provider) => ({
114
+ models: {
115
+ getSupportedModels: async () => {
116
+ options.onCatalogRead?.(provider);
117
+ return createModels(`${provider}-default`);
118
+ },
119
+ getCurrentActiveModel: async (sessionId) => createCurrentActiveModel(
120
+ options.activeModel?.(provider, sessionId) ?? `${provider}-default`,
121
+ ),
122
+ },
123
+ }),
124
+ });
125
+
126
+ return { service, catalog, sessions };
127
+ };
128
+
129
+ test('provider catalogs merge source-controlled defaults with custom persistence rows', async () => {
130
+ const calls: LLMProvider[] = [];
131
+ const { service, catalog } = createTestService({ onCatalogRead: (provider) => calls.push(provider) });
132
+
133
+ const models = await service.getProviderModels('codex');
134
+
135
+ assert.deepEqual(calls, ['codex']);
136
+ assert.equal(models.DEFAULT, 'codex-default');
137
+ assert.deepEqual(models.OPTIONS[0], {
138
+ value: 'codex-default',
139
+ label: 'codex-default',
140
+ isCustom: false,
141
+ });
142
+ assert.deepEqual(catalog.rows.get('codex'), undefined);
143
+ });
144
+
145
+ test('custom models can be created, edited, and deleted', async () => {
146
+ const { service } = createTestService();
147
+ const created = await service.createCustomModel('claude', {
148
+ model: 'My Claude',
149
+ id: 'claude-my-model',
150
+ });
151
+ const recordId = created.model.recordId as number;
152
+
153
+ assert.equal(created.model.isCustom, true);
154
+ assert.equal(created.models.OPTIONS.at(-1)?.value, 'claude-my-model');
155
+
156
+ const updated = await service.updateCustomModel('claude', recordId, {
157
+ model: 'My Better Claude',
158
+ id: 'claude-my-model-v2',
159
+ });
160
+ assert.equal(updated.model.label, 'My Better Claude');
161
+ assert.equal(updated.model.value, 'claude-my-model-v2');
162
+
163
+ const removed = await service.deleteCustomModel('claude', recordId);
164
+ assert.equal(removed.model.value, 'claude-my-model-v2');
165
+ assert.equal(removed.models.OPTIONS.some((option) => option.recordId === recordId), false);
166
+ });
167
+
168
+ test('duplicate model ids are rejected within one provider', async () => {
169
+ const { service } = createTestService();
170
+ await service.createCustomModel('cursor', { model: 'First', id: 'custom-id' });
171
+
172
+ await assert.rejects(
173
+ () => service.createCustomModel('cursor', { model: 'Second', id: 'custom-id' }),
174
+ (error) => error instanceof AppError
175
+ && error.code === 'MODEL_ID_ALREADY_EXISTS'
176
+ && error.statusCode === 409,
177
+ );
178
+
179
+ await assert.rejects(
180
+ () => service.createCustomModel('cursor', {
181
+ model: 'Duplicate built-in',
182
+ id: 'cursor-default',
183
+ }),
184
+ (error) => error instanceof AppError
185
+ && error.code === 'MODEL_ID_ALREADY_EXISTS'
186
+ && error.statusCode === 409,
187
+ );
188
+ });
189
+
190
+ test('predefined models have no database record or mutation target', async () => {
191
+ const { service, catalog } = createTestService();
192
+ const models = await service.getProviderModels('opencode');
193
+ assert.equal(models.OPTIONS[0]?.recordId, undefined);
194
+ assert.equal(models.OPTIONS[0]?.isCustom, false);
195
+ assert.deepEqual(catalog.rows.get('opencode'), undefined);
196
+
197
+ await assert.rejects(
198
+ () => service.updateCustomModel('opencode', 999, { model: 'Changed', id: 'changed' }),
199
+ (error) => error instanceof AppError && error.code === 'MODEL_NOT_FOUND',
200
+ );
201
+ });
202
+
203
+ test('resolveSessionModel asks the provider adapter for the requested session', async () => {
204
+ const calls: Array<{ provider: LLMProvider; sessionId?: string }> = [];
205
+ const { service } = createTestService({
206
+ sessions: createSessionStore({ 'session-123': null }),
207
+ activeModel: (provider, sessionId) => {
208
+ calls.push({ provider, sessionId });
209
+ return `${provider}-${sessionId}`;
210
+ },
211
+ });
212
+
213
+ const resolved = await service.resolveSessionModel('opencode', { sessionId: 'session-123' });
214
+
215
+ assert.deepEqual(calls, [{ provider: 'opencode', sessionId: 'session-123' }]);
216
+ assert.equal(resolved.model, 'opencode-session-123');
217
+ });
218
+
219
+ test('setSessionModel records the model on the session row', () => {
220
+ const sessions = createSessionStore({ 'session-1': null });
221
+ const { service } = createTestService({ sessions });
222
+
223
+ const stored = service.setSessionModel('claude', 'session-1', 'opus');
224
+
225
+ assert.deepEqual(stored, {
226
+ provider: 'claude',
227
+ sessionId: 'session-1',
228
+ model: 'opus',
229
+ effort: null,
230
+ source: 'session',
231
+ });
232
+ assert.equal(sessions.sessions.get('session-1')?.model, 'opus');
233
+ });
234
+
235
+ test('setSessionModel ignores sessions that have no row yet', () => {
236
+ const sessions = createSessionStore();
237
+ const { service } = createTestService({ sessions });
238
+
239
+ assert.equal(service.setSessionModel('claude', 'missing-session', 'opus'), null);
240
+ assert.equal(sessions.sessions.size, 0);
241
+ });
242
+
243
+ test('setSessionEffort records an explicit effort on the session row', () => {
244
+ const sessions = createSessionStore({ 'session-1': 'gpt-5.6-sol' });
245
+ const { service } = createTestService({ sessions });
246
+
247
+ const stored = service.setSessionEffort('codex', 'session-1', 'ultra');
248
+
249
+ assert.deepEqual(stored, {
250
+ provider: 'codex',
251
+ sessionId: 'session-1',
252
+ effort: 'ultra',
253
+ source: 'session',
254
+ });
255
+ assert.equal(sessions.sessions.get('session-1')?.effort, 'ultra');
256
+ });
257
+
258
+ test('setSessionEffort ignores sessions that have no row yet', () => {
259
+ const sessions = createSessionStore();
260
+ const { service } = createTestService({ sessions });
261
+
262
+ assert.equal(service.setSessionEffort('codex', 'missing-session', 'high'), null);
263
+ assert.equal(sessions.sessions.size, 0);
264
+ });
265
+
266
+ test('resolveSessionModel prefers the recorded session model', async () => {
267
+ const { service } = createTestService({
268
+ sessions: createSessionStore({ 'session-1': 'haiku' }, { 'session-1': 'high' }),
269
+ activeModel: () => 'provider-reported',
270
+ });
271
+
272
+ const resolved = await service.resolveSessionModel('claude', {
273
+ sessionId: 'session-1',
274
+ requestedModel: 'sonnet',
275
+ });
276
+
277
+ assert.equal(resolved.model, 'haiku');
278
+ assert.equal(resolved.effort, 'high');
279
+ assert.equal(resolved.source, 'session');
280
+ });
281
+
282
+ test('resolveSessionModel uses provider session state for unrecorded external sessions', async () => {
283
+ const { service } = createTestService({
284
+ sessions: createSessionStore({ 'session-1': null }),
285
+ activeModel: () => 'provider-reported',
286
+ });
287
+
288
+ const resolved = await service.resolveSessionModel('opencode', {
289
+ sessionId: 'session-1',
290
+ requestedModel: 'requested',
291
+ });
292
+
293
+ assert.equal(resolved.model, 'provider-reported');
294
+ assert.equal(resolved.source, 'provider');
295
+ });
296
+
297
+ test('resolveSessionModel uses the requested model when provider reports the catalog default', async () => {
298
+ const { service } = createTestService({
299
+ sessions: createSessionStore({ 'session-1': null }),
300
+ });
301
+
302
+ const resolved = await service.resolveSessionModel('claude', {
303
+ sessionId: 'session-1',
304
+ requestedModel: 'haiku',
305
+ });
306
+
307
+ assert.equal(resolved.model, 'haiku');
308
+ assert.equal(resolved.source, 'session');
309
+ });
310
+
311
+ test('resolveSessionModel returns a requested model before a session exists', async () => {
312
+ const { service } = createTestService();
313
+
314
+ const resolved = await service.resolveSessionModel('codex', { requestedModel: 'gpt-5.5' });
315
+
316
+ assert.equal(resolved.model, 'gpt-5.5');
317
+ assert.equal(resolved.sessionId, null);
318
+ assert.equal(resolved.source, 'session');
319
+ });
320
+
321
+ test('resolveSessionModel falls back to the provider adapter default', async () => {
322
+ const { service } = createTestService();
323
+
324
+ const resolved = await service.resolveSessionModel('codex');
325
+
326
+ assert.equal(resolved.model, 'codex-default');
327
+ assert.equal(resolved.source, 'default');
328
+ });
329
+
330
+ test('resolveResumeModel prefers the recorded session model over the requested one', async () => {
331
+ const { service } = createTestService({
332
+ sessions: createSessionStore({ 'session-456': 'composer-2' }),
333
+ });
334
+
335
+ const model = await service.resolveResumeModel('cursor', 'session-456', 'composer-2-fast');
336
+ assert.equal(model, 'composer-2');
337
+ });
338
+
339
+ test('resolveResumeModel never consults provider-global state', async () => {
340
+ let providerLookups = 0;
341
+ const { service } = createTestService({
342
+ sessions: createSessionStore({ 'session-456': null }),
343
+ activeModel: () => {
344
+ providerLookups += 1;
345
+ return 'global-config-model';
346
+ },
347
+ });
348
+
349
+ const model = await service.resolveResumeModel('codex', 'session-456', 'gpt-5.5');
350
+
351
+ assert.equal(model, 'gpt-5.5');
352
+ assert.equal(providerLookups, 0);
353
+ });