@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.
- package/.github/extensions/web-relay/extension.mjs +0 -3
- package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
- package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
- package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
- package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
- package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
- package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
- package/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +142 -11
- package/package.json +8 -4
- package/server/README.md +59 -22
- package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
- package/server/claude-worker/claude-session-process.mjs +1013 -45
- package/server/claude-worker/claude-session-worker.mjs +54 -9
- package/server/claude-worker/claude-turn-publisher.mjs +110 -19
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-sdk-runtime.mjs +12 -3
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-attachments.mjs +35 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-model-switch.mjs +425 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
- package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
- package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
- package/server/cursor-worker/cursor-session-worker.mjs +5 -5
- package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
- package/server/db-schema.mjs +61 -1
- package/server/features.mjs +155 -41
- package/server/grok-worker/grok-session-worker.mjs +5 -5
- package/server/grok-worker/grok-turn-runner.mjs +45 -4
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/annotate-core.mjs +181 -0
- package/server/public/app/annotate-editor.js +617 -0
- package/server/public/app/api-client.js +86 -5
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +172 -3
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +34 -19
- package/server/public/app/bootstrap.js +153 -69
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/context-tier-options.mjs +24 -4
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-preferences.mjs +47 -10
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1032 -218
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +190 -24
- package/server/public/app/model-selector-options.mjs +185 -1
- package/server/public/app/new-conversation-model-choice.mjs +29 -9
- package/server/public/app/pwa-install.js +64 -136
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/reasoning-effort-labels.mjs +13 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
- package/server/public/app/router.js +120 -11
- package/server/public/app/settings-modal.js +225 -20
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +47 -4
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +159 -16
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/app/update-ui.js +194 -0
- package/server/public/index.html +483 -24
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/relay.mjs +23 -21
- package/server/repositories/message-repository.mjs +78 -21
- package/server/repositories/question-repository.mjs +19 -1
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/ask-user-routes.mjs +10 -0
- package/server/routes/messages-routes.mjs +689 -159
- package/server/routes/sessions-routes.mjs +574 -80
- package/server/server-runtime.mjs +393 -925
- package/server/services/claude-auth-service.mjs +7 -1
- package/server/services/cli-install-service.mjs +8 -27
- package/server/services/cli-process-runner.mjs +30 -0
- package/server/services/copilot-model-discovery-service.mjs +202 -0
- package/server/services/data-dir-resolution.mjs +47 -0
- package/server/services/grok-auth-service.mjs +9 -1
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/model-variant-catalog-service.mjs +1183 -0
- package/server/services/oar-cli-helpers.mjs +6 -2
- package/server/services/relay-prompt-sanitizer.mjs +30 -3
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +144 -16
- package/server/services/sdk-session-sync-service.mjs +84 -27
- package/server/services/session-worker-registry-service.mjs +116 -3
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +119 -24
- package/server/services/status-event-service.mjs +8 -2
- package/server/services/steer-resend.mjs +45 -0
- package/server/services/update-check-service.mjs +220 -0
- package/server/services/update-install-service.mjs +279 -0
- package/server/services/windows-autostart-service.mjs +42 -6
- package/server/services/windows-boot-autostart-service.mjs +487 -0
- package/shared/ask-user-bridge.mjs +37 -5
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/claude-model-catalog.mjs +49 -0
- package/shared/context-window-fallbacks.mjs +1 -0
- package/shared/control-poller.mjs +4 -1
- package/shared/copilot-model-order.mjs +182 -0
- package/shared/media-embed-instructions.mjs +35 -0
- package/shared/model-descriptors.mjs +181 -30
- package/shared/model-id.mjs +6 -0
- package/shared/pwa-app-name.mjs +44 -0
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/update-semver.mjs +50 -0
- package/shared/worker-crash-guard.mjs +40 -14
- package/shared/worker-runtime/api-client.mjs +34 -0
- package/shared/worker-runtime/config-loader.mjs +104 -0
- package/shared/worker-runtime/heartbeat.mjs +109 -0
- package/shared/worker-runtime/prompt-context.mjs +160 -0
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
- package/server/claude-worker/claude-session-test-harness.mjs +0 -236
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.sh +0 -62
- package/server/services/fixtures/cli-install-stub.sh +0 -75
- 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
|
+
}
|