@cloudcli-ai/cloudcli 1.37.0 → 1.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/dist/assets/index-D3jfzuKN.css +32 -0
  2. package/dist/assets/{index-eGqPeIEu.js → index-DTMWjJj8.js} +324 -305
  3. package/dist/index.html +2 -2
  4. package/dist-server/server/modules/commands/commands.routes.js +3 -3
  5. package/dist-server/server/modules/commands/commands.routes.js.map +1 -1
  6. package/dist-server/server/modules/commands/tests/commands.test.js +2 -7
  7. package/dist-server/server/modules/commands/tests/commands.test.js.map +1 -1
  8. package/dist-server/server/modules/database/index.js +2 -0
  9. package/dist-server/server/modules/database/index.js.map +1 -1
  10. package/dist-server/server/modules/database/migrations.js +18 -1
  11. package/dist-server/server/modules/database/migrations.js.map +1 -1
  12. package/dist-server/server/modules/database/repositories/provider-models.js +112 -0
  13. package/dist-server/server/modules/database/repositories/provider-models.js.map +1 -0
  14. package/dist-server/server/modules/database/repositories/sessions.db.js +63 -7
  15. package/dist-server/server/modules/database/repositories/sessions.db.js.map +1 -1
  16. package/dist-server/server/modules/database/schema.js +28 -3
  17. package/dist-server/server/modules/database/schema.js.map +1 -1
  18. package/dist-server/server/modules/database/tests/provider-models.db.integration.test.js +102 -0
  19. package/dist-server/server/modules/database/tests/provider-models.db.integration.test.js.map +1 -0
  20. package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js +2 -1
  21. package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js.map +1 -1
  22. package/dist-server/server/modules/database/tests/sessions.db.integration.test.js +22 -0
  23. package/dist-server/server/modules/database/tests/sessions.db.integration.test.js.map +1 -1
  24. package/dist-server/server/modules/file-tree/file-tree.module.js +5 -1
  25. package/dist-server/server/modules/file-tree/file-tree.module.js.map +1 -1
  26. package/dist-server/server/modules/file-tree/file-tree.service.js +50 -18
  27. package/dist-server/server/modules/file-tree/file-tree.service.js.map +1 -1
  28. package/dist-server/server/modules/file-tree/tests/file-tree.service.test.js +83 -7
  29. package/dist-server/server/modules/file-tree/tests/file-tree.service.test.js.map +1 -1
  30. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js +46 -14
  31. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js.map +1 -1
  32. package/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js +8 -15
  33. package/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js.map +1 -1
  34. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js +58 -71
  35. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js.map +1 -1
  36. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js +1 -54
  37. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js.map +1 -1
  38. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js +7 -7
  39. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js.map +1 -1
  40. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js +32 -669
  41. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js.map +1 -1
  42. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js +105 -328
  43. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js.map +1 -1
  44. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js +0 -12
  45. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js.map +1 -1
  46. package/dist-server/server/modules/providers/provider.routes.js +139 -31
  47. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  48. package/dist-server/server/modules/providers/services/provider-models.service.js +167 -199
  49. package/dist-server/server/modules/providers/services/provider-models.service.js.map +1 -1
  50. package/dist-server/server/modules/providers/services/provider-runtime.service.js +2 -2
  51. package/dist-server/server/modules/providers/services/provider-runtime.service.js.map +1 -1
  52. package/dist-server/server/modules/providers/services/session-conversations-search.service.js +12 -4
  53. package/dist-server/server/modules/providers/services/session-conversations-search.service.js.map +1 -1
  54. package/dist-server/server/modules/providers/services/sessions.service.js +62 -3
  55. package/dist-server/server/modules/providers/services/sessions.service.js.map +1 -1
  56. package/dist-server/server/modules/providers/tests/codex-sessions.test.js +53 -17
  57. package/dist-server/server/modules/providers/tests/codex-sessions.test.js.map +1 -1
  58. package/dist-server/server/modules/providers/tests/opencode-models.test.js +15 -126
  59. package/dist-server/server/modules/providers/tests/opencode-models.test.js.map +1 -1
  60. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js +5 -5
  61. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js.map +1 -1
  62. package/dist-server/server/modules/providers/tests/provider-models.service.test.js +175 -292
  63. package/dist-server/server/modules/providers/tests/provider-models.service.test.js.map +1 -1
  64. package/dist-server/server/modules/providers/tests/provider-runtime.service.test.js +2 -6
  65. package/dist-server/server/modules/providers/tests/provider-runtime.service.test.js.map +1 -1
  66. package/dist-server/server/modules/providers/tests/provider-token-usage.service.test.js +1 -0
  67. package/dist-server/server/modules/providers/tests/provider-token-usage.service.test.js.map +1 -1
  68. package/dist-server/server/modules/providers/tests/provider.routes.test.js +126 -0
  69. package/dist-server/server/modules/providers/tests/provider.routes.test.js.map +1 -0
  70. package/dist-server/server/modules/providers/tests/sessions.service.test.js +87 -0
  71. package/dist-server/server/modules/providers/tests/sessions.service.test.js.map +1 -0
  72. package/dist-server/server/modules/websocket/services/chat-websocket.service.js +5 -1
  73. package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
  74. package/package.json +3 -3
  75. package/server/modules/commands/commands.routes.ts +3 -3
  76. package/server/modules/commands/tests/commands.test.ts +2 -7
  77. package/server/modules/database/index.ts +2 -0
  78. package/server/modules/database/migrations.ts +20 -0
  79. package/server/modules/database/repositories/provider-models.ts +168 -0
  80. package/server/modules/database/repositories/sessions.db.ts +85 -8
  81. package/server/modules/database/schema.ts +29 -3
  82. package/server/modules/database/tests/provider-models.db.integration.test.ts +125 -0
  83. package/server/modules/database/tests/sessions-provider-mapping.test.ts +2 -1
  84. package/server/modules/database/tests/sessions.db.integration.test.ts +32 -0
  85. package/server/modules/file-tree/file-tree.module.ts +5 -1
  86. package/server/modules/file-tree/file-tree.service.ts +68 -20
  87. package/server/modules/file-tree/tests/file-tree.service.test.ts +104 -7
  88. package/server/modules/providers/list/claude/claude-models.provider.ts +46 -14
  89. package/server/modules/providers/list/claude/claude-runtime.provider.js +8 -16
  90. package/server/modules/providers/list/codex/codex-models.provider.ts +58 -99
  91. package/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +1 -60
  92. package/server/modules/providers/list/codex/codex-sessions.provider.ts +7 -7
  93. package/server/modules/providers/list/cursor/cursor-models.provider.ts +32 -710
  94. package/server/modules/providers/list/opencode/opencode-models.provider.ts +105 -413
  95. package/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +0 -13
  96. package/server/modules/providers/provider.routes.ts +185 -33
  97. package/server/modules/providers/services/provider-models.service.ts +223 -288
  98. package/server/modules/providers/services/provider-runtime.service.ts +2 -3
  99. package/server/modules/providers/services/session-conversations-search.service.ts +12 -4
  100. package/server/modules/providers/services/sessions.service.ts +87 -3
  101. package/server/modules/providers/tests/codex-sessions.test.ts +57 -18
  102. package/server/modules/providers/tests/opencode-models.test.ts +34 -135
  103. package/server/modules/providers/tests/opencode-sessions.test.ts +5 -5
  104. package/server/modules/providers/tests/provider-models.service.test.ts +353 -450
  105. package/server/modules/providers/tests/provider-runtime.service.test.ts +2 -6
  106. package/server/modules/providers/tests/provider-token-usage.service.test.ts +1 -0
  107. package/server/modules/providers/tests/provider.routes.test.ts +183 -0
  108. package/server/modules/providers/tests/sessions.service.test.ts +127 -0
  109. package/server/modules/websocket/services/chat-websocket.service.ts +5 -1
  110. package/server/shared/interfaces.ts +4 -6
  111. package/server/shared/types.ts +29 -17
  112. package/dist/assets/index-DSlds633.css +0 -32
