@oar-sh/oar 0.9.1 → 0.9.3

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 (146) hide show
  1. package/.github/extensions/web-relay/extension.mjs +0 -3
  2. package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
  3. package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
  4. package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
  5. package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
  6. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
  7. package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
  8. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
  9. package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
  10. package/CHANGELOG.md +269 -0
  11. package/README.md +407 -424
  12. package/bin/oar.js +142 -11
  13. package/package.json +8 -4
  14. package/server/README.md +59 -22
  15. package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
  16. package/server/claude-worker/claude-session-process.mjs +1013 -45
  17. package/server/claude-worker/claude-session-worker.mjs +54 -9
  18. package/server/claude-worker/claude-turn-publisher.mjs +110 -19
  19. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  20. package/server/copilot-sdk-runtime.mjs +12 -3
  21. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  22. package/server/copilot-worker/copilot-attachments.mjs +35 -2
  23. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  24. package/server/copilot-worker/copilot-model-switch.mjs +425 -0
  25. package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
  26. package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
  27. package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
  28. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
  29. package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
  30. package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
  31. package/server/cursor-worker/cursor-session-worker.mjs +5 -5
  32. package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
  33. package/server/db-schema.mjs +61 -1
  34. package/server/features.mjs +155 -41
  35. package/server/grok-worker/grok-session-worker.mjs +5 -5
  36. package/server/grok-worker/grok-turn-runner.mjs +45 -4
  37. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  38. package/server/public/app/annotate-core.mjs +181 -0
  39. package/server/public/app/annotate-editor.js +617 -0
  40. package/server/public/app/api-client.js +86 -5
  41. package/server/public/app/ask-user-view.js +102 -13
  42. package/server/public/app/attachments-view.js +172 -3
  43. package/server/public/app/background-task-timeout-options.mjs +9 -0
  44. package/server/public/app/background-tasks-view.mjs +34 -19
  45. package/server/public/app/bootstrap.js +153 -69
  46. package/server/public/app/composer-control-state.mjs +52 -34
  47. package/server/public/app/context-tier-options.mjs +24 -4
  48. package/server/public/app/conversation-draft-sync.mjs +187 -0
  49. package/server/public/app/conversation-list-filter.mjs +86 -0
  50. package/server/public/app/conversation-preferences.mjs +47 -10
  51. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  52. package/server/public/app/conversation-view.js +1032 -218
  53. package/server/public/app/emoji-view.js +2 -0
  54. package/server/public/app/font-scaling.js +4 -37
  55. package/server/public/app/journal-view.js +190 -24
  56. package/server/public/app/model-selector-options.mjs +185 -1
  57. package/server/public/app/new-conversation-model-choice.mjs +29 -9
  58. package/server/public/app/pwa-install.js +64 -136
  59. package/server/public/app/question-multi-select.mjs +52 -0
  60. package/server/public/app/reasoning-effort-labels.mjs +13 -3
  61. package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
  62. package/server/public/app/router.js +120 -11
  63. package/server/public/app/settings-modal.js +225 -20
  64. package/server/public/app/settings-tabs.js +1 -1
  65. package/server/public/app/socket-handlers.js +47 -4
  66. package/server/public/app/steered-turn-merge.mjs +100 -0
  67. package/server/public/app/store.js +159 -16
  68. package/server/public/app/sync-outbox.mjs +16 -8
  69. package/server/public/app/tool-activity-emoji.mjs +36 -0
  70. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  71. package/server/public/app/update-ui.js +194 -0
  72. package/server/public/index.html +483 -24
  73. package/server/public/sw.js +7 -0
  74. package/server/relay-tools.md +2 -0
  75. package/server/relay.mjs +23 -21
  76. package/server/repositories/message-repository.mjs +78 -21
  77. package/server/repositories/question-repository.mjs +19 -1
  78. package/server/repositories/session-repository.mjs +7 -31
  79. package/server/routes/ask-user-routes.mjs +10 -0
  80. package/server/routes/messages-routes.mjs +689 -159
  81. package/server/routes/sessions-routes.mjs +574 -80
  82. package/server/server-runtime.mjs +393 -925
  83. package/server/services/claude-auth-service.mjs +7 -1
  84. package/server/services/cli-install-service.mjs +8 -27
  85. package/server/services/cli-process-runner.mjs +30 -0
  86. package/server/services/copilot-model-discovery-service.mjs +202 -0
  87. package/server/services/data-dir-resolution.mjs +47 -0
  88. package/server/services/grok-auth-service.mjs +9 -1
  89. package/server/services/live-turn-picker.mjs +47 -0
  90. package/server/services/model-variant-catalog-service.mjs +1183 -0
  91. package/server/services/oar-cli-helpers.mjs +6 -2
  92. package/server/services/relay-prompt-sanitizer.mjs +30 -3
  93. package/server/services/relay-question-context.mjs +46 -0
  94. package/server/services/sdk-session-import-service.mjs +144 -16
  95. package/server/services/sdk-session-sync-service.mjs +84 -27
  96. package/server/services/session-worker-registry-service.mjs +116 -3
  97. package/server/services/session-worker-supervisor-service.mjs +7 -1
  98. package/server/services/session-worker-websocket-service.mjs +119 -24
  99. package/server/services/status-event-service.mjs +8 -2
  100. package/server/services/steer-resend.mjs +45 -0
  101. package/server/services/update-check-service.mjs +220 -0
  102. package/server/services/update-install-service.mjs +279 -0
  103. package/server/services/windows-autostart-service.mjs +42 -6
  104. package/server/services/windows-boot-autostart-service.mjs +487 -0
  105. package/shared/ask-user-bridge.mjs +37 -5
  106. package/shared/background-task-timeout.mjs +12 -4
  107. package/shared/claude-model-catalog.mjs +49 -0
  108. package/shared/context-window-fallbacks.mjs +1 -0
  109. package/shared/control-poller.mjs +4 -1
  110. package/shared/copilot-model-order.mjs +182 -0
  111. package/shared/media-embed-instructions.mjs +35 -0
  112. package/shared/model-descriptors.mjs +181 -30
  113. package/shared/model-id.mjs +6 -0
  114. package/shared/pwa-app-name.mjs +44 -0
  115. package/shared/question-multi-select.mjs +16 -0
  116. package/shared/relay-stop-failure.mjs +15 -0
  117. package/shared/slash-command-guard.mjs +76 -0
  118. package/shared/steer-note.mjs +66 -0
  119. package/shared/steer-settle-failure.mjs +89 -0
  120. package/shared/steer-settle-markers.mjs +18 -0
  121. package/shared/update-semver.mjs +50 -0
  122. package/shared/worker-crash-guard.mjs +40 -14
  123. package/shared/worker-runtime/api-client.mjs +34 -0
  124. package/shared/worker-runtime/config-loader.mjs +104 -0
  125. package/shared/worker-runtime/heartbeat.mjs +109 -0
  126. package/shared/worker-runtime/prompt-context.mjs +160 -0
  127. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  128. package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
  129. package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
  130. package/server/claude-worker/claude-session-test-harness.mjs +0 -236
  131. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  132. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
  133. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  134. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  135. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  136. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  137. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  138. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  139. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  140. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  141. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  142. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  143. package/server/routes/messages-routes-test-harness.mjs +0 -111
  144. package/server/services/fixtures/claude-auth-stub.sh +0 -62
  145. package/server/services/fixtures/cli-install-stub.sh +0 -75
  146. package/server/services/fixtures/grok-stub.sh +0 -86
