@cloudcli-ai/cloudcli 1.32.0 → 1.33.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 (180) hide show
  1. package/README.de.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/README.ru.md +1 -1
  5. package/README.tr.md +1 -1
  6. package/README.zh-CN.md +1 -1
  7. package/dist/api-docs.html +6 -7
  8. package/dist/assets/index-BsyL_xmI.js +1348 -0
  9. package/dist/assets/index-FSP9Hz7V.css +32 -0
  10. package/dist/index.html +2 -2
  11. package/dist/modelConstants.js +848 -0
  12. package/dist-server/server/claude-sdk.js +57 -34
  13. package/dist-server/server/claude-sdk.js.map +1 -1
  14. package/dist-server/server/cursor-cli.js +6 -3
  15. package/dist-server/server/cursor-cli.js.map +1 -1
  16. package/dist-server/server/gemini-cli.js +3 -1
  17. package/dist-server/server/gemini-cli.js.map +1 -1
  18. package/dist-server/server/gemini-response-handler.js +34 -0
  19. package/dist-server/server/gemini-response-handler.js.map +1 -1
  20. package/dist-server/server/index.js +234 -67
  21. package/dist-server/server/index.js.map +1 -1
  22. package/dist-server/server/modules/database/index.js +1 -0
  23. package/dist-server/server/modules/database/index.js.map +1 -1
  24. package/dist-server/server/modules/projects/services/project-management.service.js +1 -0
  25. package/dist-server/server/modules/projects/services/project-management.service.js.map +1 -1
  26. package/dist-server/server/modules/projects/services/projects-with-sessions-fetch.service.js +4 -0
  27. package/dist-server/server/modules/projects/services/projects-with-sessions-fetch.service.js.map +1 -1
  28. package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js +28 -6
  29. package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js.map +1 -1
  30. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js +146 -0
  31. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js.map +1 -0
  32. package/dist-server/server/modules/providers/list/claude/claude.provider.js +2 -0
  33. package/dist-server/server/modules/providers/list/claude/claude.provider.js.map +1 -1
  34. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js +84 -0
  35. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js.map +1 -0
  36. package/dist-server/server/modules/providers/list/codex/codex-skills.provider.js +7 -39
  37. package/dist-server/server/modules/providers/list/codex/codex-skills.provider.js.map +1 -1
  38. package/dist-server/server/modules/providers/list/codex/codex.provider.js +2 -0
  39. package/dist-server/server/modules/providers/list/codex/codex.provider.js.map +1 -1
  40. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js +754 -0
  41. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js.map +1 -0
  42. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js +2 -15
  43. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js.map +1 -1
  44. package/dist-server/server/modules/providers/list/cursor/cursor.provider.js +2 -0
  45. package/dist-server/server/modules/providers/list/cursor/cursor.provider.js.map +1 -1
  46. package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js +27 -0
  47. package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js.map +1 -0
  48. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js +3 -9
  49. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js.map +1 -1
  50. package/dist-server/server/modules/providers/list/gemini/gemini.provider.js +2 -0
  51. package/dist-server/server/modules/providers/list/gemini/gemini.provider.js.map +1 -1
  52. package/dist-server/server/modules/providers/list/opencode/opencode-auth.provider.js +92 -0
  53. package/dist-server/server/modules/providers/list/opencode/opencode-auth.provider.js.map +1 -0
  54. package/dist-server/server/modules/providers/list/opencode/opencode-mcp.provider.js +181 -0
  55. package/dist-server/server/modules/providers/list/opencode/opencode-mcp.provider.js.map +1 -0
  56. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js +267 -0
  57. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js.map +1 -0
  58. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js +115 -0
  59. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js.map +1 -0
  60. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js +410 -0
  61. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js.map +1 -0
  62. package/dist-server/server/modules/providers/list/opencode/opencode-skills.provider.js +62 -0
  63. package/dist-server/server/modules/providers/list/opencode/opencode-skills.provider.js.map +1 -0
  64. package/dist-server/server/modules/providers/list/opencode/opencode.provider.js +19 -0
  65. package/dist-server/server/modules/providers/list/opencode/opencode.provider.js.map +1 -0
  66. package/dist-server/server/modules/providers/provider.registry.js +2 -0
  67. package/dist-server/server/modules/providers/provider.registry.js.map +1 -1
  68. package/dist-server/server/modules/providers/provider.routes.js +42 -1
  69. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  70. package/dist-server/server/modules/providers/services/mcp.service.js +1 -9
  71. package/dist-server/server/modules/providers/services/mcp.service.js.map +1 -1
  72. package/dist-server/server/modules/providers/services/provider-models.service.js +226 -0
  73. package/dist-server/server/modules/providers/services/provider-models.service.js.map +1 -0
  74. package/dist-server/server/modules/providers/services/session-synchronizer.service.js +1 -0
  75. package/dist-server/server/modules/providers/services/session-synchronizer.service.js.map +1 -1
  76. package/dist-server/server/modules/providers/services/sessions-watcher.service.js +7 -0
  77. package/dist-server/server/modules/providers/services/sessions-watcher.service.js.map +1 -1
  78. package/dist-server/server/modules/providers/shared/base/abstract.provider.js.map +1 -1
  79. package/dist-server/server/modules/providers/tests/mcp.test.js +73 -6
  80. package/dist-server/server/modules/providers/tests/mcp.test.js.map +1 -1
  81. package/dist-server/server/modules/providers/tests/opencode-models.test.js +66 -0
  82. package/dist-server/server/modules/providers/tests/opencode-models.test.js.map +1 -0
  83. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js +262 -0
  84. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js.map +1 -0
  85. package/dist-server/server/modules/providers/tests/provider-models.service.test.js +298 -0
  86. package/dist-server/server/modules/providers/tests/provider-models.service.test.js.map +1 -0
  87. package/dist-server/server/modules/providers/tests/skills.test.js +33 -0
  88. package/dist-server/server/modules/providers/tests/skills.test.js.map +1 -1
  89. package/dist-server/server/modules/websocket/services/chat-websocket.service.js +12 -1
  90. package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
  91. package/dist-server/server/modules/websocket/services/plugin-websocket-proxy.service.js +4 -4
  92. package/dist-server/server/modules/websocket/services/plugin-websocket-proxy.service.js.map +1 -1
  93. package/dist-server/server/modules/websocket/services/shell-websocket.service.js +18 -3
  94. package/dist-server/server/modules/websocket/services/shell-websocket.service.js.map +1 -1
  95. package/dist-server/server/modules/websocket/services/websocket-auth.service.js +6 -2
  96. package/dist-server/server/modules/websocket/services/websocket-auth.service.js.map +1 -1
  97. package/dist-server/server/modules/websocket/services/websocket-server.service.js +18 -0
  98. package/dist-server/server/modules/websocket/services/websocket-server.service.js.map +1 -1
  99. package/dist-server/server/openai-codex.js +32 -4
  100. package/dist-server/server/openai-codex.js.map +1 -1
  101. package/dist-server/server/opencode-cli.js +287 -0
  102. package/dist-server/server/opencode-cli.js.map +1 -0
  103. package/dist-server/server/opencode-cli.test.js +84 -0
  104. package/dist-server/server/opencode-cli.test.js.map +1 -0
  105. package/dist-server/server/routes/agent.js +21 -8
  106. package/dist-server/server/routes/agent.js.map +1 -1
  107. package/dist-server/server/routes/commands.js +202 -209
  108. package/dist-server/server/routes/commands.js.map +1 -1
  109. package/dist-server/server/routes/cursor.js +2 -2
  110. package/dist-server/server/routes/cursor.js.map +1 -1
  111. package/dist-server/server/routes/settings.js +0 -10
  112. package/dist-server/server/routes/settings.js.map +1 -1
  113. package/dist-server/server/routes/tests/commands.test.js +76 -0
  114. package/dist-server/server/routes/tests/commands.test.js.map +1 -0
  115. package/dist-server/server/shared/utils.js +286 -0
  116. package/dist-server/server/shared/utils.js.map +1 -1
  117. package/package.json +5 -2
  118. package/public/api-docs.html +878 -0
  119. package/public/modelConstants.js +848 -0
  120. package/server/claude-sdk.js +64 -35
  121. package/server/cursor-cli.js +6 -3
  122. package/server/gemini-cli.js +7 -1
  123. package/server/gemini-response-handler.js +38 -0
  124. package/server/index.js +259 -75
  125. package/server/modules/database/index.ts +1 -0
  126. package/server/modules/projects/services/project-management.service.ts +2 -0
  127. package/server/modules/projects/services/projects-with-sessions-fetch.service.ts +7 -1
  128. package/server/modules/providers/README.md +11 -3
  129. package/server/modules/providers/list/claude/claude-auth.provider.ts +34 -6
  130. package/server/modules/providers/list/claude/claude-models.provider.ts +196 -0
  131. package/server/modules/providers/list/claude/claude.provider.ts +3 -0
  132. package/server/modules/providers/list/codex/codex-models.provider.ts +125 -0
  133. package/server/modules/providers/list/codex/codex-skills.provider.ts +10 -50
  134. package/server/modules/providers/list/codex/codex.provider.ts +3 -0
  135. package/server/modules/providers/list/cursor/cursor-models.provider.ts +820 -0
  136. package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +7 -20
  137. package/server/modules/providers/list/cursor/cursor.provider.ts +3 -0
  138. package/server/modules/providers/list/gemini/gemini-models.provider.ts +42 -0
  139. package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +3 -10
  140. package/server/modules/providers/list/gemini/gemini.provider.ts +3 -0
  141. package/server/modules/providers/list/opencode/opencode-auth.provider.ts +111 -0
  142. package/server/modules/providers/list/opencode/opencode-mcp.provider.ts +228 -0
  143. package/server/modules/providers/list/opencode/opencode-models.provider.ts +339 -0
  144. package/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +157 -0
  145. package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +506 -0
  146. package/server/modules/providers/list/opencode/opencode-skills.provider.ts +78 -0
  147. package/server/modules/providers/list/opencode/opencode.provider.ts +27 -0
  148. package/server/modules/providers/provider.registry.ts +2 -0
  149. package/server/modules/providers/provider.routes.ts +62 -2
  150. package/server/modules/providers/services/mcp.service.ts +1 -12
  151. package/server/modules/providers/services/provider-models.service.ts +358 -0
  152. package/server/modules/providers/services/session-synchronizer.service.ts +1 -0
  153. package/server/modules/providers/services/sessions-watcher.service.ts +8 -0
  154. package/server/modules/providers/shared/base/abstract.provider.ts +2 -0
  155. package/server/modules/providers/tests/mcp.test.ts +93 -6
  156. package/server/modules/providers/tests/opencode-models.test.ts +73 -0
  157. package/server/modules/providers/tests/opencode-sessions.test.ts +334 -0
  158. package/server/modules/providers/tests/provider-models.service.test.ts +349 -0
  159. package/server/modules/providers/tests/skills.test.ts +66 -0
  160. package/server/modules/websocket/services/chat-websocket.service.ts +15 -1
  161. package/server/modules/websocket/services/plugin-websocket-proxy.service.ts +4 -4
  162. package/server/modules/websocket/services/shell-websocket.service.ts +22 -2
  163. package/server/modules/websocket/services/websocket-auth.service.ts +7 -2
  164. package/server/modules/websocket/services/websocket-server.service.ts +18 -0
  165. package/server/openai-codex.js +41 -4
  166. package/server/opencode-cli.js +336 -0
  167. package/server/opencode-cli.test.js +95 -0
  168. package/server/routes/agent.js +22 -8
  169. package/server/routes/commands.js +254 -233
  170. package/server/routes/cursor.js +2 -2
  171. package/server/routes/settings.js +1 -10
  172. package/server/routes/tests/commands.test.js +82 -0
  173. package/server/shared/interfaces.ts +45 -0
  174. package/server/shared/types.ts +88 -1
  175. package/server/shared/utils.ts +384 -0
  176. package/dist/assets/index-AaSE7-3t.js +0 -1336
  177. package/dist/assets/index-CKaku72N.css +0 -32
  178. package/dist-server/shared/modelConstants.js +0 -99
  179. package/dist-server/shared/modelConstants.js.map +0 -1
  180. package/shared/modelConstants.js +0 -107