@@ -1,321 +1,228 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
-
5
- import { sessionsDb } from '@/modules/database/index.js';
1
+ import { providerModelsDb, sessionsDb } from '@/modules/database/index.js';
6
2
  import { providerRegistry } from '@/modules/providers/provider.registry.js';
7
3
  import type { IProvider } from '@/shared/interfaces.js';
8
4
  import type {
5
+ CustomProviderModelInput,
6
+ CustomProviderModelRecord,
9
7
  LLMProvider,
10
8
  ProviderCurrentActiveModel,
11
- ProviderModelsCacheInfo,
9
+ ProviderModelOption,
12
10
  ProviderModelsDefinition,
13
- ProviderModelsResult,
14
11
  ProviderSessionModel,
15
12
  } from '@/shared/types.js';
16
-
17
- export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
18
- const PROVIDER_MODELS_CACHE_VERSION = 2;
19
- const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude']);
13
+ import { AppError } from '@/shared/utils.js';
20
14
 
21
15
  /** Session-row access the service needs, narrowed so tests can stub it. */
22
16
  type ProviderModelsSessionStore = {
23
- getSessionById(sessionId: string): { model: string | null } | null;
17
+ getSessionById(sessionId: string): { model: string | null; effort: string | null } | null;
24
18
  setSessionModel(sessionId: string, model: string): void;
19
+ setSessionEffort(sessionId: string, effort: string): void;
25
20
  };