@@ -0,0 +1,1183 @@
1
+ // The Copilot model catalog: the in-memory snapshot state plus the persisted
2
+ // `model_variants` rows behind GET /api/models and GET /api/model-variants.
3
+ //
4
+ // Extracted verbatim-in-spirit from server-runtime.mjs so it can be exercised
5
+ // against an in-memory SQLite database (server-runtime boots a server on
6
+ // import). The factory is created BEFORE the database exists — the runtime
7
+ // publishes its bootstrap catalog first — and `bindDatabase(db)` attaches the
8
+ // prepared statements, seeds an empty table and normalizes legacy ids, in the
9
+ // same order the runtime always did.
10
+ //
11
+ // What the runtime's snapshot producers publish is the per-model metadata
12
+ // contract (`modelMetadataByModel[modelId]`, see shared/model-descriptors.mjs
13
+ // `modelMetadataFromDescriptor`). This module is the only place that decides
14
+ // how that metadata becomes rows:
15
+ //
16
+ // * reasoning-effort variants come from the model's OWN `supportedEfforts`
17
+ // when the runtime reported them (one row per level, exactly the runtime's
18
+ // list — 'none' and 'minimal' included), a single null-effort row for a
19
+ // model the runtime reports as effort-less, and the global ladder only for
20
+ // models nobody has described yet (curated seeds, help-text ids);
21
+ // * `sort_order` is the model's index in the canonical Copilot order
22
+ // (shared/copilot-model-order.mjs), recomputed over the WHOLE table on
23
+ // every ingest — never a per-batch counter, so a model's position cannot
24
+ // depend on which snapshot introduced it;
25
+ // * the metadata object itself is persisted per row (`metadata_json`) so a
26
+ // restart serves vendor/category/window/efforts without waiting for the
27
+ // next worker or discovery snapshot;
28
+ // * curated ids only seed an EMPTY catalog; once an authoritative snapshot
29
+ // (a running engine or the server-side discovery) has listed the models,
30
+ // anything absent from it is marked `unavailable` (enabled rows stay
31
+ // listed so a picker can show them greyed; disabled ones are pruned).
32
+ import { humanizeModelLabel } from '../public/app/model-selector-options.mjs';
33
+ import { latestModelCatalogRefresh } from '../../shared/model-catalog-freshness.mjs';
34
+ import {
35
+ canonicalizeModelId,
36
+ filterValidModelIds,
37
+ isValidModelId,
38
+ } from '../../shared/model-id.mjs';
39
+ import { KNOWN_REASONING_EFFORTS, MODEL_PICKER_CATEGORIES } from '../../shared/model-descriptors.mjs';
40
+ import { copilotCatalogIndexByModel, inferCopilotVendor } from '../../shared/copilot-model-order.mjs';
41
+ import { selectModelIdsForVariantRefresh } from '../../shared/model-refresh.mjs';
42
+
43
+ export const SUPPORTED_REASONING_EFFORTS = [...KNOWN_REASONING_EFFORTS];
44
+ /**
45
+ * The ladder assumed for a gpt-/claude- model NOBODY has described yet (a
46
+ * curated seed, a help-text id). Deliberately the historical six: `minimal`
47
+ * only ever appears when the runtime reports it for a specific model.
48
+ */
49
+ export const FALLBACK_REASONING_EFFORTS = Object.freeze(['none', 'low', 'medium', 'high', 'xhigh', 'max']);
50
+ const REASONING_VARIANT_SUPPORTED_PREFIXES = ['gpt-', 'claude-'];
51
+ /**
52
+ * Snapshot sources that read the runtime's real model list. Any of them
53
+ * outranks the curated seed and the `copilot help` text fallback.
54
+ */
55
+ export const AUTHORITATIVE_SNAPSHOT_SOURCE_RE = /^(web-relay-extension|standalone-relay|copilot-sdk-worker|server-discovery):/;
56
+
57
+ /** The per-model metadata fields of the API contract, in payload order. */
58
+ export const MODEL_METADATA_FIELDS = Object.freeze([
59
+ 'defaultContextLimitTokens',
60
+ 'longContextLimitTokens',
61
+ 'pricing',
62
+ 'displayName',
63
+ 'vendor',
64
+ 'pickerCategory',
65
+ 'preview',
66
+ 'contextWindowTokens',
67
+ 'maxPromptTokens',
68
+ 'supportedEfforts',
69
+ 'catalogIndex',
70
+ ]);
71
+
72
+ function uniqueStringList(values) {
73
+ const out = [];
74
+ const seen = new Set();
75
+ for (const value of Array.isArray(values) ? values : []) {
76
+ const text = String(value || '').trim();
77
+ if (!text || seen.has(text)) continue;
78
+ seen.add(text);
79
+ out.push(text);
80
+ }
81
+ return out;
82
+ }
83
+
84
+ function toNullableInt(value) {
85
+ if (value === null || value === undefined || value === '') return null;
86
+ const n = Number(value);
87
+ if (!Number.isFinite(n)) return null;
88
+ return Math.round(n);
89
+ }
90
+
91
+ function toPositiveInt(value) {
92
+ const n = toNullableInt(value);
93
+ return n !== null && n > 0 ? n : null;
94
+ }
95
+
96
+ function normalizeText(value, maxLength) {
97
+ const text = String(value ?? '').trim();
98
+ return text ? text.slice(0, maxLength) : null;
99
+ }
100
+
101
+ export function normalizeReasoningEffort(value) {
102
+ const text = String(value || '').trim().toLowerCase();
103
+ if (!text) return null;
104
+ return SUPPORTED_REASONING_EFFORTS.includes(text) ? text : null;
105
+ }
106
+
107
+ function effortRank(effort) {
108
+ const index = SUPPORTED_REASONING_EFFORTS.indexOf(String(effort || '').toLowerCase());
109
+ return index === -1 ? SUPPORTED_REASONING_EFFORTS.length : index;
110
+ }
111
+
112
+ export function isReasoningVariantEligibleModel(modelId) {
113
+ const normalized = String(modelId || '').trim().toLowerCase();
114
+ if (!normalized) return false;
115
+ return REASONING_VARIANT_SUPPORTED_PREFIXES.some((prefix) => normalized.startsWith(prefix));
116
+ }
117
+
118
+ export function modelProviderForId(modelId) {
119
+ const text = String(modelId || '').trim().toLowerCase();
120
+ if (!text) return 'other';
121
+ if (
122
+ text.startsWith('gpt-')
123
+ || text.startsWith('o1-')
124
+ || text.startsWith('o3-')
125
+ || text.startsWith('codex-')
126
+ || text.startsWith('openai/')
127
+ ) return 'openai';
128
+ if (text.startsWith('claude-')) return 'anthropic';
129
+ if (text.startsWith('gemini-')) return 'google';
130
+ if (text.startsWith('mai-')) return 'microsoft';
131
+ if (text.startsWith('grok-')) return 'xai';
132
+ if (text.startsWith('kimi-')) return 'moonshot';
133
+ return 'other';
134
+ }
135
+
136
+ // Single source of truth with the composer's labels: the server-built variant
137
+ // labels are preferred by the client, so a second formatter here would drift
138
+ // (it did — "Claude Sonnet 4.6" in the catalog modal vs "Sonnet 4.6" in the
139
+ // composer). The module is pure browser-neutral ESM.
140
+ function modelDisplayLabel(modelId) {
141
+ return humanizeModelLabel(modelId);
142
+ }
143
+
144
+ export function buildModelVariantId(baseModelId, reasoningEffort = null) {
145
+ const base = String(baseModelId || '').trim();
146
+ if (!base) return '';
147
+ const effort = normalizeReasoningEffort(reasoningEffort);
148
+ if (!effort) return base;
149
+ return `${base}-${effort}`;
150
+ }
151
+
152
+ export function parseModelVariantId(variantId = '', {
153
+ knownBaseModels = [],
154
+ } = {}) {
155
+ const value = String(variantId || '').trim();
156
+ if (!value) return null;
157
+ const known = Array.isArray(knownBaseModels)
158
+ ? knownBaseModels.map((entry) => String(entry || '').trim()).filter(Boolean)
159
+ : [];
160
+ const orderedKnown = known.sort((a, b) => b.length - a.length);
161
+ for (const candidate of orderedKnown) {
162
+ if (!value.toLowerCase().startsWith(`${candidate.toLowerCase()}-`)) continue;
163
+ const suffix = value.slice(candidate.length + 1);
164
+ const effort = normalizeReasoningEffort(suffix);
165
+ if (effort) {
166
+ return {
167
+ variantId: buildModelVariantId(candidate, effort),
168
+ baseModelId: candidate,
169
+ reasoningEffort: effort,
170
+ };
171
+ }
172
+ }
173
+ const trailingEffortMatch = value.match(/^(.*?)-([a-z]+)$/i);
174
+ if (trailingEffortMatch) {
175
+ const effort = normalizeReasoningEffort(trailingEffortMatch[2]);
176
+ const maybeBase = String(trailingEffortMatch[1] || '').trim();
177
+ if (effort && maybeBase) {
178
+ return {
179
+ variantId: buildModelVariantId(maybeBase, effort),
180
+ baseModelId: maybeBase,
181
+ reasoningEffort: effort,
182
+ };
183
+ }
184
+ }
185
+ return {
186
+ variantId: value,
187
+ baseModelId: value,
188
+ reasoningEffort: null,
189
+ };
190
+ }
191
+
192
+ /**
193
+ * One contract metadata entry from an untrusted snapshot value. Returns null
194
+ * when nothing usable is in it. `supportedEfforts` distinguishes three
195
+ * states: `undefined` = the publisher said nothing about efforts (the global
196
+ * ladder applies to gpt-/claude- ids), `null` = the runtime reported the
197
+ * model as effort-less, an array = exactly the runtime's levels.
198
+ */
199
+ export function normalizeModelMetadataEntry(rawMetadata) {
200
+ if (!rawMetadata || typeof rawMetadata !== 'object' || Array.isArray(rawMetadata)) return null;
201
+ const entry = {
202
+ defaultContextLimitTokens: toPositiveInt(rawMetadata.defaultContextLimitTokens),
203
+ longContextLimitTokens: toPositiveInt(rawMetadata.longContextLimitTokens),
204
+ pricing: rawMetadata.pricing && typeof rawMetadata.pricing === 'object' ? rawMetadata.pricing : null,
205
+ displayName: normalizeText(rawMetadata.displayName, 120),
206
+ vendor: normalizeText(rawMetadata.vendor, 80),
207
+ pickerCategory: MODEL_PICKER_CATEGORIES.includes(String(rawMetadata.pickerCategory || '').toLowerCase())
208
+ ? String(rawMetadata.pickerCategory).toLowerCase()
209
+ : null,
210
+ preview: rawMetadata.preview === true,
211
+ contextWindowTokens: toPositiveInt(rawMetadata.contextWindowTokens),
212
+ maxPromptTokens: toPositiveInt(rawMetadata.maxPromptTokens),
213
+ catalogIndex: (() => {
214
+ const n = toNullableInt(rawMetadata.catalogIndex);
215
+ return n !== null && n >= 0 ? n : null;
216
+ })(),
217
+ };
218
+ if (Object.prototype.hasOwnProperty.call(rawMetadata, 'supportedEfforts')) {
219
+ const list = Array.isArray(rawMetadata.supportedEfforts)
220
+ ? uniqueStringList(rawMetadata.supportedEfforts.map((value) => normalizeReasoningEffort(value)).filter(Boolean))
221
+ : [];
222
+ entry.supportedEfforts = list.length ? list : null;
223
+ }
224
+ const informative = entry.defaultContextLimitTokens !== null
225
+ || entry.longContextLimitTokens !== null
226
+ || entry.pricing !== null
227
+ || entry.displayName !== null
228
+ || entry.vendor !== null
229
+ || entry.pickerCategory !== null
230
+ || entry.contextWindowTokens !== null
231
+ || entry.maxPromptTokens !== null
232
+ || entry.preview
233
+ || Object.prototype.hasOwnProperty.call(entry, 'supportedEfforts');
234
+ return informative ? entry : null;
235
+ }
236
+
237
+ export function normalizeModelMetadataByModel(value = {}) {
238
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
239
+ const normalized = {};
240
+ for (const [rawModelId, rawMetadata] of Object.entries(value)) {
241
+ const modelId = canonicalizeModelId(rawModelId);
242
+ if (!isValidModelId(modelId)) continue;
243
+ const entry = normalizeModelMetadataEntry(rawMetadata);
244
+ if (entry) normalized[modelId] = entry;
245
+ }
246
+ return normalized;
247
+ }
248
+
249
+ export function normalizeContextLimitsByModel(value = {}) {
250
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
251
+ const normalized = {};
252
+ for (const [rawModelId, rawLimit] of Object.entries(value)) {
253
+ const modelId = canonicalizeModelId(rawModelId);
254
+ const limit = Number(rawLimit);
255
+ if (!isValidModelId(modelId) || !Number.isFinite(limit) || limit <= 0) continue;
256
+ normalized[modelId] = Math.round(limit);
257
+ }
258
+ return normalized;
259
+ }
260
+
261
+ /**
262
+ * Newer metadata over stored metadata, field by field: a null from a poorer
263
+ * publisher (an extension-era snapshot carries no vendor) must not erase what
264
+ * a richer one already stored. `supportedEfforts` is the exception — when the
265
+ * newer entry SAYS anything about efforts (null = effort-less included) it is
266
+ * the runtime's word and replaces the old value.
267
+ */
268
+ export function mergeModelMetadata(stored, incoming) {
269
+ const base = stored && typeof stored === 'object' ? { ...stored } : {};
270
+ if (!incoming || typeof incoming !== 'object') return Object.keys(base).length ? base : null;
271
+ for (const [key, value] of Object.entries(incoming)) {
272
+ if (key === 'catalogIndex') continue;
273
+ if (key === 'supportedEfforts') {
274
+ base.supportedEfforts = value === undefined ? base.supportedEfforts : value;
275
+ continue;
276
+ }
277
+ if (key === 'preview') {
278
+ base.preview = value === true || (base.preview === true && value !== false);
279
+ continue;
280
+ }
281
+ if (value !== null && value !== undefined) base[key] = value;
282
+ else if (!(key in base)) base[key] = null;
283
+ }
284
+ return base;
285
+ }
286
+
287
+ /** The persisted form: contract fields only, `catalogIndex` never stored. */
288
+ function serializeModelMetadata(metadata) {
289
+ if (!metadata || typeof metadata !== 'object') return null;
290
+ const out = {};
291
+ for (const field of MODEL_METADATA_FIELDS) {
292
+ if (field === 'catalogIndex') continue;
293
+ if (metadata[field] !== undefined) out[field] = metadata[field];
294
+ }
295
+ return Object.keys(out).length ? JSON.stringify(out) : null;
296
+ }
297
+
298
+ function parseStoredMetadata(value) {
299
+ if (!value) return null;
300
+ if (typeof value === 'object') return value;
301
+ try {
302
+ const parsed = JSON.parse(value);
303
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
304
+ } catch {
305
+ return null;
306
+ }
307
+ }
308
+
309
+ /** A full contract object (every field present) for the API payloads. */
310
+ function toContractMetadata(metadata, { defaultContextLimitTokens, longContextLimitTokens, pricing, catalogIndex }) {
311
+ const source = metadata && typeof metadata === 'object' ? metadata : {};
312
+ return {
313
+ defaultContextLimitTokens: toPositiveInt(defaultContextLimitTokens ?? source.defaultContextLimitTokens),
314
+ longContextLimitTokens: toPositiveInt(longContextLimitTokens ?? source.longContextLimitTokens),
315
+ pricing: pricing ?? source.pricing ?? null,
316
+ displayName: source.displayName ?? null,
317
+ vendor: source.vendor ?? null,
318
+ pickerCategory: source.pickerCategory ?? null,
319
+ preview: source.preview === true,
320
+ contextWindowTokens: toPositiveInt(source.contextWindowTokens),
321
+ maxPromptTokens: toPositiveInt(source.maxPromptTokens),
322
+ supportedEfforts: Array.isArray(source.supportedEfforts) && source.supportedEfforts.length
323
+ ? [...source.supportedEfforts]
324
+ : null,
325
+ catalogIndex: Number.isFinite(Number(catalogIndex)) ? Math.max(0, Math.trunc(Number(catalogIndex))) : null,
326
+ };
327
+ }
328
+
329
+ /**
330
+ * The effort levels a model's variant rows should cover. Known (array) and
331
+ * effort-less (null) come from the runtime; `undefined` falls back to the
332
+ * global ladder for the families that historically had one.
333
+ */
334
+ export function effortsForModelVariants(baseModelId, metadata, fallbackEfforts = FALLBACK_REASONING_EFFORTS) {
335
+ const known = metadata && typeof metadata === 'object' ? metadata.supportedEfforts : undefined;
336
+ if (known === undefined) {
337
+ return isReasoningVariantEligibleModel(baseModelId) ? uniqueStringList(fallbackEfforts) : null;
338
+ }
339
+ if (!Array.isArray(known)) return null;
340
+ const list = uniqueStringList(known.map((value) => normalizeReasoningEffort(value)).filter(Boolean));
341
+ return list.length ? list : null;
342
+ }
343
+
344
+ export function normalizeModelVariantRow(row = {}) {
345
+ const rawBaseModelId = String(row?.base_model_id || row?.baseModelId || '').trim();
346
+ const baseModelId = canonicalizeModelId(rawBaseModelId) || rawBaseModelId;
347
+ const reasoningEffort = normalizeReasoningEffort(row?.reasoning_effort ?? row?.reasoningEffort);
348
+ const rawVariantId = String(row?.variant_id || row?.variantId || buildModelVariantId(baseModelId, reasoningEffort)).trim();
349
+ const parsed = parseModelVariantId(rawVariantId, { knownBaseModels: baseModelId ? [baseModelId] : [] });
350
+ const normalizedBaseModelId = canonicalizeModelId(parsed?.baseModelId || baseModelId) || baseModelId;
351
+ const normalizedReasoningEffort = normalizeReasoningEffort(parsed?.reasoningEffort || reasoningEffort);
352
+ const variantId = buildModelVariantId(normalizedBaseModelId, normalizedReasoningEffort);
353
+ const provider = String(row?.provider || row?.providerId || modelProviderForId(baseModelId)).trim() || 'other';
354
+ const label = String(row?.label || row?.displayName || modelDisplayLabel(normalizedBaseModelId)).trim() || normalizedBaseModelId;
355
+ const enabledValue = Number(row?.enabled);
356
+ const enabled = Number.isFinite(enabledValue) ? enabledValue === 1 : !!row?.enabled;
357
+ return {
358
+ variantId,
359
+ baseModelId: normalizedBaseModelId,
360
+ provider,
361
+ reasoningEffort: normalizedReasoningEffort,
362
+ label,
363
+ releaseStatus: String(row?.release_status || row?.releaseStatus || '').trim() || null,
364
+ contextLimitTokens: toPositiveInt(row?.context_limit_tokens ?? row?.contextLimitTokens),
365
+ longContextLimitTokens: toPositiveInt(row?.long_context_limit_tokens ?? row?.longContextLimitTokens),
366
+ pricing: (() => {
367
+ const value = row?.pricing_json ?? row?.pricing;
368
+ if (!value || typeof value === 'object') return value || null;
369
+ try { return JSON.parse(value); } catch { return null; }
370
+ })(),
371
+ metadata: parseStoredMetadata(row?.metadata_json ?? row?.metadata),
372
+ enabled,
373
+ sortOrder: Number.isFinite(Number(row?.sort_order ?? row?.sortOrder))
374
+ ? Math.max(0, Math.trunc(Number(row?.sort_order ?? row?.sortOrder)))
375
+ : 0,
376
+ updatedAt: row?.updated_at || row?.updatedAt || null,
377
+ };
378
+ }
379
+
380
+ /**
381
+ * Variant rows for a list of base models. `sortOrder` is the model's index
382
+ * in `baseModels` (all of a model's effort rows share it) unless
383
+ * `sortOrderByModel` supplies the canonical one; ingest recomputes it anyway.
384
+ */
385
+ export function buildModelVariantEntries(baseModels = [], {
386
+ defaultEnabled = true,
387
+ contextLimitsByModel = {},
388
+ modelMetadataByModel = {},
389
+ fallbackEfforts = FALLBACK_REASONING_EFFORTS,
390
+ sortOrderByModel = null,
391
+ } = {}) {
392
+ const models = uniqueStringList(baseModels);
393
+ const entries = [];
394
+ models.forEach((baseModelId, index) => {
395
+ const provider = modelProviderForId(baseModelId);
396
+ const metadata = modelMetadataByModel?.[baseModelId] || {};
397
+ // The runtime's display name travels in the metadata contract; the row
398
+ // label stays the composer's humanized id so existing pickers do not drift.
399
+ const label = modelDisplayLabel(baseModelId);
400
+ const normalizedContextLimitTokens = toPositiveInt(contextLimitsByModel?.[baseModelId])
401
+ ?? toPositiveInt(metadata.defaultContextLimitTokens);
402
+ const sortOrder = sortOrderByModel?.get?.(baseModelId) ?? sortOrderByModel?.[baseModelId] ?? index;
403
+ const efforts = effortsForModelVariants(baseModelId, metadata, fallbackEfforts);
404
+ for (const effort of efforts || [null]) {
405
+ entries.push({
406
+ variantId: buildModelVariantId(baseModelId, effort),
407
+ baseModelId,
408
+ provider,
409
+ label,
410
+ reasoningEffort: effort,
411
+ releaseStatus: null,
412
+ contextLimitTokens: normalizedContextLimitTokens,
413
+ longContextLimitTokens: toPositiveInt(metadata.longContextLimitTokens),
414
+ pricing: metadata.pricing || null,
415
+ metadata: Object.keys(metadata).length ? metadata : null,
416
+ enabled: defaultEnabled ? 1 : 0,
417
+ sortOrder,
418
+ });
419
+ }
420
+ });
421
+ return entries;
422
+ }
423
+
424
+ function hasValidReasoningByModel(reasoningByModel = {}, autoModelSentinel) {
425
+ if (!reasoningByModel || typeof reasoningByModel !== 'object') return false;
426
+ const modelIds = Object.keys(reasoningByModel).filter((modelId) => modelId !== autoModelSentinel);
427
+ if (!modelIds.length) return false;
428
+ return modelIds.every((modelId) => {
429
+ const efforts = reasoningByModel[modelId];
430
+ return Array.isArray(efforts) && efforts.length > 0;
431
+ });
432
+ }
433
+
434
+ export function parseModelsFromHelpConfigOutput(text) {
435
+ const content = String(text || '');
436
+ if (!content) return [];
437
+ const sectionMatch = content.match(/`model`:[\s\S]*?(?=\n\s*`[a-zA-Z][^`]*`:\s|$)/);
438
+ const section = sectionMatch ? sectionMatch[0] : content;
439
+ const models = [];
440
+ const regex = /"([^"]+)"/g;
441
+ let match;
442
+ while ((match = regex.exec(section))) {
443
+ const candidate = String(match[1] || '').trim();
444
+ if (!candidate || candidate === 'auto') continue;
445
+ if (!isValidModelId(candidate)) continue;
446
+ models.push(candidate);
447
+ }
448
+ return filterValidModelIds(uniqueStringList(models));
449
+ }
450
+
451
+ export function parseReasoningEffortsFromHelpOutput(text) {
452
+ const content = String(text || '');
453
+ if (!content) return FALLBACK_REASONING_EFFORTS.slice();
454
+ const values = [];
455
+ const regex = /"([a-z]+)"/g;
456
+ let match;
457
+ while ((match = regex.exec(content))) {
458
+ const effort = normalizeReasoningEffort(match[1]);
459
+ if (effort) values.push(effort);
460
+ }
461
+ const unique = uniqueStringList(values);
462
+ return unique.length ? unique : FALLBACK_REASONING_EFFORTS.slice();
463
+ }
464
+
465
+ const VARIANT_COLUMNS = 'variant_id, base_model_id, provider, label, release_status, reasoning_effort, context_limit_tokens, long_context_limit_tokens, pricing_json, metadata_json, enabled, sort_order, updated_at';
466
+
467
+ export function createModelVariantCatalogService({
468
+ defaultModel = 'gpt-5.4-mini',
469
+ curatedModelIds = [],
470
+ autoModelSentinel = 'auto',
471
+ // (args: string[]) => Promise<string>; the `copilot help` text fallback.
472
+ runCopilotCliCommand = async () => '',
473
+ } = {}) {
474
+ const DEFAULT_MODEL = String(defaultModel || 'gpt-5.4-mini').trim() || 'gpt-5.4-mini';
475
+ const AUTO_MODEL_SENTINEL = String(autoModelSentinel || 'auto');
476
+
477
+ let db = null;
478
+ let modelSelectorSql = null;
479
+ let modelCatalog = {
480
+ models: [DEFAULT_MODEL],
481
+ currentModel: DEFAULT_MODEL,
482
+ defaultModel: DEFAULT_MODEL,
483
+ source: 'bootstrap',
484
+ refreshedAt: null,
485
+ error: null,
486
+ };
487
+
488
+ function validatedModelIdList(values) {
489
+ return filterValidModelIds(uniqueStringList(values));
490
+ }
491
+
492
+ function curatedModelList() {
493
+ return uniqueStringList(curatedModelIds);
494
+ }
495
+
496
+ function compareVariantRows(a, b) {
497
+ return (a.sortOrder - b.sortOrder)
498
+ || a.baseModelId.localeCompare(b.baseModelId)
499
+ || (effortRank(a.reasoningEffort) - effortRank(b.reasoningEffort))
500
+ || a.variantId.localeCompare(b.variantId);
501
+ }
502
+
503
+ function listModelVariantRows() {
504
+ if (!modelSelectorSql?.listVariants?.all) return [];
505
+ return modelSelectorSql.listVariants.all().map((row) => normalizeModelVariantRow(row)).sort(compareVariantRows);
506
+ }
507
+
508
+ function listEnabledModelVariantRows() {
509
+ if (!modelSelectorSql?.listEnabledVariants?.all) return [];
510
+ return modelSelectorSql.listEnabledVariants.all().map((row) => normalizeModelVariantRow(row)).sort(compareVariantRows);
511
+ }
512
+
513
+ /** Stored metadata per base model (first row wins; all rows carry the same). */
514
+ function storedMetadataByModel(rows = listModelVariantRows()) {
515
+ const byModel = {};
516
+ for (const row of rows) {
517
+ if (!row.baseModelId || byModel[row.baseModelId]) continue;
518
+ if (row.metadata) byModel[row.baseModelId] = row.metadata;
519
+ }
520
+ return byModel;
521
+ }
522
+
523
+ function getModelContextLimitTokens(modelId = '') {
524
+ const normalizedModelId = canonicalizeModelId(modelId);
525
+ if (!normalizedModelId) return null;
526
+ const row = listModelVariantRows().find((entry) => entry.baseModelId === normalizedModelId
527
+ && entry.contextLimitTokens !== null
528
+ && entry.contextLimitTokens > 0);
529
+ return row?.contextLimitTokens || null;
530
+ }
531
+
532
+ function parseModelVariantSelection(value) {
533
+ const variantId = String(value || '').trim();
534
+ if (!variantId) return null;
535
+ const knownBaseModels = listModelVariantRows().map((row) => row.baseModelId);
536
+ const parsed = parseModelVariantId(variantId, { knownBaseModels });
537
+ if (!parsed) return null;
538
+ const match = listModelVariantRows().find((row) => row.variantId === parsed.variantId);
539
+ if (match) {
540
+ return {
541
+ variantId: match.variantId,
542
+ baseModelId: match.baseModelId,
543
+ reasoningEffort: match.reasoningEffort,
544
+ provider: match.provider,
545
+ label: match.label,
546
+ };
547
+ }
548
+ return parsed;
549
+ }
550
+
551
+ function getModelVariantSelectorState() {
552
+ const fallbackVariant = buildModelVariantId(DEFAULT_MODEL, isReasoningVariantEligibleModel(DEFAULT_MODEL) ? 'none' : null);
553
+ if (!modelSelectorSql?.listEnabledVariants?.all) {
554
+ const fallbackModels = buildModelVariantEntries(curatedModelList(), { defaultEnabled: true }).map((entry) => entry.variantId);
555
+ const models = fallbackModels.length ? fallbackModels : [fallbackVariant];
556
+ return {
557
+ models,
558
+ currentModel: models[0] || fallbackVariant,
559
+ defaultModel: models[0] || fallbackVariant,
560
+ source: 'bootstrap',
561
+ refreshedAt: null,
562
+ warning: null,
563
+ error: null,
564
+ };
565
+ }
566
+ const enabledRows = listEnabledModelVariantRows();
567
+ const selectorState = modelSelectorSql.getSelectorState.get() || null;
568
+ const enabledVariants = enabledRows.map((row) => row.variantId);
569
+ const models = enabledVariants.length ? enabledVariants : [fallbackVariant];
570
+ const warning = enabledVariants.length
571
+ ? null
572
+ : 'No model variants are enabled. Using fallback.';
573
+ return {
574
+ models,
575
+ currentModel: models[0] || fallbackVariant,
576
+ defaultModel: models[0] || fallbackVariant,
577
+ source: String(selectorState?.source || 'db').trim() || 'db',
578
+ refreshedAt: selectorState?.refreshed_at || null,
579
+ warning,
580
+ error: selectorState?.error ? String(selectorState.error) : null,
581
+ };
582
+ }
583
+
584
+ function getModelCatalogState() {
585
+ const selectorState = getModelVariantSelectorState();
586
+ const enabledRows = listEnabledModelVariantRows();
587
+ const modelRows = enabledRows.length ? enabledRows : listModelVariantRows().filter((row) => row.enabled);
588
+ const allRows = listModelVariantRows();
589
+ const reasoningByModel = {};
590
+ const contextLimitsByModel = {};
591
+ const modelMetadataByModel = {};
592
+ const unavailable = new Set();
593
+ const models = [];
594
+ const seenModels = new Set();
595
+ const collect = (row, { listed }) => {
596
+ const baseModelId = String(row?.baseModelId || '').trim();
597
+ if (!baseModelId) return;
598
+ if (listed && !seenModels.has(baseModelId)) {
599
+ seenModels.add(baseModelId);
600
+ models.push(baseModelId);
601
+ }
602
+ const effort = normalizeReasoningEffort(row?.reasoningEffort || 'none') || 'none';
603
+ const current = reasoningByModel[baseModelId] || [];
604
+ if (!current.includes(effort)) current.push(effort);
605
+ reasoningByModel[baseModelId] = current;
606
+ if (row.contextLimitTokens !== null && row.contextLimitTokens > 0) {
607
+ contextLimitsByModel[baseModelId] = row.contextLimitTokens;
608
+ }
609
+ if (!modelMetadataByModel[baseModelId]) {
610
+ modelMetadataByModel[baseModelId] = toContractMetadata(row.metadata, {
611
+ defaultContextLimitTokens: row.contextLimitTokens,
612
+ longContextLimitTokens: row.longContextLimitTokens,
613
+ pricing: row.pricing,
614
+ catalogIndex: row.sortOrder,
615
+ });
616
+ }
617
+ if (row.releaseStatus === 'unavailable') unavailable.add(baseModelId);
618
+ };
619
+ // Enabled rows (in canonical order) define the picker list; every row
620
+ // contributes to the effort map so a disabled variant is still resolvable.
621
+ for (const row of modelRows) collect(row, { listed: true });
622
+ for (const row of allRows) collect(row, { listed: false });
623
+ for (const modelId of Object.keys(reasoningByModel)) {
624
+ // The runtime's own effort list wins over what the rows happen to hold.
625
+ const known = modelMetadataByModel[modelId]?.supportedEfforts;
626
+ const efforts = Array.isArray(known) && known.length
627
+ ? known.map((value) => normalizeReasoningEffort(value)).filter(Boolean)
628
+ : (reasoningByModel[modelId] || []).map((value) => normalizeReasoningEffort(value)).filter(Boolean);
629
+ reasoningByModel[modelId] = uniqueStringList(efforts);
630
+ }
631
+ const autoEfforts = uniqueStringList(
632
+ Object.entries(reasoningByModel)
633
+ .filter(([modelId]) => modelId !== AUTO_MODEL_SENTINEL)
634
+ .flatMap(([, list]) => Array.isArray(list) ? list : [])
635
+ .map((value) => normalizeReasoningEffort(value))
636
+ .filter(Boolean),
637
+ );
638
+ if (autoEfforts.length) {
639
+ reasoningByModel[AUTO_MODEL_SENTINEL] = autoEfforts;
640
+ } else {
641
+ delete reasoningByModel[AUTO_MODEL_SENTINEL];
642
+ }
643
+ const catalogModels = [AUTO_MODEL_SENTINEL, ...models.filter((value) => value.toLowerCase() !== AUTO_MODEL_SENTINEL)];
644
+ const currentResolved = parseModelVariantSelection(selectorState.currentModel);
645
+ const defaultResolved = parseModelVariantSelection(selectorState.defaultModel);
646
+ const currentModel = String(currentResolved?.baseModelId || selectorState.currentModel || '').trim() || catalogModels[0] || DEFAULT_MODEL;
647
+ const defaultModelId = String(defaultResolved?.baseModelId || selectorState.defaultModel || '').trim() || currentModel || catalogModels[0] || DEFAULT_MODEL;
648
+ const reasoningMetadataValid = hasValidReasoningByModel(reasoningByModel, AUTO_MODEL_SENTINEL);
649
+ const inMemoryRefresh = modelCatalog.refreshedAt || null;
650
+ const refreshedAt = latestModelCatalogRefresh(selectorState.refreshedAt, inMemoryRefresh);
651
+ const metadataError = !reasoningMetadataValid || !!selectorState.error || modelRows.length === 0;
652
+ const stale = metadataError;
653
+ const metadataValid = !metadataError;
654
+ const warning = selectorState.warning || null;
655
+ const reasoningEfforts = uniqueStringList(
656
+ Object.values(reasoningByModel)
657
+ .flatMap((list) => Array.isArray(list) ? list : [])
658
+ .map((value) => normalizeReasoningEffort(value))
659
+ .filter(Boolean),
660
+ );
661
+ return {
662
+ models: catalogModels,
663
+ currentModel,
664
+ defaultModel: defaultModelId,
665
+ source: selectorState.source,
666
+ refreshedAt,
667
+ stale,
668
+ metadataValid,
669
+ reasoningMetadataValid,
670
+ warning,
671
+ error: selectorState.error,
672
+ reasoningByModel,
673
+ reasoningEfforts,
674
+ contextLimitsByModel,
675
+ modelMetadataByModel,
676
+ // Models the last authoritative snapshot no longer listed but whose
677
+ // enabled variants stay selectable; pickers use this to grey them out.
678
+ unavailableModels: [...unavailable].filter((modelId) => seenModels.has(modelId)),
679
+ };
680
+ }
681
+
682
+ function touchModelSelectorState({
683
+ source = 'snapshot',
684
+ error = null,
685
+ refreshedAt = new Date().toISOString(),
686
+ } = {}) {
687
+ if (!modelSelectorSql?.upsertSelectorState?.run) return;
688
+ const timestamp = latestModelCatalogRefresh(refreshedAt) || new Date().toISOString();
689
+ modelSelectorSql.upsertSelectorState.run(
690
+ String(source || 'snapshot').trim() || 'snapshot',
691
+ timestamp,
692
+ error ? String(error).trim().slice(0, 300) : null,
693
+ timestamp,
694
+ );
695
+ }
696
+
697
+ function runVariantUpsert(entry, enabled, nowIso) {
698
+ modelSelectorSql.upsertVariant.run(
699
+ entry.variantId,
700
+ entry.baseModelId,
701
+ entry.provider || modelProviderForId(entry.baseModelId),
702
+ entry.label || modelDisplayLabel(entry.baseModelId),
703
+ entry.releaseStatus || null,
704
+ entry.reasoningEffort || null,
705
+ entry.contextLimitTokens || null,
706
+ entry.longContextLimitTokens || null,
707
+ entry.pricing ? JSON.stringify(entry.pricing) : null,
708
+ serializeModelMetadata(entry.metadata),
709
+ enabled ? 1 : 0,
710
+ entry.sortOrder,
711
+ nowIso,
712
+ );
713
+ }
714
+
715
+ /**
716
+ * sort_order := the model's index in the canonical Copilot order, over
717
+ * EVERY row in the table. Called at the end of each write path so the
718
+ * number never depends on ingest batches.
719
+ */
720
+ function recomputeSortOrder() {
721
+ if (!modelSelectorSql?.updateSortOrderForBase?.run) return;
722
+ const rows = listModelVariantRows();
723
+ const descriptors = [];
724
+ const seen = new Set();
725
+ for (const row of rows) {
726
+ if (!row.baseModelId || seen.has(row.baseModelId)) continue;
727
+ seen.add(row.baseModelId);
728
+ const metadata = row.metadata || {};
729
+ descriptors.push({
730
+ modelId: row.baseModelId,
731
+ vendor: metadata.vendor || inferCopilotVendor(row.baseModelId),
732
+ pickerCategory: metadata.pickerCategory || null,
733
+ preview: metadata.preview === true,
734
+ displayName: metadata.displayName || row.label || row.baseModelId,
735
+ });
736
+ }
737
+ const indexByModel = copilotCatalogIndexByModel(descriptors);
738
+ const apply = () => {
739
+ for (const [baseModelId, index] of indexByModel) {
740
+ modelSelectorSql.updateSortOrderForBase.run(index, baseModelId);
741
+ }
742
+ };
743
+ if (db?.transaction) db.transaction(apply)();
744
+ else apply();
745
+ }
746
+
747
+ /**
748
+ * Write a set of variant rows and reconcile the rest of the table against
749
+ * it. A base model present in `entries` owns exactly those rows when its
750
+ * efforts are known (stale effort rows are dropped, an enabled one hands its
751
+ * flag to the model's remaining rows); a base model absent from `entries`
752
+ * follows `absentBasePolicy`: 'reconcile' (enabled rows → unavailable,
753
+ * disabled rows pruned) or 'ignore' (left untouched).
754
+ */
755
+ function reconcileModelVariantRows(entries = [], {
756
+ source = 'manual-refresh',
757
+ error = null,
758
+ preserveEnabled = true,
759
+ absentBasePolicy = 'reconcile',
760
+ knownEffortBases = new Set(),
761
+ touchSelectorState = true,
762
+ } = {}) {
763
+ if (!modelSelectorSql?.upsertVariant?.run || !modelSelectorSql?.upsertSelectorState?.run) {
764
+ return getModelVariantSelectorState();
765
+ }
766
+ const normalizedEntries = Array.isArray(entries)
767
+ ? entries.map((entry) => ({ ...normalizeModelVariantRow(entry), metadata: entry?.metadata || null }))
768
+ .filter((entry) => entry.variantId && entry.baseModelId)
769
+ : [];
770
+ const existingRows = listModelVariantRows();
771
+ const existingEnabled = new Map(existingRows.map((row) => [row.variantId, row.enabled ? 1 : 0]));
772
+ const incomingIds = new Set(normalizedEntries.map((entry) => entry.variantId));
773
+ const incomingBaseIds = new Set(normalizedEntries.map((entry) => entry.baseModelId));
774
+ const nowIso = new Date().toISOString();
775
+ const tx = db.transaction(() => {
776
+ for (const entry of normalizedEntries) {
777
+ const enabled = preserveEnabled && existingEnabled.has(entry.variantId)
778
+ ? existingEnabled.get(entry.variantId)
779
+ : (entry.enabled ? 1 : 0);
780
+ runVariantUpsert(entry, enabled, nowIso);
781
+ }
782
+ const orphanedEnabledBases = new Set();
783
+ for (const row of existingRows) {
784
+ if (incomingIds.has(row.variantId)) continue;
785
+ if (incomingBaseIds.has(row.baseModelId)) {
786
+ if (knownEffortBases.has(row.baseModelId)) {
787
+ // The runtime told us this model's exact levels; a row for a
788
+ // level it does not offer is stale, not "unavailable".
789
+ if (row.enabled) orphanedEnabledBases.add(row.baseModelId);
790
+ modelSelectorSql.deleteVariant.run(row.variantId);
791
+ } else {
792
+ runVariantUpsert({ ...row, releaseStatus: 'unavailable' }, row.enabled, nowIso);
793
+ }
794
+ continue;
795
+ }
796
+ if (absentBasePolicy === 'ignore') continue;
797
+ if (row.enabled) {
798
+ runVariantUpsert({ ...row, releaseStatus: 'unavailable' }, 1, nowIso);
799
+ continue;
800
+ }
801
+ // Variants that are both disabled and fully absent from the incoming
802
+ // base set are no longer relevant; prune them so they do not linger.
803
+ modelSelectorSql.deleteVariant.run(row.variantId);
804
+ }
805
+ // A selection must not silently vanish because its effort level did:
806
+ // when the deletions left a model with no enabled row, its first
807
+ // remaining row (the runtime's lowest level, or the effort-less row)
808
+ // inherits the selection.
809
+ for (const baseModelId of orphanedEnabledBases) {
810
+ const remaining = listModelVariantRows().filter((row) => row.baseModelId === baseModelId);
811
+ if (!remaining.length || remaining.some((row) => row.enabled)) continue;
812
+ modelSelectorSql.enableVariant.run(nowIso, remaining[0].variantId);
813
+ }
814
+ if (touchSelectorState) {
815
+ modelSelectorSql.upsertSelectorState.run(
816
+ String(source || 'manual-refresh').trim() || 'manual-refresh',
817
+ nowIso,
818
+ error ? String(error).trim().slice(0, 300) : null,
819
+ nowIso,
820
+ );
821
+ }
822
+ });
823
+ tx();
824
+ recomputeSortOrder();
825
+ return getModelVariantSelectorState();
826
+ }
827
+
828
+ function upsertModelVariantCatalogEntries(entries = [], {
829
+ source = 'manual-refresh',
830
+ error = null,
831
+ preserveEnabled = true,
832
+ } = {}) {
833
+ const knownEffortBases = new Set(
834
+ (Array.isArray(entries) ? entries : [])
835
+ .filter((entry) => entry?.metadata && entry.metadata.supportedEfforts !== undefined)
836
+ .map((entry) => canonicalizeModelId(entry.baseModelId) || String(entry.baseModelId || '').trim()),
837
+ );
838
+ return reconcileModelVariantRows(entries, { source, error, preserveEnabled, absentBasePolicy: 'reconcile', knownEffortBases });
839
+ }
840
+
841
+ function setEnabledModelVariants(variantIds = []) {
842
+ if (!modelSelectorSql?.disableAllVariants?.run || !modelSelectorSql?.enableVariant?.run) {
843
+ return getModelVariantSelectorState();
844
+ }
845
+ const nextEnabled = new Set(
846
+ Array.isArray(variantIds) ? variantIds.map((value) => String(value || '').trim()).filter(Boolean) : [],
847
+ );
848
+ const existingRows = listModelVariantRows();
849
+ const nowIso = new Date().toISOString();
850
+ const tx = db.transaction(() => {
851
+ modelSelectorSql.disableAllVariants.run(nowIso);
852
+ for (const row of existingRows) {
853
+ if (!nextEnabled.has(row.variantId)) continue;
854
+ modelSelectorSql.enableVariant.run(nowIso, row.variantId);
855
+ }
856
+ });
857
+ tx();
858
+ return getModelVariantSelectorState();
859
+ }
860
+
861
+ function updateModelCatalog(snapshot = {}) {
862
+ const incomingModels = validatedModelIdList(Array.isArray(snapshot.models) ? snapshot.models : []);
863
+ const incomingCurrentRaw = String(snapshot.currentModel || '').trim();
864
+ const incomingDefaultRaw = String(snapshot.defaultModel || '').trim();
865
+ const incomingCurrent = isValidModelId(incomingCurrentRaw) ? incomingCurrentRaw : '';
866
+ const incomingDefault = isValidModelId(incomingDefaultRaw) ? incomingDefaultRaw : '';
867
+ const contextLimitsByModel = normalizeContextLimitsByModel(snapshot.contextLimitsByModel);
868
+ const modelMetadataByModel = normalizeModelMetadataByModel(snapshot.modelMetadataByModel);
869
+ const receivedMetadata = incomingModels.length > 0 || Object.keys(contextLimitsByModel).length > 0 || Object.keys(modelMetadataByModel).length > 0;
870
+ const source = String(snapshot.source || modelCatalog.source || 'unknown').trim() || 'unknown';
871
+ // A snapshot from something that read the runtime's real list is the
872
+ // whole catalog: curated ids and earlier in-memory lists do not get to
873
+ // resurrect models the runtime no longer serves.
874
+ const authoritative = AUTHORITATIVE_SNAPSHOT_SOURCE_RE.test(source) && incomingModels.length > 0;
875
+ const merged = validatedModelIdList(authoritative
876
+ ? [...incomingModels, incomingCurrent, incomingDefault]
877
+ : [
878
+ ...curatedModelList(),
879
+ ...incomingModels,
880
+ incomingCurrent,
881
+ incomingDefault,
882
+ ...(Array.isArray(modelCatalog.models) ? modelCatalog.models : []),
883
+ modelCatalog.currentModel,
884
+ modelCatalog.defaultModel,
885
+ ]);
886
+ if (!merged.length) merged.push(DEFAULT_MODEL);
887
+ const currentModel = incomingCurrent || modelCatalog.currentModel || incomingDefault || merged[0] || DEFAULT_MODEL;
888
+ const defaultModelId = incomingDefault || modelCatalog.defaultModel || currentModel || DEFAULT_MODEL;
889
+ const models = uniqueStringList([currentModel, defaultModelId, ...merged]);
890
+
891
+ modelCatalog = {
892
+ models,
893
+ currentModel,
894
+ defaultModel: defaultModelId,
895
+ source,
896
+ refreshedAt: receivedMetadata ? new Date().toISOString() : modelCatalog.refreshedAt,
897
+ error: snapshot.error ? String(snapshot.error).trim().slice(0, 300) : null,
898
+ };
899
+ if (modelSelectorSql?.upsertVariant?.run) {
900
+ const existingRows = listModelVariantRows();
901
+ const stored = storedMetadataByModel(existingRows);
902
+ const mergedMetadataByModel = { ...stored };
903
+ for (const [modelId, metadata] of Object.entries(modelMetadataByModel)) {
904
+ mergedMetadataByModel[modelId] = mergeModelMetadata(stored[modelId], metadata);
905
+ }
906
+ const nowIso = new Date().toISOString();
907
+ if (authoritative) {
908
+ const entries = buildModelVariantEntries(incomingModels, {
909
+ defaultEnabled: false,
910
+ contextLimitsByModel,
911
+ modelMetadataByModel: mergedMetadataByModel,
912
+ });
913
+ reconcileModelVariantRows(entries, {
914
+ source: modelCatalog.source,
915
+ error: modelCatalog.error || null,
916
+ preserveEnabled: true,
917
+ absentBasePolicy: 'reconcile',
918
+ knownEffortBases: new Set(
919
+ incomingModels.filter((modelId) => mergedMetadataByModel[modelId]?.supportedEfforts !== undefined),
920
+ ),
921
+ // updateModelCatalog stamps the selector state itself below.
922
+ touchSelectorState: false,
923
+ });
924
+ } else {
925
+ const existingBaseIds = new Set(existingRows.map((r) => r.baseModelId));
926
+ const newBaseIds = models.filter((id) => !existingBaseIds.has(id));
927
+ if (newBaseIds.length) {
928
+ const newEntries = buildModelVariantEntries(newBaseIds, {
929
+ defaultEnabled: false,
930
+ contextLimitsByModel,
931
+ modelMetadataByModel: mergedMetadataByModel,
932
+ });
933
+ reconcileModelVariantRows(newEntries, {
934
+ source: modelCatalog.source,
935
+ error: modelCatalog.error || null,
936
+ preserveEnabled: true,
937
+ absentBasePolicy: 'ignore',
938
+ touchSelectorState: false,
939
+ });
940
+ }
941
+ }
942
+ for (const [modelId, contextLimitTokens] of Object.entries(contextLimitsByModel)) {
943
+ modelSelectorSql.updateContextLimitForBase.run(contextLimitTokens, nowIso, modelId);
944
+ }
945
+ for (const [modelId, metadata] of Object.entries(modelMetadataByModel)) {
946
+ modelSelectorSql.updateModelMetadataForBase.run(
947
+ metadata.defaultContextLimitTokens,
948
+ metadata.longContextLimitTokens,
949
+ metadata.pricing ? JSON.stringify(metadata.pricing) : null,
950
+ serializeModelMetadata(mergedMetadataByModel[modelId]),
951
+ nowIso,
952
+ modelId,
953
+ );
954
+ }
955
+ if (Object.keys(modelMetadataByModel).length) recomputeSortOrder();
956
+ }
957
+ if (receivedMetadata && modelSelectorSql?.upsertSelectorState?.run) {
958
+ touchModelSelectorState({
959
+ source: modelCatalog.source,
960
+ error: modelCatalog.error,
961
+ refreshedAt: modelCatalog.refreshedAt,
962
+ });
963
+ }
964
+ return getModelCatalogState();
965
+ }
966
+
967
+ async function refreshModelVariantCatalogFromCli() {
968
+ let source = 'rpc-snapshot';
969
+ let modelIds = [];
970
+ let reasoningEfforts = FALLBACK_REASONING_EFFORTS.slice();
971
+ // All four publishers read the runtime's real model list, so any of their
972
+ // snapshots outranks the CLI help-text fallback below.
973
+ const hasAuthoritativeSnapshot = AUTHORITATIVE_SNAPSHOT_SOURCE_RE.test(String(modelCatalog.source || ''));
974
+ const refreshSelectionFromSnapshot = selectModelIdsForVariantRefresh({
975
+ snapshotModels: hasAuthoritativeSnapshot && Array.isArray(modelCatalog.models) ? modelCatalog.models : [],
976
+ currentModel: modelCatalog.currentModel,
977
+ defaultModel: modelCatalog.defaultModel,
978
+ helpModelIds: [],
979
+ });
980
+ source = refreshSelectionFromSnapshot.source;
981
+ modelIds = refreshSelectionFromSnapshot.modelIds;
982
+ const genericHelpText = await runCopilotCliCommand(['help']).catch(() => '');
983
+ reasoningEfforts = parseReasoningEffortsFromHelpOutput(genericHelpText);
984
+ if (!modelIds.length) {
985
+ const configHelpText = await runCopilotCliCommand(['help', 'config']);
986
+ const helpModelIds = parseModelsFromHelpConfigOutput(configHelpText);
987
+ const refreshSelectionFromHelp = selectModelIdsForVariantRefresh({
988
+ snapshotModels: [],
989
+ currentModel: '',
990
+ defaultModel: '',
991
+ helpModelIds,
992
+ });
993
+ source = refreshSelectionFromHelp.source;
994
+ modelIds = refreshSelectionFromHelp.modelIds;
995
+ }
996
+ // A refresh re-derives rows from what the table already knows about each
997
+ // model: the runtime-reported efforts, context limits and metadata. The
998
+ // help-text ladder only covers models nothing has described.
999
+ const rows = listModelVariantRows();
1000
+ const contextLimitsByModel = {};
1001
+ for (const row of rows) {
1002
+ if (row.contextLimitTokens && !contextLimitsByModel[row.baseModelId]) {
1003
+ contextLimitsByModel[row.baseModelId] = row.contextLimitTokens;
1004
+ }
1005
+ }
1006
+ const entries = buildModelVariantEntries(modelIds, {
1007
+ defaultEnabled: false,
1008
+ contextLimitsByModel,
1009
+ modelMetadataByModel: storedMetadataByModel(rows),
1010
+ fallbackEfforts: reasoningEfforts,
1011
+ });
1012
+ if (!entries.length) {
1013
+ throw new Error('No models found in snapshot/help model output');
1014
+ }
1015
+ return upsertModelVariantCatalogEntries(entries, {
1016
+ source,
1017
+ error: null,
1018
+ preserveEnabled: true,
1019
+ });
1020
+ }
1021
+
1022
+ /**
1023
+ * Attach the database: prepare statements, seed an empty table with the
1024
+ * curated ids, normalize legacy variant ids, and number the rows
1025
+ * canonically. Mirrors the runtime's original post-schema block.
1026
+ */
1027
+ function bindDatabase(database) {
1028
+ db = database;
1029
+ modelSelectorSql = {
1030
+ listVariants: db.prepare(`
1031
+ SELECT ${VARIANT_COLUMNS}
1032
+ FROM model_variants
1033
+ ORDER BY sort_order ASC, base_model_id ASC, variant_id ASC
1034
+ `),
1035
+ listEnabledVariants: db.prepare(`
1036
+ SELECT ${VARIANT_COLUMNS}
1037
+ FROM model_variants
1038
+ WHERE enabled = 1
1039
+ ORDER BY sort_order ASC, base_model_id ASC, variant_id ASC
1040
+ `),
1041
+ upsertVariant: db.prepare(`
1042
+ INSERT INTO model_variants (
1043
+ ${VARIANT_COLUMNS}
1044
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1045
+ ON CONFLICT(variant_id) DO UPDATE SET
1046
+ base_model_id = excluded.base_model_id,
1047
+ provider = excluded.provider,
1048
+ label = excluded.label,
1049
+ release_status = excluded.release_status,
1050
+ reasoning_effort = excluded.reasoning_effort,
1051
+ context_limit_tokens = COALESCE(excluded.context_limit_tokens, model_variants.context_limit_tokens),
1052
+ long_context_limit_tokens = COALESCE(excluded.long_context_limit_tokens, model_variants.long_context_limit_tokens),
1053
+ pricing_json = COALESCE(excluded.pricing_json, model_variants.pricing_json),
1054
+ metadata_json = COALESCE(excluded.metadata_json, model_variants.metadata_json),
1055
+ enabled = excluded.enabled,
1056
+ sort_order = excluded.sort_order,
1057
+ updated_at = excluded.updated_at
1058
+ `),
1059
+ updateContextLimitForBase: db.prepare(`
1060
+ UPDATE model_variants
1061
+ SET context_limit_tokens = ?, updated_at = ?
1062
+ WHERE base_model_id = ?
1063
+ `),
1064
+ updateModelMetadataForBase: db.prepare(`
1065
+ UPDATE model_variants
1066
+ SET context_limit_tokens = COALESCE(?, context_limit_tokens),
1067
+ long_context_limit_tokens = COALESCE(?, long_context_limit_tokens),
1068
+ pricing_json = COALESCE(?, pricing_json),
1069
+ metadata_json = COALESCE(?, metadata_json),
1070
+ updated_at = ?
1071
+ WHERE base_model_id = ?
1072
+ `),
1073
+ updateSortOrderForBase: db.prepare(`UPDATE model_variants SET sort_order = ? WHERE base_model_id = ?`),
1074
+ disableAllVariants: db.prepare(`UPDATE model_variants SET enabled = 0, updated_at = ?`),
1075
+ enableVariant: db.prepare(`UPDATE model_variants SET enabled = 1, updated_at = ? WHERE variant_id = ?`),
1076
+ deleteVariant: db.prepare(`DELETE FROM model_variants WHERE variant_id = ?`),
1077
+ getSelectorState: db.prepare(`SELECT source, refreshed_at, error, updated_at FROM model_selector_state WHERE id = 1 LIMIT 1`),
1078
+ upsertSelectorState: db.prepare(`
1079
+ INSERT INTO model_selector_state (id, source, refreshed_at, error, updated_at)
1080
+ VALUES (1, ?, ?, ?, ?)
1081
+ ON CONFLICT(id) DO UPDATE SET
1082
+ source = excluded.source,
1083
+ refreshed_at = excluded.refreshed_at,
1084
+ error = excluded.error,
1085
+ updated_at = excluded.updated_at
1086
+ `),
1087
+ };
1088
+
1089
+ const existingCount = Number(db.prepare(`SELECT COUNT(*) AS cnt FROM model_variants`).get()?.cnt || 0);
1090
+ const normalizeLegacyVariantIdsTx = db.transaction(() => {
1091
+ const rows = modelSelectorSql.listVariants.all();
1092
+ const canonicalRows = new Map();
1093
+ for (const rawRow of rows) {
1094
+ const row = normalizeModelVariantRow(rawRow);
1095
+ const canonicalBaseModelId = canonicalizeModelId(row.baseModelId);
1096
+ if (!canonicalBaseModelId) continue;
1097
+ const canonicalVariantId = buildModelVariantId(canonicalBaseModelId, row.reasoningEffort);
1098
+ const existing = canonicalRows.get(canonicalVariantId);
1099
+ if (!existing) {
1100
+ canonicalRows.set(canonicalVariantId, {
1101
+ variantId: canonicalVariantId,
1102
+ baseModelId: canonicalBaseModelId,
1103
+ provider: row.provider || modelProviderForId(canonicalBaseModelId),
1104
+ label: row.label || modelDisplayLabel(canonicalBaseModelId),
1105
+ releaseStatus: row.releaseStatus || null,
1106
+ reasoningEffort: row.reasoningEffort || null,
1107
+ contextLimitTokens: row.contextLimitTokens,
1108
+ longContextLimitTokens: row.longContextLimitTokens,
1109
+ pricing: row.pricing,
1110
+ metadata: row.metadata,
1111
+ enabled: row.enabled ? 1 : 0,
1112
+ sortOrder: row.sortOrder,
1113
+ updatedAt: row.updatedAt,
1114
+ });
1115
+ continue;
1116
+ }
1117
+ existing.enabled = existing.enabled || (row.enabled ? 1 : 0) ? 1 : 0;
1118
+ if (existing.releaseStatus !== null && row.releaseStatus === null) {
1119
+ existing.releaseStatus = null;
1120
+ }
1121
+ existing.sortOrder = Math.min(existing.sortOrder, row.sortOrder);
1122
+ if (!existing.label && row.label) existing.label = row.label;
1123
+ }
1124
+
1125
+ const nowIso = new Date().toISOString();
1126
+ for (const entry of canonicalRows.values()) {
1127
+ runVariantUpsert(entry, entry.enabled, nowIso);
1128
+ }
1129
+ for (const rawRow of rows) {
1130
+ const rawVariantId = String(rawRow?.variant_id || '').trim();
1131
+ const rawBaseModelId = String(rawRow?.base_model_id || '').trim();
1132
+ const canonicalBaseModelId = canonicalizeModelId(rawBaseModelId);
1133
+ if (!canonicalBaseModelId) continue;
1134
+ const canonicalReasoningEffort = normalizeReasoningEffort(rawRow?.reasoning_effort);
1135
+ const canonicalVariantId = buildModelVariantId(canonicalBaseModelId, canonicalReasoningEffort);
1136
+ if (rawVariantId !== canonicalVariantId) {
1137
+ modelSelectorSql.deleteVariant.run(rawVariantId);
1138
+ }
1139
+ }
1140
+ });
1141
+ if (existingCount === 0) {
1142
+ const nowIso = new Date().toISOString();
1143
+ const seedEntries = buildModelVariantEntries(curatedModelList(), { defaultEnabled: true });
1144
+ const tx = db.transaction(() => {
1145
+ for (const entry of seedEntries) runVariantUpsert(entry, entry.enabled, nowIso);
1146
+ modelSelectorSql.upsertSelectorState.run('bootstrap-seed', nowIso, null, nowIso);
1147
+ });
1148
+ tx();
1149
+ } else if (!modelSelectorSql.getSelectorState.get()) {
1150
+ const nowIso = new Date().toISOString();
1151
+ modelSelectorSql.upsertSelectorState.run('legacy', nowIso, null, nowIso);
1152
+ }
1153
+ normalizeLegacyVariantIdsTx();
1154
+ recomputeSortOrder();
1155
+ }
1156
+
1157
+ return {
1158
+ SUPPORTED_REASONING_EFFORTS,
1159
+ FALLBACK_REASONING_EFFORTS,
1160
+ bindDatabase,
1161
+ normalizeReasoningEffort,
1162
+ buildModelVariantId,
1163
+ parseModelVariantId,
1164
+ normalizeModelVariantRow,
1165
+ buildModelVariantEntries,
1166
+ normalizeModelMetadataByModel,
1167
+ normalizeContextLimitsByModel,
1168
+ getModelCatalogState,
1169
+ updateModelCatalog,
1170
+ listModelVariantRows,
1171
+ listEnabledModelVariantRows,
1172
+ getModelContextLimitTokens,
1173
+ parseModelVariantSelection,
1174
+ getModelVariantSelectorState,
1175
+ upsertModelVariantCatalogEntries,
1176
+ setEnabledModelVariants,
1177
+ refreshModelVariantCatalogFromCli,
1178
+ parseModelsFromHelpConfigOutput,
1179
+ parseReasoningEffortsFromHelpOutput,
1180
+ /** The in-memory snapshot state (read-only view for the runtime). */
1181
+ get modelCatalog() { return modelCatalog; },
1182
+ };
1183
+ }