@@ -2,10 +2,17 @@ import express, { type Request, type Response } from 'express';
2
2
 
3
3
  import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
4
4
  import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
5
+ import { providerModelsService } from '@/modules/providers/services/provider-models.service.js';
5
6
  import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
6
7
  import { sessionConversationsSearchService } from '@/modules/providers/services/session-conversations-search.service.js';
7
8
  import { sessionsService } from '@/modules/providers/services/sessions.service.js';
8
- import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js';
9
+ import type {
10
+ LLMProvider,
11
+ McpScope,
12
+ McpTransport,
13
+ ProviderChangeActiveModelInput,
14
+ UpsertProviderMcpServerInput,
15
+ } from '@/shared/types.js';
9
16
  import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
10
17
 
11
18
  const router = express.Router();
@@ -173,7 +180,13 @@ const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput =
173
180
 
174
181
  const parseProvider = (value: unknown): LLMProvider => {
175
182
  const normalized = normalizeProviderParam(value);
176
- if (normalized === 'claude' || normalized === 'codex' || normalized === 'cursor' || normalized === 'gemini') {
183
+ if (
184
+ normalized === 'claude'
185
+ || normalized === 'codex'
186
+ || normalized === 'cursor'
187
+ || normalized === 'gemini'
188
+ || normalized === 'opencode'
189
+ ) {
177
190
  return normalized;
178
191
  }
179
192
 
@@ -239,6 +252,29 @@ const parseSessionSearchLimit = (value: unknown): number => {
239
252
  return Math.max(1, Math.min(parsed, 100));
240
253
  };
241
254
 
255
+ const parseChangeActiveModelPayload = (payload: unknown): ProviderChangeActiveModelInput => {
256
+ if (!payload || typeof payload !== 'object') {
257
+ throw new AppError('Request body must be an object.', {
258
+ code: 'INVALID_REQUEST_BODY',
259
+ statusCode: 400,
260
+ });
261
+ }
262
+
263
+ const body = payload as Record<string, unknown>;
264
+ const model = readOptionalQueryString(body.model);
265
+ if (!model) {
266
+ throw new AppError('model is required.', {
267
+ code: 'MODEL_REQUIRED',
268
+ statusCode: 400,
269
+ });
270
+ }
271
+
272
+ return {
273
+ sessionId: '',
274
+ model,
275
+ };
276
+ };
277
+
242
278
  router.get(
243
279
  '/:provider/auth/status',
244
280
  asyncHandler(async (req: Request, res: Response) => {
@@ -248,6 +284,30 @@ router.get(
248
284
  }),
249
285
  );
250
286
 
287
+ router.get(
288
+ '/:provider/models',
289
+ asyncHandler(async (req: Request, res: Response) => {
290
+ const provider = parseProvider(req.params.provider);
291
+ const bypassCache = parseOptionalBooleanQuery(req.query.bypassCache, 'bypassCache') ?? false;
292
+ const result = await providerModelsService.getProviderModels(provider, { bypassCache });
293
+ res.json(createApiSuccessResponse({ provider, models: result.models, cache: result.cache }));
294
+ }),
295
+ );
296
+
297
+ router.post(
298
+ '/:provider/sessions/:sessionId/active-model',
299
+ asyncHandler(async (req: Request, res: Response) => {
300
+ const provider = parseProvider(req.params.provider);
301
+ const sessionId = parseSessionId(req.params.sessionId);
302
+ const payload = parseChangeActiveModelPayload(req.body);
303
+ const result = await providerModelsService.changeActiveModel(provider, {
304
+ ...payload,
305
+ sessionId,
306
+ });
307
+ res.json(createApiSuccessResponse(result));
308
+ }),
309
+ );
310
+
251
311
  // ----------------- Skills routes -----------------
252
312
  router.get(
253
313
  '/:provider/skills',
@@ -1,18 +1,7 @@
1
- import os from 'node:os';
2
-
3
1
  import { providerRegistry } from '@/modules/providers/provider.registry.js';
4
2
  import type { LLMProvider, McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
5
3
  import { AppError } from '@/shared/utils.js';
6
4
 
7
- /** Cursor MCP is not supported on Windows hosts (no Cursor CLI integration). */
8
- function includeProviderInGlobalMcp(providerId: LLMProvider): boolean {
9
- if (providerId === 'cursor' && os.platform() === 'win32') {
10
- return false;
11
- }
12
-
13
- return true;
14
- }
15
-
16
5
 
17
6
  export const providerMcpService = {
18
7
  /**
@@ -75,7 +64,7 @@ export const providerMcpService = {
75
64
 
76
65
  const scope = input.scope ?? 'project';
77
66
  const results: Array<{ provider: LLMProvider; created: boolean; error?: string }> = [];
78
- const providers = providerRegistry.listProviders().filter((p) => includeProviderInGlobalMcp(p.id));
67
+ const providers = providerRegistry.listProviders();
79
68
  for (const provider of providers) {
80
69
  try {
81
70
  await provider.mcp.upsertServer({ ...input, scope });
@@ -0,0 +1,358 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { providerRegistry } from '@/modules/providers/provider.registry.js';
6
+ import type { IProvider } from '@/shared/interfaces.js';
7
+ import type {
8
+ LLMProvider,
9
+ ProviderChangeActiveModelInput,
10
+ ProviderCurrentActiveModel,
11
+ ProviderModelsCacheInfo,
12
+ ProviderModelsDefinition,
13
+ ProviderModelsResult,
14
+ ProviderSessionActiveModelChange,
15
+ } from '@/shared/types.js';
16
+ import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
17
+
18
+ export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
19
+ const PROVIDER_MODELS_CACHE_VERSION = 1;
20
+ const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude']);
21
+
22
+ type ProviderModelsServiceDependencies = {
23
+ resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
24
+ cachePath?: string;
25
+ activeModelChangesPath?: string;
26
+ now?: () => number;
27
+ };
28
+
29
+ type ProviderModelsOptions = {
30
+ bypassCache?: boolean;
31
+ };
32
+
33
+ type ProviderModelsCacheEntry = {
34
+ updatedAt: number;
35
+ expiresAt: number;
36
+ models: ProviderModelsDefinition;
37
+ };
38
+
39
+ type ProviderModelsCacheFile = {
40
+ version: number;
41
+ entries: Record<string, ProviderModelsCacheEntry>;
42
+ };
43
+
44
+ const getProviderModelsCachePath = (): string => path.join(
45
+ os.homedir(),
46
+ '.cloudcli',
47
+ 'provider-models-cache.json',
48
+ );
49
+
50
+ const toProviderModelsCacheInfo = (
51
+ entry: ProviderModelsCacheEntry,
52
+ source: ProviderModelsCacheInfo['source'],
53
+ ): ProviderModelsCacheInfo => ({
54
+ updatedAt: new Date(entry.updatedAt).toISOString(),
55
+ expiresAt: new Date(entry.expiresAt).toISOString(),
56
+ source,
57
+ });
58
+
59
+ const isProviderModelOption = (
60
+ value: unknown,
61
+ ): value is ProviderModelsDefinition['OPTIONS'][number] => (
62
+ Boolean(value)
63
+ && typeof value === 'object'
64
+ && typeof (value as ProviderModelsDefinition['OPTIONS'][number]).value === 'string'
65
+ && typeof (value as ProviderModelsDefinition['OPTIONS'][number]).label === 'string'
66
+ && (
67
+ typeof (value as ProviderModelsDefinition['OPTIONS'][number]).description === 'undefined'
68
+ || typeof (value as ProviderModelsDefinition['OPTIONS'][number]).description === 'string'
69
+ )
70
+ );
71
+
72
+ const isProviderModelsDefinition = (value: unknown): value is ProviderModelsDefinition => (
73
+ Boolean(value)
74
+ && typeof value === 'object'
75
+ && Array.isArray((value as ProviderModelsDefinition).OPTIONS)
76
+ && (value as ProviderModelsDefinition).OPTIONS.every(isProviderModelOption)
77
+ && typeof (value as ProviderModelsDefinition).DEFAULT === 'string'
78
+ );
79
+
80
+ const isProviderModelsCacheEntry = (value: unknown): value is ProviderModelsCacheEntry => (
81
+ Boolean(value)
82
+ && typeof value === 'object'
83
+ && typeof (value as ProviderModelsCacheEntry).updatedAt === 'number'
84
+ && typeof (value as ProviderModelsCacheEntry).expiresAt === 'number'
85
+ && isProviderModelsDefinition((value as ProviderModelsCacheEntry).models)
86
+ );
87
+
88
+ const readProviderModelsCacheFile = async (
89
+ cachePath: string,
90
+ ): Promise<ProviderModelsCacheFile | null> => {
91
+ try {
92
+ const raw = await readFile(cachePath, 'utf8');
93
+ const parsed = JSON.parse(raw) as Partial<ProviderModelsCacheFile>;
94
+ if (parsed.version !== PROVIDER_MODELS_CACHE_VERSION || !parsed.entries || typeof parsed.entries !== 'object') {
95
+ return null;
96
+ }
97
+
98
+ const entries = Object.fromEntries(
99
+ Object.entries(parsed.entries).filter((entry): entry is [string, ProviderModelsCacheEntry] =>
100
+ isProviderModelsCacheEntry(entry[1]),
101
+ ),
102
+ );
103
+
104
+ return {
105
+ version: PROVIDER_MODELS_CACHE_VERSION,
106
+ entries,
107
+ };
108
+ } catch {
109
+ return null;
110
+ }
111
+ };
112
+
113
+ const writeProviderModelsCacheFile = async (
114
+ cachePath: string,
115
+ entries: Map<LLMProvider, ProviderModelsCacheEntry>,
116
+ now: number,
117
+ ): Promise<void> => {
118
+ const serializableEntries = Object.fromEntries(
119
+ [...entries.entries()].filter(([, entry]) => entry.expiresAt > now),
120
+ );
121
+ const payload: ProviderModelsCacheFile = {
122
+ version: PROVIDER_MODELS_CACHE_VERSION,
123
+ entries: serializableEntries,
124
+ };
125
+
126
+ await mkdir(path.dirname(cachePath), { recursive: true });
127
+ await writeFile(cachePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
128
+ };
129
+
130
+ /**
131
+ * Provider model lookup service.
132
+ *
133
+ * Routes and other service callers use this layer instead of resolving provider
134
+ * classes directly so the provider-registry dependency stays centralized in one
135
+ * place.
136
+ */
137
+ export const createProviderModelsService = (dependencies: ProviderModelsServiceDependencies = {}) => {
138
+ const resolveProvider = dependencies.resolveProvider ?? providerRegistry.resolveProvider;
139
+ const cachePath = dependencies.cachePath ?? getProviderModelsCachePath();
140
+ const activeModelChangesPath = dependencies.activeModelChangesPath;
141
+ const now = dependencies.now ?? (() => Date.now());
142
+ const memoryCache = new Map<LLMProvider, ProviderModelsCacheEntry>();
143
+ const pendingRequests = new Map<LLMProvider, Promise<ProviderModelsResult>>();
144
+ let persistedCacheLoaded = false;
145
+ let persistedCacheLoadPromise: Promise<void> | null = null;
146
+
147
+ const pruneExpiredMemoryEntry = (
148
+ provider: LLMProvider,
149
+ currentTime: number,
150
+ source: ProviderModelsCacheInfo['source'],
151
+ ): ProviderModelsResult | null => {
152
+ const cachedEntry = memoryCache.get(provider);
153
+ if (!cachedEntry) {
154
+ return null;
155
+ }
156
+
157
+ if (cachedEntry.expiresAt > currentTime) {
158
+ return {
159
+ models: cachedEntry.models,
160
+ cache: toProviderModelsCacheInfo(cachedEntry, source),
161
+ };
162
+ }
163
+
164
+ memoryCache.delete(provider);
165
+ return null;
166
+ };
167
+
168
+ const loadPersistedCache = async (): Promise<void> => {
169
+ if (persistedCacheLoaded) {
170
+ return;
171
+ }
172
+
173
+ if (!persistedCacheLoadPromise) {
174
+ persistedCacheLoadPromise = (async () => {
175
+ const cacheFile = await readProviderModelsCacheFile(cachePath);
176
+ const currentTime = now();
177
+
178
+ for (const [provider, entry] of Object.entries(cacheFile?.entries ?? {})) {
179
+ if (entry.expiresAt > currentTime) {
180
+ memoryCache.set(provider as LLMProvider, entry);
181
+ }
182
+ }
183
+
184
+ persistedCacheLoaded = true;
185
+ })().finally(() => {
186
+ persistedCacheLoadPromise = null;
187
+ });
188
+ }
189
+
190
+ await persistedCacheLoadPromise;
191
+ };
192
+
193
+ const persistCache = async (): Promise<void> => {
194
+ try {
195
+ await writeProviderModelsCacheFile(cachePath, memoryCache, now());
196
+ } catch (error) {
197
+ console.warn('Unable to persist provider models cache:', error);
198
+ }
199
+ };
200
+
201
+ const setCacheEntry = async (
202
+ provider: LLMProvider,
203
+ models: ProviderModelsDefinition,
204
+ ): Promise<ProviderModelsCacheEntry> => {
205
+ const currentTime = now();
206
+ const entry: ProviderModelsCacheEntry = {
207
+ updatedAt: currentTime,
208
+ expiresAt: currentTime + PROVIDER_MODELS_CACHE_TTL_MS,
209
+ models,
210
+ };
211
+
212
+ memoryCache.set(provider, entry);
213
+ await persistCache();
214
+ return entry;
215
+ };
216
+
217
+ const loadAndCacheModels = (
218
+ provider: LLMProvider,
219
+ ): Promise<ProviderModelsResult> => {
220
+ const request = resolveProvider(provider).models.getSupportedModels()
221
+ .then(async (models) => {
222
+ const entry = await setCacheEntry(provider, models);
223
+ return {
224
+ models,
225
+ cache: toProviderModelsCacheInfo(entry, 'fresh'),
226
+ };
227
+ })
228
+ .finally(() => {
229
+ pendingRequests.delete(provider);
230
+ });
231
+
232
+ pendingRequests.set(provider, request);
233
+ return request;
234
+ };
235
+
236
+ const loadDirectModels = (
237
+ provider: LLMProvider,
238
+ ): Promise<ProviderModelsResult> => {
239
+ const request = resolveProvider(provider).models.getSupportedModels()
240
+ .then((models) => {
241
+ const currentTime = now();
242
+ return {
243
+ models,
244
+ cache: {
245
+ updatedAt: new Date(currentTime).toISOString(),
246
+ expiresAt: new Date(currentTime).toISOString(),
247
+ source: 'fresh' as const,
248
+ },
249
+ };
250
+ })
251
+ .finally(() => {
252
+ pendingRequests.delete(provider);
253
+ });
254
+
255
+ pendingRequests.set(provider, request);
256
+ return request;
257
+ };
258
+
259
+ const getProviderModels = async (
260
+ provider: LLMProvider,
261
+ options: ProviderModelsOptions = {},
262
+ ): Promise<ProviderModelsResult> => {
263
+ if (UNCACHED_PROVIDERS.has(provider)) {
264
+ const pendingRequest = pendingRequests.get(provider);
265
+ if (pendingRequest) {
266
+ return pendingRequest;
267
+ }
268
+
269
+ return loadDirectModels(provider);
270
+ }
271
+
272
+ if (options.bypassCache) {
273
+ const pendingRequest = pendingRequests.get(provider);
274
+ if (pendingRequest) {
275
+ return pendingRequest;
276
+ }
277
+
278
+ return loadAndCacheModels(provider);
279
+ }
280
+
281
+ const cachedModels = pruneExpiredMemoryEntry(provider, now(), 'memory');
282
+ if (cachedModels) {
283
+ return cachedModels;
284
+ }
285
+
286
+ const pendingRequest = pendingRequests.get(provider);
287
+ if (pendingRequest) {
288
+ return pendingRequest;
289
+ }
290
+
291
+ await loadPersistedCache();
292
+
293
+ const persistedModels = pruneExpiredMemoryEntry(provider, now(), 'disk');
294
+ if (persistedModels) {
295
+ return persistedModels;
296
+ }
297
+
298
+ const postLoadPendingRequest = pendingRequests.get(provider);
299
+ if (postLoadPendingRequest) {
300
+ return postLoadPendingRequest;
301
+ }
302
+
303
+ return loadAndCacheModels(provider);
304
+ };
305
+
306
+ const getCurrentActiveModel = async (
307
+ provider: LLMProvider,
308
+ sessionId?: string,
309
+ ): Promise<ProviderCurrentActiveModel> => resolveProvider(provider).models.getCurrentActiveModel(sessionId);
310
+
311
+ const changeActiveModel = async (
312
+ provider: LLMProvider,
313
+ input: ProviderChangeActiveModelInput,
314
+ ): Promise<ProviderSessionActiveModelChange> => resolveProvider(provider).models.changeActiveModel(input);
315
+
316
+ const getChangedActiveModel = async (
317
+ provider: LLMProvider,
318
+ sessionId: string,
319
+ ): Promise<ProviderSessionActiveModelChange> => readProviderSessionActiveModelChange(provider, sessionId, {
320
+ filePath: activeModelChangesPath,
321
+ });
322
+
323
+ const resolveResumeModel = async (
324
+ provider: LLMProvider,
325
+ sessionId: string | undefined,
326
+ requestedModel?: string | null,
327
+ ): Promise<string | undefined> => {
328
+ const normalizedRequestedModel = typeof requestedModel === 'string' ? requestedModel.trim() : '';
329
+ if (!sessionId?.trim()) {
330
+ return normalizedRequestedModel || undefined;
331
+ }
332
+
333
+ const changedModel = await getChangedActiveModel(provider, sessionId);
334
+ if (changedModel.supported && changedModel.changed && changedModel.model?.trim()) {
335
+ return changedModel.model.trim();
336
+ }
337
+
338
+ return normalizedRequestedModel || undefined;
339
+ };
340
+
341
+ const clearCache = (): void => {
342
+ memoryCache.clear();
343
+ pendingRequests.clear();
344
+ persistedCacheLoaded = false;
345
+ persistedCacheLoadPromise = null;
346
+ };
347
+
348
+ return {
349
+ getProviderModels,
350
+ getCurrentActiveModel,
351
+ getChangedActiveModel,
352
+ changeActiveModel,
353
+ resolveResumeModel,
354
+ clearCache,
355
+ };
356
+ };
357
+
358
+ export const providerModelsService = createProviderModelsService();
@@ -22,6 +22,7 @@ export const sessionSynchronizerService = {
22
22
  codex: 0,
23
23
  cursor: 0,
24
24
  gemini: 0,
25
+ opencode: 0,
25
26
  };
26
27
  const failures: string[] = [];
27
28
 
@@ -34,6 +34,10 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> =
34
34
  provider: 'gemini',
35
35
  rootPath: path.join(os.homedir(), '.gemini', 'tmp'),
36
36
  },
37
+ {
38
+ provider: 'opencode',
39
+ rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'),
40
+ },
37
41
  ];
38
42
 
39
43
  const WATCHER_IGNORED_PATTERNS = [
@@ -67,6 +71,10 @@ let watcherRescheduleAfterRefresh = false;
67
71
  * Filters watcher events to provider-specific session artifact file types.
68
72
  */
69
73
  function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean {
74
+ if (provider === 'opencode') {
75
+ return path.basename(filePath) === 'opencode.db';
76
+ }
77
+
70
78
  if (provider === 'gemini') {
71
79
  return filePath.endsWith('.json') || filePath.endsWith('.jsonl');
72
80
  }
@@ -2,6 +2,7 @@ import type {
2
2
  IProvider,
3
3
  IProviderAuth,
4
4
  IProviderMcp,
5
+ IProviderModels,
5
6
  IProviderSessionSynchronizer,
6
7
  IProviderSkills,
7
8
  IProviderSessions,
@@ -17,6 +18,7 @@ import type { LLMProvider } from '@/shared/types.js';
17
18
  */
18
19
  export abstract class AbstractProvider implements IProvider {
19
20
  readonly id: LLMProvider;
21
+ abstract readonly models: IProviderModels;
20
22
  abstract readonly mcp: IProviderMcp;
21
23
  abstract readonly auth: IProviderAuth;
22
24
  abstract readonly skills: IProviderSkills;
@@ -169,6 +169,93 @@ test('providerMcpService handles codex MCP TOML config and capability validation
169
169
  }
170
170
  });
171
171
 
172
+ /**
173
+ * This test covers OpenCode MCP support for user/project config files, JSONC-compatible
174
+ * reads, and validation for unsupported scope/transport combinations.
175
+ */
176
+ test('providerMcpService handles opencode MCP config and capability validation', { concurrency: false }, async () => {
177
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-opencode-'));
178
+ const workspacePath = path.join(tempRoot, 'workspace');
179
+ await fs.mkdir(workspacePath, { recursive: true });
180
+ await fs.mkdir(path.join(tempRoot, '.config', 'opencode'), { recursive: true });
181
+ await fs.writeFile(
182
+ path.join(tempRoot, '.config', 'opencode', 'opencode.jsonc'),
183
+ `{
184
+ // Existing comments should not block OpenCode MCP reads.
185
+ "mcp": {}
186
+ }\n`,
187
+ 'utf8',
188
+ );
189
+
190
+ const restoreHomeDir = patchHomeDir(tempRoot);
191
+ try {
192
+ await providerMcpService.upsertProviderMcpServer('opencode', {
193
+ name: 'opencode-user-stdio',
194
+ scope: 'user',
195
+ transport: 'stdio',
196
+ command: 'node',
197
+ args: ['server.js'],
198
+ env: { API_KEY: 'x' },
199
+ });
200
+
201
+ await providerMcpService.upsertProviderMcpServer('opencode', {
202
+ name: 'opencode-project-http',
203
+ scope: 'project',
204
+ transport: 'http',
205
+ url: 'https://opencode.example.com/mcp',
206
+ headers: { Authorization: 'Bearer token' },
207
+ workspacePath,
208
+ });
209
+
210
+ const userConfig = await readJson(path.join(tempRoot, '.config', 'opencode', 'opencode.jsonc'));
211
+ const userServers = userConfig.mcp as Record<string, unknown>;
212
+ const userStdio = userServers['opencode-user-stdio'] as Record<string, unknown>;
213
+ assert.equal(userStdio.type, 'local');
214
+ assert.deepEqual(userStdio.command, ['node', 'server.js']);
215
+ assert.deepEqual(userStdio.environment, { API_KEY: 'x' });
216
+
217
+ const projectConfig = await readJson(path.join(workspacePath, 'opencode.json'));
218
+ const projectServers = projectConfig.mcp as Record<string, unknown>;
219
+ const projectHttp = projectServers['opencode-project-http'] as Record<string, unknown>;
220
+ assert.equal(projectHttp.type, 'remote');
221
+ assert.equal(projectHttp.url, 'https://opencode.example.com/mcp');
222
+
223
+ const grouped = await providerMcpService.listProviderMcpServers('opencode', { workspacePath });
224
+ assert.ok(grouped.user.some((server) => server.name === 'opencode-user-stdio' && server.transport === 'stdio'));
225
+ assert.ok(grouped.project.some((server) => server.name === 'opencode-project-http' && server.transport === 'http'));
226
+
227
+ await assert.rejects(
228
+ providerMcpService.upsertProviderMcpServer('opencode', {
229
+ name: 'opencode-local',
230
+ scope: 'local',
231
+ transport: 'stdio',
232
+ command: 'node',
233
+ }),
234
+ (error: unknown) =>
235
+ error instanceof AppError &&
236
+ error.code === 'MCP_SCOPE_NOT_SUPPORTED' &&
237
+ error.statusCode === 400,
238
+ );
239
+
240
+ await assert.rejects(
241
+ providerMcpService.upsertProviderMcpServer('opencode', {
242
+ name: 'opencode-sse',
243
+ scope: 'project',
244
+ transport: 'sse',
245
+ url: 'https://example.com/sse',
246
+ workspacePath,
247
+ }),
248
+ (error: unknown) =>
249
+ error instanceof AppError &&
250
+ error.code === 'MCP_TRANSPORT_NOT_SUPPORTED' &&
251
+ error.statusCode === 400,
252
+ );
253
+ } finally {
254
+ restoreHomeDir();
255
+ await fs.rm(tempRoot, { recursive: true, force: true });
256
+ }
257
+ });
258
+
172
259
  /**
173
260
  * This test covers Gemini/Cursor MCP JSON formats and user/project scope persistence.
174
261
  */
@@ -254,8 +341,7 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
254
341
  workspacePath,
255
342
  });
256
343
 
257
- const expectCursorGlobal = process.platform !== 'win32';
258
- assert.equal(globalResult.length, expectCursorGlobal ? 4 : 3);
344
+ assert.equal(globalResult.length, 5);
259
345
  assert.ok(globalResult.every((entry) => entry.created === true));
260
346
 
261
347
  const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
@@ -267,10 +353,11 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
267
353
  const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
268
354
  assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']);
269
355
 
270
- if (expectCursorGlobal) {
271
- const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
272
- assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
273
- }
356
+ const opencodeProject = await readJson(path.join(workspacePath, 'opencode.json'));
357
+ assert.ok((opencodeProject.mcp as Record<string, unknown>)['global-http']);
358
+
359
+ const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
360
+ assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
274
361
 
275
362
  await assert.rejects(
276
363
  providerMcpService.addMcpServerToAllProviders({