26
21
 
22
+ /** SQLite catalog operations used by the Providers service and its unit fakes. */
23
+ type ProviderModelsCatalogStore = Pick<
24
+ typeof providerModelsDb,
25
+ | 'listCustomProviderModels'
26
+ | 'getCustomProviderModel'
27
+ | 'findCustomProviderModelByModelId'
28
+ | 'createCustomProviderModel'
29
+ | 'updateCustomProviderModel'
30
+ | 'deleteCustomProviderModel'
31
+ >;
32
+
27
33
  type ProviderModelsServiceDependencies = {
28
34
  resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
29
- cachePath?: string;
35
+ catalog?: ProviderModelsCatalogStore;
30
36
  sessions?: ProviderModelsSessionStore;
31
- now?: () => number;
32
- };
33
-
34
- type ProviderModelsOptions = {
35
- bypassCache?: boolean;
36
37
  };
37
38
 
38
- type ProviderModelsCacheEntry = {
39
- updatedAt: number;
40
- expiresAt: number;
41
- models: ProviderModelsDefinition;
42
- };
39
+ const toCustomProviderModelOption = (
40
+ record: CustomProviderModelRecord,
41
+ ): ProviderModelOption => ({
42
+ value: record.modelId,
43
+ label: record.model,
44
+ recordId: record.recordId,
45
+ isCustom: true,
46
+ });
43
47
 
