@openchambery/web 1.19.19 → 1.19.20

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 (84) hide show
  1. package/dist/assets/{AgentAvatar-BRfqdWCk.js → AgentAvatar-NT957MuI.js} +1 -1
  2. package/dist/assets/{AssistantView-DaQygS2g.js → AssistantView-BjvfkSRS.js} +3 -3
  3. package/dist/assets/{DiagramView-DU13N_4L.js → DiagramView-C_JfbxNl.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-C6tR_fyF.js → FadeInOnReveal-CilRl4gc.js} +1 -1
  5. package/dist/assets/{Icon-B5uh1TBO.js → Icon-i5GiQRLq.js} +1 -1
  6. package/dist/assets/{MarkdownRenderer-CQHRhqB1.js → MarkdownRenderer-Cg82L3fN.js} +2 -2
  7. package/dist/assets/{MarkdownRendererImpl-CfPlhzKB.js → MarkdownRendererImpl-B1iUwzOz.js} +1 -1
  8. package/dist/assets/{MarkstreamRendererImpl-BZu0kqan.js → MarkstreamRendererImpl-BPtMj3-X.js} +1 -1
  9. package/dist/assets/{MobileDetailNavigation-DeLsBXQl.js → MobileDetailNavigation-s_vvUD_F.js} +1 -1
  10. package/dist/assets/{MobileShareBridge-6jsuuuL-.js → MobileShareBridge-CKClHsWN.js} +2 -2
  11. package/dist/assets/{MobileSurface-DCP4BC35.js → MobileSurface-DHpXHbJp.js} +1 -1
  12. package/dist/assets/{MultiRunWindow-EN15uCln.js → MultiRunWindow-DiA24bnN.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-_lWjW-Z6.js → OnboardingScreen-BGRLk4Wi.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-DfKTImP7.js → OverlayScrollbar-NuQI58XD.js} +1 -1
  15. package/dist/assets/{SettingsView-Cm-fwFNM.js → SettingsView-l0J7pfYo.js} +2 -2
  16. package/dist/assets/{SettingsWindow-CSBXDRrg.js → SettingsWindow-XlzRSS7E.js} +1 -1
  17. package/dist/assets/{TerminalView-CWRwefBO.js → TerminalView-DdIaaLba.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-CJFSAiw6.js → ToolOutputDialog-BbwuLdXU.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-BCBPxOn0.js → appThemeRegistry-ChzfD7mc.js} +1 -1
  20. package/dist/assets/{button-B1A-UMjO.js → button-DtzFDvij.js} +1 -1
  21. package/dist/assets/{collapsible-CMDtiFan.js → collapsible-D-nzhcyF.js} +1 -1
  22. package/dist/assets/{context-menu-DhqbwEHq.js → context-menu-BzVSzHLG.js} +1 -1
  23. package/dist/assets/{desktopHosts-B052a4UK.js → desktopHosts-B1ixEylr.js} +1 -1
  24. package/dist/assets/{desktopRelayRestore-OjKNXr10.js → desktopRelayRestore-4JmFUw8r.js} +1 -1
  25. package/dist/assets/{diagram-Cp2-31s3.js → diagram-2YWalSb5.js} +1 -1
  26. package/dist/assets/{dialog-Ck2GVXYI.js → dialog-DGV7DsZC.js} +1 -1
  27. package/dist/assets/{es-0Yrlq6Oy.js → es-rqMiRP7-.js} +1 -1
  28. package/dist/assets/{fileMentionTouchSelection-LxAU-jJG.js → fileMentionTouchSelection-DyjmxUEN.js} +1 -1
  29. package/dist/assets/{fileQueries-B_s_B-fH.js → fileQueries-DQc8EJUf.js} +1 -1
  30. package/dist/assets/{fr-AXS_d4Ac.js → fr-DsBZeN8Y.js} +1 -1
  31. package/dist/assets/{gitApi-BSN7ZfSp.js → gitApi-CHpibtno.js} +1 -1
  32. package/dist/assets/{input-DVaUSQqw.js → input-CbB2HdLg.js} +1 -1
  33. package/dist/assets/{insertionBoundaries-DiTLBzlO.js → insertionBoundaries-Yd1Lorjw.js} +1 -1
  34. package/dist/assets/{ja-iISfz4W8.js → ja-Bgr8-dOg.js} +1 -1
  35. package/dist/assets/{ko-DeN9jk1E.js → ko-B_Gxh-0l.js} +1 -1
  36. package/dist/assets/{main-BbJQ9Vtx.js → main-BoRXxmB3.js} +2 -2
  37. package/dist/assets/main-DCDcdV5o.js +169 -0
  38. package/dist/assets/{markdownHeightCache-BWYOoF-W.js → markdownHeightCache-C9ym9Tsb.js} +1 -1
  39. package/dist/assets/{miniChat-DaZ0MUes.js → miniChat-12fxk5KR.js} +2 -2
  40. package/dist/assets/{mobile-ctmwtKIg.js → mobile-CkZem9Ad.js} +2 -2
  41. package/dist/assets/{multirun-9Z45iiC7.js → multirun-8fkF0Ji_.js} +1 -1
  42. package/dist/assets/{persistence-DwRBC4zl.js → persistence-Ce3AnpEr.js} +2 -2
  43. package/dist/assets/{pl-C32gRuHl.js → pl-DW2XJGC4.js} +1 -1
  44. package/dist/assets/{platform-DQ5_LNKO.js → platform-0w_Q9_ji.js} +1 -1
  45. package/dist/assets/{projectMeta-CIGtyoau.js → projectMeta--y6tyjrv.js} +1 -1
  46. package/dist/assets/{pt-BR-CodLmzOe.js → pt-BR-CIsanf4T.js} +1 -1
  47. package/dist/assets/{renderElectronMiniChatApp-Bpmux7pj.js → renderElectronMiniChatApp-COFc0ZDJ.js} +2 -2
  48. package/dist/assets/{renderMobileApp-DYlwZgHu.js → renderMobileApp-BKzx7JL3.js} +4 -4
  49. package/dist/assets/{runtimeConfig-B0raYq9L.js → runtimeConfig-DYWtBrkW.js} +1 -1
  50. package/dist/assets/{runtimeEndpointReset-D09YtB1G.js → runtimeEndpointReset-BSWg99jc.js} +1 -1
  51. package/dist/assets/{sessionChangeSummary-B2KglTnI.js → sessionChangeSummary-BwupJF-Q.js} +1 -1
  52. package/dist/assets/{sessionLookup-CBlhAA0q.js → sessionLookup-DP2X1qRR.js} +1 -1
  53. package/dist/assets/{sortable-tabs-strip-CSQUAwi0.js → sortable-tabs-strip-CfgEJfB0.js} +1 -1
  54. package/dist/assets/{store-BBmfPqaV.js → store-B6dImdlY.js} +2 -2
  55. package/dist/assets/{terminalApi-CfgOfnZk.js → terminalApi-xjqssKke.js} +1 -1
  56. package/dist/assets/{textarea-C0GHF5Of.js → textarea-BnD799LW.js} +1 -1
  57. package/dist/assets/{tooltip-C5FMIBsd.js → tooltip-Djn7MSOX.js} +1 -1
  58. package/dist/assets/{uk-L_pdbOtS.js → uk-BPmPEUYJ.js} +1 -1
  59. package/dist/assets/{url-CMQwHq4k.js → url-_z7evzdK.js} +1 -1
  60. package/dist/assets/{useAppFontEffects-CDbz3983.js → useAppFontEffects-DWLxpWlS.js} +5 -5
  61. package/dist/assets/{useAssistantUIStore-CLPCNoj_.js → useAssistantUIStore-CMutVh9B.js} +1 -1
  62. package/dist/assets/{useDesktopWindowControlsLayout-DNqYfyg5.js → useDesktopWindowControlsLayout-BGPR_rq6.js} +1 -1
  63. package/dist/assets/{useEffectiveDirectory-B_8RoAcb.js → useEffectiveDirectory-CbV9_r1s.js} +1 -1
  64. package/dist/assets/{useFontPreferences-DCaY8NbU.js → useFontPreferences-B1JYyzpK.js} +1 -1
  65. package/dist/assets/{useMobileNavigationStore-Btsf88wS.js → useMobileNavigationStore-DrB_awgk.js} +4 -4
  66. package/dist/assets/{useSessionAutoCleanup-B7uBJZxU.js → useSessionAutoCleanup-Dnors7hB.js} +1 -1
  67. package/dist/assets/useUIStore-Bxv3f23A.js +1 -0
  68. package/dist/assets/{useWorkerHighlightedLines-CPzF9843.js → useWorkerHighlightedLines-DQqqrOBU.js} +1 -1
  69. package/dist/assets/{zh-CN-CwyG5bxD.js → zh-CN-R8-mDcIp.js} +2 -2
  70. package/dist/assets/{zh-TW-Ba7atZ7i.js → zh-TW-BsOQbrwT.js} +2 -2
  71. package/dist/index.html +8 -8
  72. package/dist/mini-chat.html +8 -8
  73. package/dist/mobile.html +8 -8
  74. package/package.json +1 -1
  75. package/server/lib/opencode/settings-helpers.js +6 -0
  76. package/server/lib/opencode/settings-helpers.test.js +3 -0
  77. package/server/lib/small-model/DOCUMENTATION.md +33 -14
  78. package/server/lib/small-model/custom-api.js +141 -0
  79. package/server/lib/small-model/custom-api.test.js +101 -0
  80. package/server/lib/small-model/index.js +91 -27
  81. package/server/lib/small-model/routes.js +22 -0
  82. package/server/lib/small-model/summary-settings.test.js +85 -1
  83. package/dist/assets/main-DFRHWaDr.js +0 -169
  84. package/dist/assets/useUIStore-CxOoCv7B.js +0 -1
