@glwhappen/web-code 1.32.9 → 1.32.11

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 (167) 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-D_7CSvqO.js → index-CBo8yakG.js} +276 -261
  9. package/dist/assets/index-Dl5QP21C.css +32 -0
  10. package/dist/index.html +2 -2
  11. package/dist/modelConstants.js +841 -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 +131 -19
  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-models.provider.js +143 -0
  29. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js.map +1 -0
  30. package/dist-server/server/modules/providers/list/claude/claude.provider.js +2 -0
  31. package/dist-server/server/modules/providers/list/claude/claude.provider.js.map +1 -1
  32. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js +84 -0
  33. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js.map +1 -0
  34. package/dist-server/server/modules/providers/list/codex/codex-skills.provider.js +7 -39
  35. package/dist-server/server/modules/providers/list/codex/codex-skills.provider.js.map +1 -1
  36. package/dist-server/server/modules/providers/list/codex/codex.provider.js +2 -0
  37. package/dist-server/server/modules/providers/list/codex/codex.provider.js.map +1 -1
  38. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js +754 -0
  39. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js.map +1 -0
  40. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js +2 -15
  41. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js.map +1 -1
  42. package/dist-server/server/modules/providers/list/cursor/cursor.provider.js +2 -0
  43. package/dist-server/server/modules/providers/list/cursor/cursor.provider.js.map +1 -1
  44. package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js +27 -0
  45. package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js.map +1 -0
  46. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js +3 -9
  47. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js.map +1 -1
  48. package/dist-server/server/modules/providers/list/gemini/gemini.provider.js +2 -0
  49. package/dist-server/server/modules/providers/list/gemini/gemini.provider.js.map +1 -1
  50. package/dist-server/server/modules/providers/list/opencode/opencode-auth.provider.js +92 -0
  51. package/dist-server/server/modules/providers/list/opencode/opencode-auth.provider.js.map +1 -0
  52. package/dist-server/server/modules/providers/list/opencode/opencode-mcp.provider.js +181 -0
  53. package/dist-server/server/modules/providers/list/opencode/opencode-mcp.provider.js.map +1 -0
  54. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js +267 -0
  55. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js.map +1 -0
  56. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js +115 -0
  57. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js.map +1 -0
  58. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js +410 -0
  59. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js.map +1 -0
  60. package/dist-server/server/modules/providers/list/opencode/opencode-skills.provider.js +62 -0
  61. package/dist-server/server/modules/providers/list/opencode/opencode-skills.provider.js.map +1 -0
  62. package/dist-server/server/modules/providers/list/opencode/opencode.provider.js +19 -0
  63. package/dist-server/server/modules/providers/list/opencode/opencode.provider.js.map +1 -0
  64. package/dist-server/server/modules/providers/provider.registry.js +2 -0
  65. package/dist-server/server/modules/providers/provider.registry.js.map +1 -1
  66. package/dist-server/server/modules/providers/provider.routes.js +42 -1
  67. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  68. package/dist-server/server/modules/providers/services/mcp.service.js +1 -9
  69. package/dist-server/server/modules/providers/services/mcp.service.js.map +1 -1
  70. package/dist-server/server/modules/providers/services/provider-models.service.js +199 -0
  71. package/dist-server/server/modules/providers/services/provider-models.service.js.map +1 -0
  72. package/dist-server/server/modules/providers/services/session-synchronizer.service.js +1 -0
  73. package/dist-server/server/modules/providers/services/session-synchronizer.service.js.map +1 -1
  74. package/dist-server/server/modules/providers/services/sessions-watcher.service.js +7 -0
  75. package/dist-server/server/modules/providers/services/sessions-watcher.service.js.map +1 -1
  76. package/dist-server/server/modules/providers/shared/base/abstract.provider.js.map +1 -1
  77. package/dist-server/server/modules/providers/tests/mcp.test.js +73 -6
  78. package/dist-server/server/modules/providers/tests/mcp.test.js.map +1 -1
  79. package/dist-server/server/modules/providers/tests/opencode-models.test.js +66 -0
  80. package/dist-server/server/modules/providers/tests/opencode-models.test.js.map +1 -0
  81. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js +264 -0
  82. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js.map +1 -0
  83. package/dist-server/server/modules/providers/tests/provider-models.service.test.js +270 -0
  84. package/dist-server/server/modules/providers/tests/provider-models.service.test.js.map +1 -0
  85. package/dist-server/server/modules/providers/tests/skills.test.js +33 -0
  86. package/dist-server/server/modules/providers/tests/skills.test.js.map +1 -1
  87. package/dist-server/server/modules/websocket/services/chat-websocket.service.js +18 -1
  88. package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
  89. package/dist-server/server/modules/websocket/services/shell-websocket.service.js +9 -1
  90. package/dist-server/server/modules/websocket/services/shell-websocket.service.js.map +1 -1
  91. package/dist-server/server/openai-codex.js +32 -4
  92. package/dist-server/server/openai-codex.js.map +1 -1
  93. package/dist-server/server/opencode-cli.js +287 -0
  94. package/dist-server/server/opencode-cli.js.map +1 -0
  95. package/dist-server/server/opencode-cli.test.js +84 -0
  96. package/dist-server/server/opencode-cli.test.js.map +1 -0
  97. package/dist-server/server/routes/agent.js +21 -8
  98. package/dist-server/server/routes/agent.js.map +1 -1
  99. package/dist-server/server/routes/commands.js +202 -209
  100. package/dist-server/server/routes/commands.js.map +1 -1
  101. package/dist-server/server/routes/cursor.js +2 -2
  102. package/dist-server/server/routes/cursor.js.map +1 -1
  103. package/dist-server/server/routes/settings.js +0 -10
  104. package/dist-server/server/routes/settings.js.map +1 -1
  105. package/dist-server/server/routes/tests/commands.test.js +76 -0
  106. package/dist-server/server/routes/tests/commands.test.js.map +1 -0
  107. package/dist-server/server/shared/utils.js +286 -0
  108. package/dist-server/server/shared/utils.js.map +1 -1
  109. package/package.json +3 -1
  110. package/public/api-docs.html +878 -0
  111. package/public/modelConstants.js +841 -0
  112. package/server/claude-sdk.js +64 -35
  113. package/server/cursor-cli.js +6 -3
  114. package/server/gemini-cli.js +7 -1
  115. package/server/gemini-response-handler.js +38 -0
  116. package/server/index.js +150 -19
  117. package/server/modules/database/index.ts +1 -0
  118. package/server/modules/projects/services/project-management.service.ts +2 -0
  119. package/server/modules/projects/services/projects-with-sessions-fetch.service.ts +7 -1
  120. package/server/modules/providers/README.md +11 -3
  121. package/server/modules/providers/list/claude/claude-models.provider.ts +193 -0
  122. package/server/modules/providers/list/claude/claude.provider.ts +3 -0
  123. package/server/modules/providers/list/codex/codex-models.provider.ts +125 -0
  124. package/server/modules/providers/list/codex/codex-skills.provider.ts +10 -50
  125. package/server/modules/providers/list/codex/codex.provider.ts +3 -0
  126. package/server/modules/providers/list/cursor/cursor-models.provider.ts +820 -0
  127. package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +7 -20
  128. package/server/modules/providers/list/cursor/cursor.provider.ts +3 -0
  129. package/server/modules/providers/list/gemini/gemini-models.provider.ts +42 -0
  130. package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +3 -10
  131. package/server/modules/providers/list/gemini/gemini.provider.ts +3 -0
  132. package/server/modules/providers/list/opencode/opencode-auth.provider.ts +111 -0
  133. package/server/modules/providers/list/opencode/opencode-mcp.provider.ts +228 -0
  134. package/server/modules/providers/list/opencode/opencode-models.provider.ts +339 -0
  135. package/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +158 -0
  136. package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +506 -0
  137. package/server/modules/providers/list/opencode/opencode-skills.provider.ts +78 -0
  138. package/server/modules/providers/list/opencode/opencode.provider.ts +27 -0
  139. package/server/modules/providers/provider.registry.ts +2 -0
  140. package/server/modules/providers/provider.routes.ts +62 -2
  141. package/server/modules/providers/services/mcp.service.ts +1 -12
  142. package/server/modules/providers/services/provider-models.service.ts +325 -0
  143. package/server/modules/providers/services/session-synchronizer.service.ts +1 -0
  144. package/server/modules/providers/services/sessions-watcher.service.ts +8 -0
  145. package/server/modules/providers/shared/base/abstract.provider.ts +2 -0
  146. package/server/modules/providers/tests/mcp.test.ts +93 -6
  147. package/server/modules/providers/tests/opencode-models.test.ts +73 -0
  148. package/server/modules/providers/tests/opencode-sessions.test.ts +336 -0
  149. package/server/modules/providers/tests/provider-models.service.test.ts +318 -0
  150. package/server/modules/providers/tests/skills.test.ts +66 -0
  151. package/server/modules/websocket/services/chat-websocket.service.ts +21 -1
  152. package/server/modules/websocket/services/shell-websocket.service.ts +9 -0
  153. package/server/openai-codex.js +40 -4
  154. package/server/opencode-cli.js +336 -0
  155. package/server/opencode-cli.test.js +95 -0
  156. package/server/routes/agent.js +22 -8
  157. package/server/routes/commands.js +254 -233
  158. package/server/routes/cursor.js +2 -2
  159. package/server/routes/settings.js +1 -10
  160. package/server/routes/tests/commands.test.js +82 -0
  161. package/server/shared/interfaces.ts +45 -0
  162. package/server/shared/types.ts +88 -1
  163. package/server/shared/utils.ts +384 -0
  164. package/dist/assets/index-DdxLnCfK.css +0 -32
  165. package/dist-server/shared/modelConstants.js +0 -99
  166. package/dist-server/shared/modelConstants.js.map +0 -1
  167. package/shared/modelConstants.js +0 -107