44
- type ProviderModelsCacheFile = {
45
- version: number;
46
- entries: Record<string, ProviderModelsCacheEntry>;
48
+ const mergeProviderModels = (
49
+ predefined: ProviderModelsDefinition,
50
+ custom: CustomProviderModelRecord[],
51
+ ): ProviderModelsDefinition => {
52
+ return {
53
+ OPTIONS: [
54
+ ...predefined.OPTIONS.map((option) => ({ ...option, isCustom: false })),
55
+ ...custom.map(toCustomProviderModelOption),
56
+ ],
57
+ DEFAULT: predefined.DEFAULT,
58
+ };
47
59
  };
48
60
 
49
- const getProviderModelsCachePath = (): string => path.join(
50
- os.homedir(),
51
- '.cloudcli',
52
- 'provider-models-cache.json',
53
- );
54
-
55
- const toProviderModelsCacheInfo = (
56
- entry: ProviderModelsCacheEntry,
57
- source: ProviderModelsCacheInfo['source'],
58
- ): ProviderModelsCacheInfo => ({
59
- updatedAt: new Date(entry.updatedAt).toISOString(),
60
- expiresAt: new Date(entry.expiresAt).toISOString(),
61
- source,
61
+ const normalizeCustomModelInput = (input: CustomProviderModelInput): CustomProviderModelInput => ({
62
+ id: input.id.trim(),
63
+ model: input.model.trim(),
62
64
  });
63
65
 
64
- const isProviderModelOption = (
65
- value: unknown,
66
- ): value is ProviderModelsDefinition['OPTIONS'][number] => (
67
- Boolean(value)
68
- && typeof value === 'object'
69
- && typeof (value as ProviderModelsDefinition['OPTIONS'][number]).value === 'string'
70
- && typeof (value as ProviderModelsDefinition['OPTIONS'][number]).label === 'string'
71
- && (
72
- typeof (value as ProviderModelsDefinition['OPTIONS'][number]).description === 'undefined'
73
- || typeof (value as ProviderModelsDefinition['OPTIONS'][number]).description === 'string'
74
- )
75
- );
76
-
77
- const isProviderModelsDefinition = (value: unknown): value is ProviderModelsDefinition => (
78
- Boolean(value)
79
- && typeof value === 'object'
80
- && Array.isArray((value as ProviderModelsDefinition).OPTIONS)
81
- && (value as ProviderModelsDefinition).OPTIONS.every(isProviderModelOption)
82
- && typeof (value as ProviderModelsDefinition).DEFAULT === 'string'
83
- );
84
-
85
- const isProviderModelsCacheEntry = (value: unknown): value is ProviderModelsCacheEntry => (
86
- Boolean(value)
87
- && typeof value === 'object'
88
- && typeof (value as ProviderModelsCacheEntry).updatedAt === 'number'
89
- && typeof (value as ProviderModelsCacheEntry).expiresAt === 'number'
90
- && isProviderModelsDefinition((value as ProviderModelsCacheEntry).models)
66
+ const isUniqueConstraintError = (error: unknown): boolean => (
67
+ error !== null
68
+ && error !== undefined
69
+ && typeof error === 'object'
70
+ && 'code' in error
71
+ && String(error.code).startsWith('SQLITE_CONSTRAINT')
91
72
  );
92
73
 
93
- const readProviderModelsCacheFile = async (
94
- cachePath: string,
95
- ): Promise<ProviderModelsCacheFile | null> => {
96
- try {
97
- const raw = await readFile(cachePath, 'utf8');
98
- const parsed = JSON.parse(raw) as Partial<ProviderModelsCacheFile>;
99
- if (parsed.version !== PROVIDER_MODELS_CACHE_VERSION || !parsed.entries || typeof parsed.entries !== 'object') {
100
- return null;
101
- }
102
-
103
- const entries = Object.fromEntries(
104
- Object.entries(parsed.entries).filter((entry): entry is [string, ProviderModelsCacheEntry] =>
105
- isProviderModelsCacheEntry(entry[1]),
106
- ),
107
- );
108
-
109
- return {
110
- version: PROVIDER_MODELS_CACHE_VERSION,
111
- entries,
112
- };
113
- } catch {
114
- return null;
115
- }
116
- };
117
-
118
- const writeProviderModelsCacheFile = async (
119
- cachePath: string,
120
- entries: Map<LLMProvider, ProviderModelsCacheEntry>,
121
- now: number,
122
- ): Promise<void> => {
123
- const serializableEntries = Object.fromEntries(
124
- [...entries.entries()].filter(([, entry]) => entry.expiresAt > now),
125
- );
126
- const payload: ProviderModelsCacheFile = {
127
- version: PROVIDER_MODELS_CACHE_VERSION,
128
- entries: serializableEntries,
129
- };
130
-
131
- await mkdir(path.dirname(cachePath), { recursive: true });
132
- await writeFile(cachePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
133
- };
134
-
135
74
  /**
136
- * Provider model lookup service.
75
+ * Creates the provider model application service used by Providers routes,
76
+ * Commands, and provider runtimes.
137
77
  *
138
- * Routes and other service callers use this layer instead of resolving provider
139
- * classes directly so the provider-registry dependency stays centralized in one
140
- * place.
78
+ * Curated adapter definitions stay source-controlled and are merged at read
79
+ * time with custom SQLite rows. This deliberately has no predefined-model
80
+ * persistence, memory cache, disk cache, TTL, or provider-native discovery.
81
+ * Tests inject a small custom-model store through the same boundary.
141
82
  */
142
83
  export const createProviderModelsService = (dependencies: ProviderModelsServiceDependencies = {}) => {
143
84
  const resolveProvider = dependencies.resolveProvider ?? providerRegistry.resolveProvider;
144
- const cachePath = dependencies.cachePath ?? getProviderModelsCachePath();
85
+ const catalog = dependencies.catalog ?? providerModelsDb;
145
86
  const sessions = dependencies.sessions ?? sessionsDb;
146
- const now = dependencies.now ?? (() => Date.now());
147
- const memoryCache = new Map<LLMProvider, ProviderModelsCacheEntry>();
148
- const pendingRequests = new Map<LLMProvider, Promise<ProviderModelsResult>>();
149
- let persistedCacheLoaded = false;
150
- let persistedCacheLoadPromise: Promise<void> | null = null;
151
87
 
152
- const pruneExpiredMemoryEntry = (
88
+ const getProviderModels = async (provider: LLMProvider): Promise<ProviderModelsDefinition> => {
89
+ const predefined = await resolveProvider(provider).models.getSupportedModels();
90
+ return mergeProviderModels(predefined, catalog.listCustomProviderModels(provider));
91
+ };
92
+
93
+ const getCurrentActiveModel = async (
153
94
  provider: LLMProvider,
154
- currentTime: number,
155
- source: ProviderModelsCacheInfo['source'],
156
- ): ProviderModelsResult | null => {
157
- const cachedEntry = memoryCache.get(provider);
158
- if (!cachedEntry) {
159
- return null;
160
- }
95
+ sessionId?: string,
96
+ ): Promise<ProviderCurrentActiveModel> => resolveProvider(provider).models.getCurrentActiveModel(sessionId);
161
97
 
162
- if (cachedEntry.expiresAt > currentTime) {
163
- return {
164
- models: cachedEntry.models,
165
- cache: toProviderModelsCacheInfo(cachedEntry, source),
166
- };
98
+ const readCustomModel = (
99
+ provider: LLMProvider,
100
+ recordId: number,
101
+ ): CustomProviderModelRecord => {
102
+ const existing = catalog.getCustomProviderModel(provider, recordId);
103
+ if (!existing) {
104
+ throw new AppError('Model not found.', {
105
+ code: 'MODEL_NOT_FOUND',
106
+ statusCode: 404,
107
+ });
167
108
  }
168
109
 
169
- memoryCache.delete(provider);
170
- return null;
110
+ return existing;
171
111
  };
172
112
 
173
- const loadPersistedCache = async (): Promise<void> => {
174
- if (persistedCacheLoaded) {
175
- return;
113
+ const assertModelIdAvailable = (
114
+ provider: LLMProvider,
115
+ predefined: ProviderModelsDefinition,
116
+ modelId: string,
117
+ currentRecordId?: number,
118
+ ): void => {
119
+ if (predefined.OPTIONS.some((option) => option.value === modelId)) {
120
+ throw new AppError(`A ${provider} model with this ID already exists.`, {
121
+ code: 'MODEL_ID_ALREADY_EXISTS',
122
+ statusCode: 409,
123
+ });
176
124
  }
177
125
 
178
- if (!persistedCacheLoadPromise) {
179
- persistedCacheLoadPromise = (async () => {
180
- const cacheFile = await readProviderModelsCacheFile(cachePath);
181
- const currentTime = now();
182
-
183
- for (const [provider, entry] of Object.entries(cacheFile?.entries ?? {})) {
184
- if (entry.expiresAt > currentTime) {
185
- memoryCache.set(provider as LLMProvider, entry);
186
- }
187
- }
188
-
189
- persistedCacheLoaded = true;
190
- })().finally(() => {
191
- persistedCacheLoadPromise = null;
126
+ const duplicate = catalog.findCustomProviderModelByModelId(provider, modelId);
127
+ if (duplicate && duplicate.recordId !== currentRecordId) {
128
+ throw new AppError(`A ${provider} model with this ID already exists.`, {
129
+ code: 'MODEL_ID_ALREADY_EXISTS',
130
+ statusCode: 409,
192
131
  });
193
132
  }
194
-
195
- await persistedCacheLoadPromise;
196
133
  };
197
134
 
198
- const persistCache = async (): Promise<void> => {
135
+ const createCustomModel = async (
136
+ provider: LLMProvider,
137
+ input: CustomProviderModelInput,
138
+ ): Promise<{ model: ProviderModelOption; models: ProviderModelsDefinition }> => {
139
+ const predefined = await resolveProvider(provider).models.getSupportedModels();
140
+ const normalized = normalizeCustomModelInput(input);
141
+ assertModelIdAvailable(provider, predefined, normalized.id);
142
+
199
143
  try {
200
- await writeProviderModelsCacheFile(cachePath, memoryCache, now());
144
+ const created = catalog.createCustomProviderModel(provider, normalized);
145
+ return {
146
+ model: toCustomProviderModelOption(created),
147
+ models: mergeProviderModels(predefined, catalog.listCustomProviderModels(provider)),
148
+ };
201
149
  } catch (error) {
202
- console.warn('Unable to persist provider models cache:', error);
150
+ if (isUniqueConstraintError(error)) {
151
+ throw new AppError(`A ${provider} model with this ID already exists.`, {
152
+ code: 'MODEL_ID_ALREADY_EXISTS',
153
+ statusCode: 409,
154
+ });
155
+ }
156
+ throw error;
203
157
  }
204
158
  };
205
159
 
206
- const setCacheEntry = async (
207
- provider: LLMProvider,
208
- models: ProviderModelsDefinition,
209
- ): Promise<ProviderModelsCacheEntry> => {
210
- const currentTime = now();
211
- const entry: ProviderModelsCacheEntry = {
212
- updatedAt: currentTime,
213
- expiresAt: currentTime + PROVIDER_MODELS_CACHE_TTL_MS,
214
- models,
215
- };
216
-
217
- memoryCache.set(provider, entry);
218
- await persistCache();
219
- return entry;
220
- };
221
-
222
- const loadAndCacheModels = (
160
+ const updateCustomModel = async (
223
161
  provider: LLMProvider,
224
- ): Promise<ProviderModelsResult> => {
225
- const request = resolveProvider(provider).models.getSupportedModels()
226
- .then(async (models) => {
227
- const entry = await setCacheEntry(provider, models);
228
- return {
229
- models,
230
- cache: toProviderModelsCacheInfo(entry, 'fresh'),
231
- };
232
- })
233
- .finally(() => {
234
- pendingRequests.delete(provider);
235
- });
162
+ recordId: number,
163
+ input: CustomProviderModelInput,
164
+ ): Promise<{ model: ProviderModelOption; models: ProviderModelsDefinition }> => {
165
+ const predefined = await resolveProvider(provider).models.getSupportedModels();
166
+ readCustomModel(provider, recordId);
167
+ const normalized = normalizeCustomModelInput(input);
168
+ assertModelIdAvailable(provider, predefined, normalized.id, recordId);
236
169
 
237
- pendingRequests.set(provider, request);
238
- return request;
239
- };
240
-
241
- const loadDirectModels = (
242
- provider: LLMProvider,
243
- ): Promise<ProviderModelsResult> => {
244
- const request = resolveProvider(provider).models.getSupportedModels()
245
- .then((models) => {
246
- const currentTime = now();
247
- return {
248
- models,
249
- cache: {
250
- updatedAt: new Date(currentTime).toISOString(),
251
- expiresAt: new Date(currentTime).toISOString(),
252
- source: 'fresh' as const,
253
- },
254
- };
255
- })
256
- .finally(() => {
257
- pendingRequests.delete(provider);
258
- });
259
-
260
- pendingRequests.set(provider, request);
261
- return request;
262
- };
263
-
264
- const getProviderModels = async (
265
- provider: LLMProvider,
266
- options: ProviderModelsOptions = {},
267
- ): Promise<ProviderModelsResult> => {
268
- if (UNCACHED_PROVIDERS.has(provider)) {
269
- const pendingRequest = pendingRequests.get(provider);
270
- if (pendingRequest) {
271
- return pendingRequest;
170
+ try {
171
+ const updated = catalog.updateCustomProviderModel(provider, recordId, normalized);
172
+ if (!updated) {
173
+ throw new AppError('Model not found.', {
174
+ code: 'MODEL_NOT_FOUND',
175
+ statusCode: 404,
176
+ });
272
177
  }
273
178
 
274
- return loadDirectModels(provider);
275
- }
276
-
277
- if (options.bypassCache) {
278
- const pendingRequest = pendingRequests.get(provider);
279
- if (pendingRequest) {
280
- return pendingRequest;
179
+ return {
180
+ model: toCustomProviderModelOption(updated),
181
+ models: mergeProviderModels(predefined, catalog.listCustomProviderModels(provider)),
182
+ };
183
+ } catch (error) {
184
+ if (isUniqueConstraintError(error)) {
185
+ throw new AppError(`A ${provider} model with this ID already exists.`, {
186
+ code: 'MODEL_ID_ALREADY_EXISTS',
187
+ statusCode: 409,
188
+ });
281
189
  }
282
-
283
- return loadAndCacheModels(provider);
284
- }
285
-
286
- const cachedModels = pruneExpiredMemoryEntry(provider, now(), 'memory');
287
- if (cachedModels) {
288
- return cachedModels;
289
- }
290
-
291
- const pendingRequest = pendingRequests.get(provider);
292
- if (pendingRequest) {
293
- return pendingRequest;
294
- }
295
-
296
- await loadPersistedCache();
297
-
298
- const persistedModels = pruneExpiredMemoryEntry(provider, now(), 'disk');
299
- if (persistedModels) {
300
- return persistedModels;
190
+ throw error;
301
191
  }
192
+ };
302
193
 
303
- const postLoadPendingRequest = pendingRequests.get(provider);
304
- if (postLoadPendingRequest) {
305
- return postLoadPendingRequest;
194
+ const deleteCustomModel = async (
195
+ provider: LLMProvider,
196
+ recordId: number,
197
+ ): Promise<{ model: ProviderModelOption; models: ProviderModelsDefinition }> => {
198
+ const predefined = await resolveProvider(provider).models.getSupportedModels();
199
+ readCustomModel(provider, recordId);
200
+ const removed = catalog.deleteCustomProviderModel(provider, recordId, predefined.DEFAULT);
201
+ if (!removed) {
202
+ throw new AppError('Model not found.', {
203
+ code: 'MODEL_NOT_FOUND',
204
+ statusCode: 404,
205
+ });
306
206
  }
307
207
 
308
- return loadAndCacheModels(provider);
208
+ return {
209
+ model: toCustomProviderModelOption(removed),
210
+ models: mergeProviderModels(predefined, catalog.listCustomProviderModels(provider)),
211
+ };
309
212
  };
310
213
 
311
- const getCurrentActiveModel = async (
312
- provider: LLMProvider,
313
- sessionId?: string,
314
- ): Promise<ProviderCurrentActiveModel> => resolveProvider(provider).models.getCurrentActiveModel(sessionId);
315
-
316
- const readRecordedSessionModel = (sessionId: string): string | null => {
214
+ const readRecordedSessionSelection = (
215
+ sessionId: string,
216
+ ): { model: string | null; effort: string | null } | null => {
317
217
  const session = sessions.getSessionById(sessionId);
318
- return session?.model?.trim() || null;
218
+ if (!session) {
219
+ return null;
220
+ }
221
+
222
+ return {
223
+ model: session.model?.trim() || null,
224
+ effort: session.effort?.trim() || null,
225
+ };
319
226
  };
320
227
 
321
228
  /**
@@ -338,7 +245,8 @@ export const createProviderModelsService = (dependencies: ProviderModelsServiceD
338
245
  return null;
339
246
  }
340
247
 
341
- if (!sessions.getSessionById(normalizedSessionId)) {
248
+ const recordedSelection = readRecordedSessionSelection(normalizedSessionId);
249
+ if (!recordedSelection) {
342
250
  return null;
343
251
  }
344
252
 
@@ -347,6 +255,38 @@ export const createProviderModelsService = (dependencies: ProviderModelsServiceD
347
255
  provider,
348
256
  sessionId: normalizedSessionId,
349
257
  model: normalizedModel,
258
+ effort: recordedSelection.effort,
259
+ source: 'session',
260
+ };
261
+ };
262
+
263
+ /**
264
+ * Records the reasoning effort one session runs with.
265
+ *
266
+ * Like `setSessionModel`, this ignores an id that has not been allocated by
267
+ * the session gateway yet. The websocket send path records it once the row
268
+ * exists, so a pre-session composer choice is not lost.
269
+ */
270
+ const setSessionEffort = (
271
+ provider: LLMProvider,
272
+ sessionId: string,
273
+ effort: string,
274
+ ): { provider: LLMProvider; sessionId: string; effort: string; source: 'session' } | null => {
275
+ const normalizedSessionId = sessionId.trim();
276
+ const normalizedEffort = effort.trim();
277
+ if (!normalizedSessionId || !normalizedEffort) {
278
+ return null;
279
+ }
280
+
281
+ if (!readRecordedSessionSelection(normalizedSessionId)) {
282
+ return null;
283
+ }
284
+
285
+ sessions.setSessionEffort(normalizedSessionId, normalizedEffort);
286
+ return {
287
+ provider,
288
+ sessionId: normalizedSessionId,
289
+ effort: normalizedEffort,
350
290
  source: 'session',
351
291
  };
352
292
  };
@@ -355,13 +295,10 @@ export const createProviderModelsService = (dependencies: ProviderModelsServiceD
355
295
  * Answers "which model is this session using?" for every display surface.
356
296
  *
357
297
  * Precedence, highest first:
358
- * 1. the model recorded on the session row — the user's pick, or whatever
359
- * the last send used;
360
- * 2. the provider's own session state, for sessions started outside the app
361
- * that we have never recorded a model for;
362
- * 3. `requestedModel`, the client's current default, for a chat that has no
363
- * session yet;
364
- * 4. the provider catalog default.
298
+ * 1. the model recorded on the session row;
299
+ * 2. the provider's own session state for externally-created sessions;
300
+ * 3. `requestedModel`, the client's current default;
301
+ * 4. the source-controlled provider catalog default.
365
302
  */
366
303
  const resolveSessionModel = async (
367
304
  provider: LLMProvider,
@@ -373,26 +310,26 @@ export const createProviderModelsService = (dependencies: ProviderModelsServiceD
373
310
  : '';
374
311
 
375
312
  if (normalizedSessionId) {
376
- const recordedModel = readRecordedSessionModel(normalizedSessionId);
377
- if (recordedModel) {
313
+ const recordedSelection = readRecordedSessionSelection(normalizedSessionId);
314
+ if (recordedSelection?.model) {
378
315
  return {
379
316
  provider,
380
317
  sessionId: normalizedSessionId,
381
- model: recordedModel,
318
+ model: recordedSelection.model,
319
+ effort: recordedSelection.effort,
382
320
  source: 'session',
383
321
  };
384
322
  }
385
323
 
386
- // Never sent on through the app. Ask the provider what its own session
387
- // state says before falling back to anything client-supplied.
388
- const catalog = (await getProviderModels(provider)).models;
324
+ const providerCatalog = await getProviderModels(provider);
389
325
  const providerModel = await getCurrentActiveModel(provider, normalizedSessionId);
390
326
  const resolvedProviderModel = providerModel.model?.trim();
391
- if (resolvedProviderModel && resolvedProviderModel !== catalog.DEFAULT) {
327
+ if (resolvedProviderModel && resolvedProviderModel !== providerCatalog.DEFAULT) {
392
328
  return {
393
329
  provider,
394
330
  sessionId: normalizedSessionId,
395
331
  model: resolvedProviderModel,
332
+ effort: recordedSelection?.effort ?? null,
396
333
  source: 'provider',
397
334
  };
398
335
  }
@@ -400,7 +337,8 @@ export const createProviderModelsService = (dependencies: ProviderModelsServiceD
400
337
  return {
401
338
  provider,
402
339
  sessionId: normalizedSessionId,
403
- model: normalizedRequestedModel || catalog.DEFAULT,
340
+ model: normalizedRequestedModel || providerCatalog.DEFAULT,
341
+ effort: recordedSelection?.effort ?? null,
404
342
  source: normalizedRequestedModel ? 'session' : 'default',
405
343
  };
406
344
  }
@@ -410,26 +348,26 @@ export const createProviderModelsService = (dependencies: ProviderModelsServiceD
410
348
  provider,
411
349
  sessionId: null,
412
350
  model: normalizedRequestedModel,
351
+ effort: null,
413
352
  source: 'session',
414
353
  };
415
354
  }
416
355
 
417
- const catalog = (await getProviderModels(provider)).models;
356
+ const providerCatalog = await getProviderModels(provider);
418
357
  return {
419
358
  provider,
420
359
  sessionId: null,
421
- model: catalog.DEFAULT,
360
+ model: providerCatalog.DEFAULT,
361
+ effort: null,
422
362
  source: 'default',
423
363
  };
424
364
  };
425
365
 
426
366
  /**
427
- * Picks the model one run should use, for provider runtime adapters.
367
+ * Picks the model one resumed provider run should use.
428
368
  *
429
- * Deliberately narrower than `resolveSessionModel`: the provider's own
430
- * session state is not consulted here. Codex reports a global config value
431
- * from `getCurrentActiveModel`, which would silently override the model the
432
- * user picked in the composer on every single run.
369
+ * Provider-global state is deliberately ignored because it must never
370
+ * override the model explicitly selected in the composer.
433
371
  */
434
372
  const resolveResumeModel = async (
435
373
  provider: LLMProvider,
@@ -443,24 +381,21 @@ export const createProviderModelsService = (dependencies: ProviderModelsServiceD
443
381
  return normalizedRequestedModel || undefined;
444
382
  }
445
383
 
446
- const recordedModel = readRecordedSessionModel(normalizedSessionId);
384
+ const recordedModel = readRecordedSessionSelection(normalizedSessionId)?.model;
447
385
  return recordedModel || normalizedRequestedModel || undefined;
448
386
  };
449
387
 
450
- const clearCache = (): void => {
451
- memoryCache.clear();
452
- pendingRequests.clear();
453
- persistedCacheLoaded = false;
454
- persistedCacheLoadPromise = null;
455
- };
456
-
457
388
  return {
458
389
  getProviderModels,
390
+ createCustomModel,
391
+ updateCustomModel,
392
+ deleteCustomModel,
459
393
  setSessionModel,
394
+ setSessionEffort,
460
395
  resolveSessionModel,
461
396
  resolveResumeModel,
462
- clearCache,
463
397
  };
464
398
  };
465
399
 
400
+ /** Shared Providers service used by routes, Commands, and provider runtimes. */
466
401
  export const providerModelsService = createProviderModelsService();
@@ -29,7 +29,7 @@ const defaultDependencies: ProviderRuntimeServiceDependencies = {
29
29
  resolveProviderSessionId: (sessionId) => sessionsService.resolveProviderSessionId(sessionId),
30
30
  resolveResumeModel: (provider, sessionId, requestedModel) =>
31
31
  providerModelsService.resolveResumeModel(provider, sessionId, requestedModel),
32
- getProviderModels: (provider, options) => providerModelsService.getProviderModels(provider, options),
32
+ getProviderModels: (provider) => providerModelsService.getProviderModels(provider),
33
33
  };
34
34
 
35
35
  /**
@@ -50,8 +50,7 @@ export function createProviderRuntimeService(
50
50
  resolveProviderSessionId: dependencies.resolveProviderSessionId,
51
51
  resolveResumeModel: (sessionId, requestedModel) =>
52
52
  dependencies.resolveResumeModel(provider.id, sessionId, requestedModel),
53
- getProviderModels: async () =>
54
- (await dependencies.getProviderModels(provider.id)).models,
53
+ getProviderModels: async () => dependencies.getProviderModels(provider.id),
55
54
  normalizeMessage: (raw, sessionId) => provider.sessions.normalizeMessage(raw, sessionId),
56
55
  async isProviderInstalled() {
57
56
  try {