@@ -3,10 +3,13 @@ import os from 'os';
3
3
  import path from 'path';
4
4
  import { readAuthFile } from '../opencode/auth.js';
5
5
  import { readConfigLayers } from '../opencode/shared.js';
6
+ import { createOpencodeClient } from '@opencode-ai/sdk/v2';
7
+ import { loadConnectedCatalog } from '../llm/catalog.js';
6
8
  import { createModelCatalogLoader, getCatalogProvider } from './catalog.js';
7
9
  import { resolveSmallModel, parseModelRef, isUsableAuthEntry, getAuthEntryForProvider } from './resolve.js';
8
10
  import { callSmallModel } from './call.js';
9
11
  import { generateViaOpenCodeSession, stop as stopOpenCodeSessionTemp } from './opencode-session.js';
12
+ import { listCustomSummaryModels, testCustomSummaryApi } from './custom-api.js';
10
13
 
11
14
  const OPENCHAMBER_SETTINGS_FILE = path.join(
12
15
  process.env.OPENCHAMBER_DATA_DIR
@@ -25,13 +28,19 @@ const readSummarySettings = () => {
25
28
  const mode = settings.summaryModelMode === 'custom' ? 'custom' : 'provider';
26
29
  const providerID = typeof settings.summaryProviderID === 'string' ? settings.summaryProviderID.trim() : '';
27
30
  const modelID = typeof settings.summaryModelID === 'string' ? settings.summaryModelID.trim() : '';
31
+ const customModelID = typeof settings.summaryCustomModelID === 'string'
32
+ ? settings.summaryCustomModelID.trim()
33
+ : '';
34
+ const effectiveCustomModelID = customModelID || modelID;
28
35
  const baseURL = typeof settings.summaryCustomBaseURL === 'string' ? settings.summaryCustomBaseURL.trim() : '';
29
36
  const apiToken = typeof settings.summaryCustomAPIToken === 'string' ? settings.summaryCustomAPIToken.trim() : '';
30
37
  return {
31
38
  mode,
32
39
  providerID,
33
40
  modelID,
34
- custom: baseURL && apiToken && modelID ? { baseURL, apiToken, modelID } : null,
41
+ custom: baseURL && apiToken && effectiveCustomModelID
42
+ ? { baseURL, apiToken, modelID: effectiveCustomModelID }
43
+ : null,
35
44
  prompts: {
36
45
  commit: typeof settings.summaryCommitPrompt === 'string' ? settings.summaryCommitPrompt.trim() : '',
37
46
  'session-title': typeof settings.summarySessionTitlePrompt === 'string' ? settings.summarySessionTitlePrompt.trim() : '',
@@ -113,28 +122,39 @@ const shouldUseDirectAdapter = (providerID, auth) => {
113
122
  return isCallableDedicatedAuth(auth, providerID);
114
123
  };
115
124
 
116
- const listCallableProviderIDsForState = (auth, catalog) => {
117
- const ids = new Set(
118
- Object.keys(auth || {}).filter((providerID) => {
119
- // Surface Copilot only as github-copilot (alias handled below).
120
- if (providerID === 'copilot') return false;
121
- if (DEDICATED_DIRECT_PROVIDERS.has(providerID)) {
122
- return isCallableDedicatedAuth(auth, providerID);
123
- }
124
- if (!isUsableAuthEntry(auth[providerID])) return false;
125
- const provider = getCatalogProvider(catalog, providerID);
126
- return providerHasCatalogModels(provider);
127
- }),
128
- );
125
+ const addNonDedicatedCallableProvider = (ids, catalog, providerID) => {
126
+ if (!providerID || providerID === 'copilot' || DEDICATED_DIRECT_PROVIDERS.has(providerID)) return;
127
+ const provider = getCatalogProvider(catalog, providerID);
128
+ if (providerHasCatalogModels(provider)) ids.add(providerID);
129
+ };
130
+
131
+ const listCallableProviderIDsForState = (auth, catalog, connectedIDs) => {
132
+ const ids = new Set();
133
+ for (const providerID of DEDICATED_DIRECT_PROVIDERS) {
134
+ if (isCallableDedicatedAuth(auth, providerID)) ids.add(providerID);
135
+ }
129
136
  if (isUsableAuthEntry(getAuthEntryForProvider(auth, 'github-copilot'))) {
130
137
  ids.add('github-copilot');
131
138
  }
139
+
140
+ if (Array.isArray(connectedIDs)) {
141
+ for (const providerID of connectedIDs) {
142
+ addNonDedicatedCallableProvider(ids, catalog, providerID);
143
+ }
144
+ } else {
145
+ // Connected catalog failed: do not pretend plugin providers are absent.
146
+ for (const providerID of Object.keys(auth || {})) {
147
+ if (providerID === 'copilot') continue;
148
+ if (!isUsableAuthEntry(auth[providerID])) continue;
149
+ addNonDedicatedCallableProvider(ids, catalog, providerID);
150
+ }
151
+ }
132
152
  return Array.from(ids);
133
153
  };
134
154
 
135
- const listCallableModelsForState = (auth, catalog) => {
155
+ const listCallableModelsForState = (auth, catalog, connectedIDs) => {
136
156
  const result = {};
137
- for (const providerID of listCallableProviderIDsForState(auth, catalog)) {
157
+ for (const providerID of listCallableProviderIDsForState(auth, catalog, connectedIDs)) {
138
158
  if (providerID === 'openai' && auth.openai?.type === 'oauth') {
139
159
  result[providerID] = ['gpt-5.4-mini'];
140
160
  continue;
@@ -154,8 +174,8 @@ const listCallableModelsForState = (auth, catalog) => {
154
174
  return result;
155
175
  };
156
176
 
157
- const resolveDefaultSummaryModel = (auth, catalog) => {
158
- const callableModels = listCallableModelsForState(auth, catalog);
177
+ const resolveDefaultSummaryModel = (auth, catalog, connectedIDs) => {
178
+ const callableModels = listCallableModelsForState(auth, catalog, connectedIDs);
159
179
  const providerIDs = Object.keys(callableModels);
160
180
  const providerID = callableModels.openai ? 'openai' : providerIDs[0];
161
181
  const modelID = providerID ? callableModels[providerID]?.[0] : undefined;
@@ -183,6 +203,7 @@ const readConfiguredSmallModel = (workingDirectory) => {
183
203
  * buildOpenCodeUrl: (pathname: string, search?: string) => string,
184
204
  * getOpenCodeAuthHeaders: () => Record<string, string>,
185
205
  * getModelCatalog?: (directory?: string) => Promise<object>,
206
+ * getConnectedCatalog?: (directory?: string) => Promise<{ connected?: string[] }>,
186
207
  * }} dependencies
187
208
  */
188
209
  export function createSmallModelService(dependencies) {
@@ -193,6 +214,41 @@ export function createSmallModelService(dependencies) {
193
214
  buildOpenCodeUrl,
194
215
  getOpenCodeAuthHeaders,
195
216
  }).getModelCatalog;
217
+ const getConnectedCatalog = dependencies.getConnectedCatalog || (async (directory) => {
218
+ const client = createOpencodeClient({
219
+ baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
220
+ ...(directory ? { directory } : {}),
221
+ headers: getOpenCodeAuthHeaders(),
222
+ });
223
+ return loadConnectedCatalog(client);
224
+ });
225
+
226
+ const loadCallableContext = async (directory) => {
227
+ const auth = readAuthFile();
228
+ const catalog = await getModelCatalog(directory);
229
+ let connectedIDs = null;
230
+ try {
231
+ const connectedCatalog = await getConnectedCatalog(directory);
232
+ connectedIDs = Array.isArray(connectedCatalog?.connected) ? connectedCatalog.connected : [];
233
+ } catch {
234
+ connectedIDs = null;
235
+ }
236
+ return { auth, catalog, connectedIDs };
237
+ };
238
+
239
+ const resolveCustomApiInput = (body = {}) => {
240
+ const summarySettings = readSummarySettings();
241
+ const baseURL = typeof body.baseURL === 'string' && body.baseURL.trim()
242
+ ? body.baseURL.trim()
243
+ : (summarySettings?.custom?.baseURL || '');
244
+ const modelID = typeof body.modelID === 'string' && body.modelID.trim()
245
+ ? body.modelID.trim()
246
+ : (summarySettings?.custom?.modelID || '');
247
+ const apiToken = typeof body.apiToken === 'string' && body.apiToken.trim()
248
+ ? body.apiToken.trim()
249
+ : (summarySettings?.custom?.apiToken || '');
250
+ return { baseURL, modelID, apiToken };
251
+ };
196
252
 
197
253
  /**
198
254
  * Generates text with the user's small model, resolved and authenticated
@@ -213,12 +269,11 @@ export function createSmallModelService(dependencies) {
213
269
  throw Object.assign(new Error('prompt is required'), { statusCode: 400 });
214
270
  }
215
271
 
216
- const auth = readAuthFile();
217
- const catalog = await getModelCatalog(directory);
272
+ const { auth, catalog, connectedIDs } = await loadCallableContext(directory);
218
273
  const summarySettings = SUMMARY_PURPOSES.has(purpose) ? readSummarySettings() : null;
219
274
  const summaryPrompt = summarySettings?.prompts?.[purpose];
220
275
  const defaultSummaryModel = SUMMARY_PURPOSES.has(purpose)
221
- ? resolveDefaultSummaryModel(auth, catalog)
276
+ ? resolveDefaultSummaryModel(auth, catalog, connectedIDs)
222
277
  : null;
223
278
 
224
279
  const explicit = parseModelRef(model);
@@ -324,9 +379,8 @@ export function createSmallModelService(dependencies) {
324
379
  */
325
380
  async function listCallableProviders({ directory } = {}) {
326
381
  try {
327
- const auth = readAuthFile();
328
- const catalog = await getModelCatalog(directory);
329
- return listCallableProviderIDsForState(auth, catalog);
382
+ const { auth, catalog, connectedIDs } = await loadCallableContext(directory);
383
+ return listCallableProviderIDsForState(auth, catalog, connectedIDs);
330
384
  } catch {
331
385
  return [];
332
386
  }
@@ -334,14 +388,22 @@ export function createSmallModelService(dependencies) {
334
388
 
335
389
  async function listCallableModels({ directory } = {}) {
336
390
  try {
337
- const auth = readAuthFile();
338
- const catalog = await getModelCatalog(directory);
339
- return listCallableModelsForState(auth, catalog);
391
+ const { auth, catalog, connectedIDs } = await loadCallableContext(directory);
392
+ return listCallableModelsForState(auth, catalog, connectedIDs);
340
393
  } catch {
341
394
  return {};
342
395
  }
343
396
  }
344
397
 
398
+ async function testCustomApi(body) {
399
+ return testCustomSummaryApi(resolveCustomApiInput(body));
400
+ }
401
+
402
+ async function listCustomModels(body) {
403
+ const { baseURL, apiToken } = resolveCustomApiInput(body);
404
+ return listCustomSummaryModels({ baseURL, apiToken });
405
+ }
406
+
345
407
  /**
346
408
  * Reports which model would be used, without calling it.
347
409
  */
@@ -363,6 +425,8 @@ export function createSmallModelService(dependencies) {
363
425
  generateSmallModelText,
364
426
  listCallableProviders,
365
427
  listCallableModels,
428
+ testCustomApi,
429
+ listCustomModels,
366
430
  describeSmallModel,
367
431
  stop: stopOpenCodeSessionTemp,
368
432
  };
@@ -44,4 +44,26 @@ export function registerSmallModelRoutes(app, { getSmallModelService }) {
44
44
  res.status(statusCode).json({ error: error.message || 'Small model generation failed' });
45
45
  }
46
46
  });
47
+
48
+ app.post('/api/small-model/test', async (req, res) => {
49
+ try {
50
+ const { testCustomApi } = await getSmallModelService();
51
+ const result = await testCustomApi(req.body || {});
52
+ res.status(result.ok ? 200 : 400).json(result);
53
+ } catch (error) {
54
+ console.error('Small model custom API test failed:', error);
55
+ res.status(500).json({ ok: false, code: 'baseURL' });
56
+ }
57
+ });
58
+
59
+ app.post('/api/small-model/custom-models', async (req, res) => {
60
+ try {
61
+ const { listCustomModels } = await getSmallModelService();
62
+ const models = await listCustomModels(req.body || {});
63
+ res.json({ models });
64
+ } catch (error) {
65
+ console.error('Small model custom model list failed:', error);
66
+ res.json({ models: [] });
67
+ }
68
+ });
47
69
  }
@@ -29,10 +29,14 @@ const { callSmallModel } = await import('./call.js');
29
29
  const { generateViaOpenCodeSession } = await import('./opencode-session.js');
30
30
  const { readAuthFile } = await import('../opencode/auth.js');
31
31
 
32
- const createService = (catalog = {}) => createSmallModelService({
32
+ const createService = (catalog = {}, connected = null) => createSmallModelService({
33
33
  buildOpenCodeUrl: () => 'http://127.0.0.1:4096/',
34
34
  getOpenCodeAuthHeaders: () => ({}),
35
35
  getModelCatalog: async () => catalog,
36
+ getConnectedCatalog: async () => {
37
+ if (connected === null) throw new Error('connected catalog unavailable');
38
+ return { connected, providers: [], models: [] };
39
+ },
36
40
  });
37
41
 
38
42
  describe('summary AI settings', () => {
@@ -282,4 +286,84 @@ describe('summary AI settings', () => {
282
286
  source: 'summary-provider',
283
287
  });
284
288
  });
289
+
290
+ it('lists connected plugin providers that have no auth.json entry', async () => {
291
+ vi.mocked(readAuthFile).mockReturnValue({});
292
+ const catalog = {
293
+ codebuddy: {
294
+ id: 'codebuddy',
295
+ name: 'CodeBuddy',
296
+ models: { 'codebuddy-flash': { id: 'codebuddy-flash' } },
297
+ },
298
+ };
299
+
300
+ const { listCallableProviders, listCallableModels } = createService(catalog, ['codebuddy']);
301
+ expect(await listCallableProviders()).toEqual(['codebuddy']);
302
+ expect(await listCallableModels()).toEqual({ codebuddy: ['codebuddy-flash'] });
303
+ });
304
+
305
+ it('does not list auth.json plugin providers that are not connected', async () => {
306
+ vi.mocked(readAuthFile).mockReturnValue({
307
+ mistral: { type: 'api', key: 'mistral-key' },
308
+ });
309
+ const catalog = {
310
+ mistral: {
311
+ id: 'mistral',
312
+ name: 'Mistral',
313
+ models: { 'mistral-small': { id: 'mistral-small' } },
314
+ },
315
+ };
316
+
317
+ const { listCallableProviders, listCallableModels } = createService(catalog, []);
318
+ expect(await listCallableProviders()).toEqual([]);
319
+ expect(await listCallableModels()).toEqual({});
320
+ });
321
+
322
+ it('keeps dedicated providers callable from auth.json even when they are not connected', async () => {
323
+ vi.mocked(readAuthFile).mockReturnValue({
324
+ openai: { type: 'oauth', access: 'openai-access', refresh: 'openai-refresh', expires: 0 },
325
+ });
326
+
327
+ const { listCallableProviders, listCallableModels } = createService({}, []);
328
+ expect(await listCallableProviders()).toEqual(['openai']);
329
+ expect(await listCallableModels()).toEqual({ openai: ['gpt-5.4-mini'] });
330
+ });
331
+
332
+ it('requires a custom base URL, model ID, and token before enabling custom mode', async () => {
333
+ await fsPromises.writeFile(path.join(tempRoot, 'settings.json'), JSON.stringify({
334
+ summaryModelMode: 'custom',
335
+ summaryCustomBaseURL: 'https://summary.example.test/v1',
336
+ summaryModelID: 'summary-model',
337
+ }), 'utf8');
338
+
339
+ const { generateSmallModelText } = createService();
340
+ await expect(generateSmallModelText({
341
+ purpose: 'commit',
342
+ prompt: 'Diff content',
343
+ })).rejects.toMatchObject({
344
+ message: 'Custom summary API requires a Base URL, model ID, and API token',
345
+ statusCode: 400,
346
+ });
347
+ });
348
+
349
+ it('prefers summaryCustomModelID over the shared summaryModelID for custom calls', async () => {
350
+ await fsPromises.writeFile(path.join(tempRoot, 'settings.json'), JSON.stringify({
351
+ summaryModelMode: 'custom',
352
+ summaryCustomBaseURL: 'https://summary.example.test/v1',
353
+ summaryModelID: 'provider-model',
354
+ summaryCustomModelID: 'custom-model',
355
+ summaryCustomAPIToken: 'summary-token',
356
+ }), 'utf8');
357
+
358
+ const { generateSmallModelText } = createService();
359
+ await generateSmallModelText({
360
+ purpose: 'commit',
361
+ prompt: 'Diff content',
362
+ });
363
+
364
+ expect(callSmallModel).toHaveBeenCalledWith(expect.objectContaining({
365
+ modelID: 'custom-model',
366
+ custom: expect.objectContaining({ modelID: 'custom-model' }),
367
+ }));
368
+ });
285
369
  });