@@ -3,10 +3,17 @@ import express, { type Request, type Response } from 'express';
3
3
  import { queuedMessagesDb } from '@/modules/database/index.js';
4
4
  import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
5
5
  import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
6
+ import { providerModelsService } from '@/modules/providers/services/provider-models.service.js';
6
7
  import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
7
8
  import { sessionConversationsSearchService } from '@/modules/providers/services/session-conversations-search.service.js';
8
9
  import { sessionsService } from '@/modules/providers/services/sessions.service.js';
9
- import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js';
10
+ import type {
11
+ LLMProvider,
12
+ McpScope,
13
+ McpTransport,
14
+ ProviderChangeActiveModelInput,
15
+ UpsertProviderMcpServerInput,
16
+ } from '@/shared/types.js';
10
17
  import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
11
18
 
12
19
  const router = express.Router();
@@ -211,7 +218,13 @@ const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput =
211
218
 
212
219
  const parseProvider = (value: unknown): LLMProvider => {
213
220
  const normalized = normalizeProviderParam(value);
214
- if (normalized === 'claude' || normalized === 'codex' || normalized === 'cursor' || normalized === 'gemini') {
221
+ if (
222
+ normalized === 'claude'
223
+ || normalized === 'codex'
224
+ || normalized === 'cursor'
225
+ || normalized === 'gemini'
226
+ || normalized === 'opencode'
227
+ ) {
215
228
  return normalized;
216
229
  }
217
230
 
@@ -337,6 +350,29 @@ const parseSessionSearchLimit = (value: unknown): number => {
337
350
  return Math.max(1, Math.min(parsed, 100));
338
351
  };
339
352
 
353
+ const parseChangeActiveModelPayload = (payload: unknown): ProviderChangeActiveModelInput => {
354
+ if (!payload || typeof payload !== 'object') {
355
+ throw new AppError('Request body must be an object.', {
356
+ code: 'INVALID_REQUEST_BODY',
357
+ statusCode: 400,
358
+ });
359
+ }
360
+
361
+ const body = payload as Record<string, unknown>;
362
+ const model = readOptionalQueryString(body.model);
363
+ if (!model) {
364
+ throw new AppError('model is required.', {
365
+ code: 'MODEL_REQUIRED',
366
+ statusCode: 400,
367
+ });
368
+ }
369
+
370
+ return {
371
+ sessionId: '',
372
+ model,
373
+ };
374
+ };
375
+
340
376
  router.get(
341
377
  '/:provider/auth/status',
342
378
  asyncHandler(async (req: Request, res: Response) => {
@@ -346,6 +382,30 @@ router.get(
346
382
  }),
347
383
  );
348
384
 
385
+ router.get(
386
+ '/:provider/models',
387
+ asyncHandler(async (req: Request, res: Response) => {
388
+ const provider = parseProvider(req.params.provider);
389
+ const bypassCache = parseOptionalBooleanQuery(req.query.bypassCache, 'bypassCache') ?? false;
390
+ const result = await providerModelsService.getProviderModels(provider, { bypassCache });
391
+ res.json(createApiSuccessResponse({ provider, models: result.models, cache: result.cache }));
392
+ }),
393
+ );
394
+
395
+ router.post(
396
+ '/:provider/sessions/:sessionId/active-model',
397
+ asyncHandler(async (req: Request, res: Response) => {
398
+ const provider = parseProvider(req.params.provider);
399
+ const sessionId = parseSessionId(req.params.sessionId);
400
+ const payload = parseChangeActiveModelPayload(req.body);
401
+ const result = await providerModelsService.changeActiveModel(provider, {
402
+ ...payload,
403
+ sessionId,
404
+ });
405
+ res.json(createApiSuccessResponse(result));
406
+ }),
407
+ );
408
+
349
409
  // ----------------- Skills routes -----------------
350
410
  router.get(
351
411
  '/: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,325 @@
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
+
21
+ type ProviderModelsServiceDependencies = {
22
+ resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
23
+ cachePath?: string;
24
+ activeModelChangesPath?: string;
25
+ now?: () => number;
26
+ };
27
+
28
+ type ProviderModelsOptions = {
29
+ bypassCache?: boolean;
30
+ };
31
+
32
+ type ProviderModelsCacheEntry = {
33
+ updatedAt: number;
34
+ expiresAt: number;
35
+ models: ProviderModelsDefinition;
36
+ };
37
+
38
+ type ProviderModelsCacheFile = {
39
+ version: number;
40
+ entries: Record<string, ProviderModelsCacheEntry>;
41
+ };
42
+
43
+ const getProviderModelsCachePath = (): string => path.join(
44
+ os.homedir(),
45
+ '.cloudcli',
46
+ 'provider-models-cache.json',
47
+ );
48
+
49
+ const toProviderModelsCacheInfo = (
50
+ entry: ProviderModelsCacheEntry,
51
+ source: ProviderModelsCacheInfo['source'],
52
+ ): ProviderModelsCacheInfo => ({
53
+ updatedAt: new Date(entry.updatedAt).toISOString(),
54
+ expiresAt: new Date(entry.expiresAt).toISOString(),
55
+ source,
56
+ });
57
+
58
+ const isProviderModelOption = (
59
+ value: unknown,
60
+ ): value is ProviderModelsDefinition['OPTIONS'][number] => (
61
+ Boolean(value)
62
+ && typeof value === 'object'
63
+ && typeof (value as ProviderModelsDefinition['OPTIONS'][number]).value === 'string'
64
+ && typeof (value as ProviderModelsDefinition['OPTIONS'][number]).label === 'string'
65
+ && (
66
+ typeof (value as ProviderModelsDefinition['OPTIONS'][number]).description === 'undefined'
67
+ || typeof (value as ProviderModelsDefinition['OPTIONS'][number]).description === 'string'
68
+ )
69
+ );
70
+
71
+ const isProviderModelsDefinition = (value: unknown): value is ProviderModelsDefinition => (
72
+ Boolean(value)
73
+ && typeof value === 'object'
74
+ && Array.isArray((value as ProviderModelsDefinition).OPTIONS)
75
+ && (value as ProviderModelsDefinition).OPTIONS.every(isProviderModelOption)
76
+ && typeof (value as ProviderModelsDefinition).DEFAULT === 'string'
77
+ );
78
+
79
+ const isProviderModelsCacheEntry = (value: unknown): value is ProviderModelsCacheEntry => (
80
+ Boolean(value)
81
+ && typeof value === 'object'
82
+ && typeof (value as ProviderModelsCacheEntry).updatedAt === 'number'
83
+ && typeof (value as ProviderModelsCacheEntry).expiresAt === 'number'
84
+ && isProviderModelsDefinition((value as ProviderModelsCacheEntry).models)
85
+ );
86
+
87
+ const readProviderModelsCacheFile = async (
88
+ cachePath: string,
89
+ ): Promise<ProviderModelsCacheFile | null> => {
90
+ try {
91
+ const raw = await readFile(cachePath, 'utf8');
92
+ const parsed = JSON.parse(raw) as Partial<ProviderModelsCacheFile>;
93
+ if (parsed.version !== PROVIDER_MODELS_CACHE_VERSION || !parsed.entries || typeof parsed.entries !== 'object') {
94
+ return null;
95
+ }
96
+
97
+ const entries = Object.fromEntries(
98
+ Object.entries(parsed.entries).filter((entry): entry is [string, ProviderModelsCacheEntry] =>
99
+ isProviderModelsCacheEntry(entry[1]),
100
+ ),
101
+ );
102
+
103
+ return {
104
+ version: PROVIDER_MODELS_CACHE_VERSION,
105
+ entries,
106
+ };
107
+ } catch {
108
+ return null;
109
+ }
110
+ };
111
+
112
+ const writeProviderModelsCacheFile = async (
113
+ cachePath: string,
114
+ entries: Map<LLMProvider, ProviderModelsCacheEntry>,
115
+ now: number,
116
+ ): Promise<void> => {
117
+ const serializableEntries = Object.fromEntries(
118
+ [...entries.entries()].filter(([, entry]) => entry.expiresAt > now),
119
+ );
120
+ const payload: ProviderModelsCacheFile = {
121
+ version: PROVIDER_MODELS_CACHE_VERSION,
122
+ entries: serializableEntries,
123
+ };
124
+
125
+ await mkdir(path.dirname(cachePath), { recursive: true });
126
+ await writeFile(cachePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
127
+ };
128
+
129
+ /**
130
+ * Provider model lookup service.
131
+ *
132
+ * Routes and other service callers use this layer instead of resolving provider
133
+ * classes directly so the provider-registry dependency stays centralized in one
134
+ * place.
135
+ */
136
+ export const createProviderModelsService = (dependencies: ProviderModelsServiceDependencies = {}) => {
137
+ const resolveProvider = dependencies.resolveProvider ?? providerRegistry.resolveProvider;
138
+ const cachePath = dependencies.cachePath ?? getProviderModelsCachePath();
139
+ const activeModelChangesPath = dependencies.activeModelChangesPath;
140
+ const now = dependencies.now ?? (() => Date.now());
141
+ const memoryCache = new Map<LLMProvider, ProviderModelsCacheEntry>();
142
+ const pendingRequests = new Map<LLMProvider, Promise<ProviderModelsResult>>();
143
+ let persistedCacheLoaded = false;
144
+ let persistedCacheLoadPromise: Promise<void> | null = null;
145
+
146
+ const pruneExpiredMemoryEntry = (
147
+ provider: LLMProvider,
148
+ currentTime: number,
149
+ source: ProviderModelsCacheInfo['source'],
150
+ ): ProviderModelsResult | null => {
151
+ const cachedEntry = memoryCache.get(provider);
152
+ if (!cachedEntry) {
153
+ return null;
154
+ }
155
+
156
+ if (cachedEntry.expiresAt > currentTime) {
157
+ return {
158
+ models: cachedEntry.models,
159
+ cache: toProviderModelsCacheInfo(cachedEntry, source),
160
+ };
161
+ }
162
+
163
+ memoryCache.delete(provider);
164
+ return null;
165
+ };
166
+
167
+ const loadPersistedCache = async (): Promise<void> => {
168
+ if (persistedCacheLoaded) {
169
+ return;
170
+ }
171
+
172
+ if (!persistedCacheLoadPromise) {
173
+ persistedCacheLoadPromise = (async () => {
174
+ const cacheFile = await readProviderModelsCacheFile(cachePath);
175
+ const currentTime = now();
176
+
177
+ for (const [provider, entry] of Object.entries(cacheFile?.entries ?? {})) {
178
+ if (entry.expiresAt > currentTime) {
179
+ memoryCache.set(provider as LLMProvider, entry);
180
+ }
181
+ }
182
+
183
+ persistedCacheLoaded = true;
184
+ })().finally(() => {
185
+ persistedCacheLoadPromise = null;
186
+ });
187
+ }
188
+
189
+ await persistedCacheLoadPromise;
190
+ };
191
+
192
+ const persistCache = async (): Promise<void> => {
193
+ try {
194
+ await writeProviderModelsCacheFile(cachePath, memoryCache, now());
195
+ } catch (error) {
196
+ console.warn('Unable to persist provider models cache:', error);
197
+ }
198
+ };
199
+
200
+ const setCacheEntry = async (
201
+ provider: LLMProvider,
202
+ models: ProviderModelsDefinition,
203
+ ): Promise<ProviderModelsCacheEntry> => {
204
+ const currentTime = now();
205
+ const entry: ProviderModelsCacheEntry = {
206
+ updatedAt: currentTime,
207
+ expiresAt: currentTime + PROVIDER_MODELS_CACHE_TTL_MS,
208
+ models,
209
+ };
210
+
211
+ memoryCache.set(provider, entry);
212
+ await persistCache();
213
+ return entry;
214
+ };
215
+
216
+ const loadAndCacheModels = (
217
+ provider: LLMProvider,
218
+ ): Promise<ProviderModelsResult> => {
219
+ const request = resolveProvider(provider).models.getSupportedModels()
220
+ .then(async (models) => {
221
+ const entry = await setCacheEntry(provider, models);
222
+ return {
223
+ models,
224
+ cache: toProviderModelsCacheInfo(entry, 'fresh'),
225
+ };
226
+ })
227
+ .finally(() => {
228
+ pendingRequests.delete(provider);
229
+ });
230
+
231
+ pendingRequests.set(provider, request);
232
+ return request;
233
+ };
234
+
235
+ const getProviderModels = async (
236
+ provider: LLMProvider,
237
+ options: ProviderModelsOptions = {},
238
+ ): Promise<ProviderModelsResult> => {
239
+ if (options.bypassCache) {
240
+ const pendingRequest = pendingRequests.get(provider);
241
+ if (pendingRequest) {
242
+ return pendingRequest;
243
+ }
244
+
245
+ return loadAndCacheModels(provider);
246
+ }
247
+
248
+ const cachedModels = pruneExpiredMemoryEntry(provider, now(), 'memory');
249
+ if (cachedModels) {
250
+ return cachedModels;
251
+ }
252
+
253
+ const pendingRequest = pendingRequests.get(provider);
254
+ if (pendingRequest) {
255
+ return pendingRequest;
256
+ }
257
+
258
+ await loadPersistedCache();
259
+
260
+ const persistedModels = pruneExpiredMemoryEntry(provider, now(), 'disk');
261
+ if (persistedModels) {
262
+ return persistedModels;
263
+ }
264
+
265
+ const postLoadPendingRequest = pendingRequests.get(provider);
266
+ if (postLoadPendingRequest) {
267
+ return postLoadPendingRequest;
268
+ }
269
+
270
+ return loadAndCacheModels(provider);
271
+ };
272
+
273
+ const getCurrentActiveModel = async (
274
+ provider: LLMProvider,
275
+ sessionId?: string,
276
+ ): Promise<ProviderCurrentActiveModel> => resolveProvider(provider).models.getCurrentActiveModel(sessionId);
277
+
278
+ const changeActiveModel = async (
279
+ provider: LLMProvider,
280
+ input: ProviderChangeActiveModelInput,
281
+ ): Promise<ProviderSessionActiveModelChange> => resolveProvider(provider).models.changeActiveModel(input);
282
+
283
+ const getChangedActiveModel = async (
284
+ provider: LLMProvider,
285
+ sessionId: string,
286
+ ): Promise<ProviderSessionActiveModelChange> => readProviderSessionActiveModelChange(provider, sessionId, {
287
+ filePath: activeModelChangesPath,
288
+ });
289
+
290
+ const resolveResumeModel = async (
291
+ provider: LLMProvider,
292
+ sessionId: string | undefined,
293
+ requestedModel?: string | null,
294
+ ): Promise<string | undefined> => {
295
+ const normalizedRequestedModel = typeof requestedModel === 'string' ? requestedModel.trim() : '';
296
+ if (!sessionId?.trim()) {
297
+ return normalizedRequestedModel || undefined;
298
+ }
299
+
300
+ const changedModel = await getChangedActiveModel(provider, sessionId);
301
+ if (changedModel.supported && changedModel.changed && changedModel.model?.trim()) {
302
+ return changedModel.model.trim();
303
+ }
304
+
305
+ return normalizedRequestedModel || undefined;
306
+ };
307
+
308
+ const clearCache = (): void => {
309
+ memoryCache.clear();
310
+ pendingRequests.clear();
311
+ persistedCacheLoaded = false;
312
+ persistedCacheLoadPromise = null;
313
+ };
314
+
315
+ return {
316
+ getProviderModels,
317
+ getCurrentActiveModel,
318
+ getChangedActiveModel,
319
+ changeActiveModel,
320
+ resolveResumeModel,
321
+ clearCache,
322
+ };
323
+ };
324
+
325
+ 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
 
@@ -54,6 +54,10 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> =
54
54
  provider: 'gemini',
55
55
  rootPath: path.join(os.homedir(), '.gemini', 'tmp'),
56
56
  },
57
+ {
58
+ provider: 'opencode',
59
+ rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'),
60
+ },
57
61
  ];
58
62
 
59
63
  const WATCHER_IGNORED_PATTERNS = [
@@ -87,6 +91,10 @@ let watcherRescheduleAfterRefresh = false;
87
91
  * Filters watcher events to provider-specific session artifact file types.
88
92
  */
89
93
  function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean {
94
+ if (provider === 'opencode') {
95
+ return path.basename(filePath) === 'opencode.db';
96
+ }
97
+
90
98
  if (provider === 'gemini') {
91
99
  return filePath.endsWith('.json') || filePath.endsWith('.jsonl');
92
100
  }
@@ -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({
@@ -0,0 +1,73 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import {
5
+ buildOpenCodeDefinitionFromIds,
6
+ parseOpenCodeModelsStdout,
7
+ } from '@/modules/providers/list/opencode/opencode-models.provider.js';
8
+
9
+ test('OpenCode models provider parses plain CLI output and removes duplicates', () => {
10
+ const ids = parseOpenCodeModelsStdout(`
11
+ opencode/big-pickle
12
+ not a model
13
+ anthropic/claude-opus-4-7-fast
14
+ anthropic/claude-opus-4-7-fast
15
+ openai/gpt-5.5-pro
16
+ `);
17
+
18
+ assert.deepEqual(ids, [
19
+ 'opencode/big-pickle',
20
+ 'anthropic/claude-opus-4-7-fast',
21
+ 'openai/gpt-5.5-pro',
22
+ ]);
23
+ });
24
+
25
+ test('OpenCode models provider formats frontend labels from provider-prefixed ids', () => {
26
+ const definition = buildOpenCodeDefinitionFromIds([
27
+ 'opencode/deepseek-v4-flash-free',
28
+ 'opencode/nemotron-3-super-free',
29
+ 'anthropic/claude-3-5-sonnet-20241022',
30
+ 'anthropic/claude-opus-4-7-fast',
31
+ 'openai/gpt-5.4-mini-fast',
32
+ 'openai/gpt-5.5-pro',
33
+ 'newprovider/alpha-v12-special-20261231',
34
+ ]);
35
+
36
+ assert.deepEqual(definition.OPTIONS, [
37
+ {
38
+ value: 'opencode/deepseek-v4-flash-free',
39
+ label: 'Deepseek V4 Flash Free',
40
+ description: 'opencode - opencode/deepseek-v4-flash-free',
41
+ },
42
+ {
43
+ value: 'opencode/nemotron-3-super-free',
44
+ label: 'Nemotron 3 Super Free',
45
+ description: 'opencode - opencode/nemotron-3-super-free',
46
+ },
47
+ {
48
+ value: 'anthropic/claude-3-5-sonnet-20241022',
49
+ label: 'Claude 3.5 Sonnet (2024-10-22)',
50
+ description: 'anthropic - anthropic/claude-3-5-sonnet-20241022',
51
+ },
52
+ {
53
+ value: 'anthropic/claude-opus-4-7-fast',
54
+ label: 'Claude Opus 4.7 Fast',
55
+ description: 'anthropic - anthropic/claude-opus-4-7-fast',
56
+ },
57
+ {
58
+ value: 'openai/gpt-5.4-mini-fast',
59
+ label: 'GPT-5.4 Mini Fast',
60
+ description: 'openai - openai/gpt-5.4-mini-fast',
61
+ },
62
+ {
63
+ value: 'openai/gpt-5.5-pro',
64
+ label: 'GPT-5.5 Pro',
65
+ description: 'openai - openai/gpt-5.5-pro',
66
+ },
67
+ {
68
+ value: 'newprovider/alpha-v12-special-20261231',
69
+ label: 'Alpha V12 Special (2026-12-31)',
70
+ description: 'newprovider - newprovider/alpha-v12-special-20261231',
71
+ },
72
+ ]);
73
+ });