@luckydraw/cumulus 1.0.10 → 1.0.12
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/CHANGELOG.md +16 -0
- package/dist/gateway/adapters/webchat.d.ts +1 -1
- package/dist/gateway/adapters/webchat.d.ts.map +1 -1
- package/dist/gateway/adapters/webchat.js +31 -1
- package/dist/gateway/adapters/webchat.js.map +1 -1
- package/dist/gateway/config.d.ts +75 -2
- package/dist/gateway/config.d.ts.map +1 -1
- package/dist/gateway/config.js +101 -5
- package/dist/gateway/config.js.map +1 -1
- package/dist/gateway/daemon.js +16 -0
- package/dist/gateway/daemon.js.map +1 -1
- package/dist/gateway/hooks.d.ts +1 -0
- package/dist/gateway/hooks.d.ts.map +1 -1
- package/dist/gateway/hooks.js +1 -0
- package/dist/gateway/hooks.js.map +1 -1
- package/dist/gateway/server.d.ts +3 -0
- package/dist/gateway/server.d.ts.map +1 -1
- package/dist/gateway/server.js +76 -6
- package/dist/gateway/server.js.map +1 -1
- package/dist/gateway/static/widget.js +423 -54
- package/dist/lib/gateway.d.ts +35 -2
- package/dist/lib/gateway.d.ts.map +1 -1
- package/dist/lib/gateway.js +56 -4
- package/dist/lib/gateway.js.map +1 -1
- package/dist/lib/huggingface-provider.d.ts +12 -0
- package/dist/lib/huggingface-provider.d.ts.map +1 -1
- package/dist/lib/huggingface-provider.js +45 -14
- package/dist/lib/huggingface-provider.js.map +1 -1
- package/package.json +1 -1
|
@@ -50,10 +50,40 @@
|
|
|
50
50
|
// Claude CLI variants and direct API models have different runtime contracts,
|
|
51
51
|
// but the UI presents them as one provider-facing catalog. Keep these transforms
|
|
52
52
|
// pure so their losslessness/default rules can be regression-tested directly.
|
|
53
|
+
// Task 147: a model entry's provider is `custom:<id>` when it runs on a
|
|
54
|
+
// user-registered OpenAI-compatible endpoint. The catalog carries that string
|
|
55
|
+
// verbatim so a round-trip can never silently re-home a model onto one of the
|
|
56
|
+
// three built-in providers.
|
|
57
|
+
function isCustomProvider(provider) {
|
|
58
|
+
return typeof provider === 'string' && provider.indexOf('custom:') === 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Derive a registry id from a display name. Must satisfy the SAME shape the
|
|
62
|
+
// gateway validates ([a-z0-9][a-z0-9_-]*), or the provider is dropped on save
|
|
63
|
+
// and its models silently fall back to the Claude path.
|
|
64
|
+
function slugifyProviderId(label) {
|
|
65
|
+
var slug = String(label || '')
|
|
66
|
+
.toLowerCase()
|
|
67
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
68
|
+
.replace(/^-+/, '')
|
|
69
|
+
.replace(/-+$/, '');
|
|
70
|
+
return slug;
|
|
71
|
+
}
|
|
72
|
+
|
|
53
73
|
function gatewayConfigToModelCatalog(config) {
|
|
54
74
|
config = config || {};
|
|
55
75
|
var topModels = Array.isArray(config.models) ? config.models : [];
|
|
56
76
|
var claudeModels = Array.isArray(config.claudeModels) ? config.claudeModels : [];
|
|
77
|
+
var customProviders = (Array.isArray(config.customProviders) ? config.customProviders : []).map(
|
|
78
|
+
function (provider) {
|
|
79
|
+
return {
|
|
80
|
+
id: String(provider.id || ''),
|
|
81
|
+
label: String(provider.label || provider.id || ''),
|
|
82
|
+
baseUrl: String(provider.baseUrl || ''),
|
|
83
|
+
maskedKey: String(provider.apiKey || ''),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
);
|
|
57
87
|
var sentinel = topModels.find(function (model) {
|
|
58
88
|
return model.id === 'claude' || model.provider === 'claude-cli';
|
|
59
89
|
}) || { id: 'claude', label: 'Claude (CLI)', provider: 'claude-cli' };
|
|
@@ -81,7 +111,11 @@
|
|
|
81
111
|
catalog.push({
|
|
82
112
|
id: model.id,
|
|
83
113
|
label: model.label || model.name || model.id,
|
|
84
|
-
provider: model.provider
|
|
114
|
+
provider: isCustomProvider(model.provider)
|
|
115
|
+
? model.provider
|
|
116
|
+
: model.provider === 'openai'
|
|
117
|
+
? 'openai'
|
|
118
|
+
: 'huggingface',
|
|
85
119
|
contextWindow: typeof model.contextWindow === 'number' ? model.contextWindow : undefined,
|
|
86
120
|
gatewayDefault: globalDefault === model.id,
|
|
87
121
|
providerDefault: false,
|
|
@@ -96,7 +130,11 @@
|
|
|
96
130
|
) {
|
|
97
131
|
catalog[0].gatewayDefault = true;
|
|
98
132
|
}
|
|
99
|
-
return {
|
|
133
|
+
return {
|
|
134
|
+
catalog: catalog,
|
|
135
|
+
sentinel: Object.assign({}, sentinel),
|
|
136
|
+
customProviders: customProviders,
|
|
137
|
+
};
|
|
100
138
|
}
|
|
101
139
|
|
|
102
140
|
function modelCatalogToGatewayConfig(state) {
|
|
@@ -137,7 +175,11 @@
|
|
|
137
175
|
var entry = Object.assign({}, model.source || {});
|
|
138
176
|
entry.id = model.id;
|
|
139
177
|
entry.label = model.label || model.id;
|
|
140
|
-
entry.provider = model.provider
|
|
178
|
+
entry.provider = isCustomProvider(model.provider)
|
|
179
|
+
? model.provider
|
|
180
|
+
: model.provider === 'openai'
|
|
181
|
+
? 'openai'
|
|
182
|
+
: 'huggingface';
|
|
141
183
|
delete entry.default;
|
|
142
184
|
if (typeof model.contextWindow === 'number' && model.contextWindow > 0) {
|
|
143
185
|
entry.contextWindow = model.contextWindow;
|
|
@@ -150,13 +192,38 @@
|
|
|
150
192
|
models: [sentinel].concat(directModels),
|
|
151
193
|
claudeModels: claudeModels,
|
|
152
194
|
model: selected && selected.provider !== 'anthropic' ? selected.id : 'claude',
|
|
195
|
+
// Registry travels without credentials — addCredentialPatch fills those
|
|
196
|
+
// in, so an untouched key round-trips as absent and the server preserves it.
|
|
197
|
+
customProviders: (Array.isArray(state.customProviders) ? state.customProviders : []).map(
|
|
198
|
+
function (provider) {
|
|
199
|
+
return {
|
|
200
|
+
id: provider.id,
|
|
201
|
+
label: provider.label || provider.id,
|
|
202
|
+
baseUrl: provider.baseUrl,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
),
|
|
153
206
|
};
|
|
154
207
|
}
|
|
155
208
|
|
|
156
|
-
|
|
209
|
+
// Task 147. `customProviders` is optional: when supplied, a model referencing
|
|
210
|
+
// a provider that is not in the registry is rejected here rather than saved —
|
|
211
|
+
// the gateway resolves an orphaned `custom:<id>` fail-closed to the Claude
|
|
212
|
+
// path, so the model would silently run somewhere else instead of erroring.
|
|
213
|
+
function validateModelCatalog(catalog, customProviders) {
|
|
214
|
+
var known = null;
|
|
215
|
+
if (Array.isArray(customProviders)) {
|
|
216
|
+
known = {};
|
|
217
|
+
customProviders.forEach(function (provider) {
|
|
218
|
+
known['custom:' + provider.id] = true;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
157
221
|
var seen = { claude: true };
|
|
158
222
|
for (var i = 0; i < catalog.length; i++) {
|
|
159
223
|
var model = catalog[i];
|
|
224
|
+
if (known && isCustomProvider(model.provider) && !known[model.provider]) {
|
|
225
|
+
return 'Unknown provider for ' + (model.id || 'model') + ': ' + model.provider;
|
|
226
|
+
}
|
|
160
227
|
model.id = String(model.id || '').trim();
|
|
161
228
|
model.label = String(model.label || model.id).trim() || model.id;
|
|
162
229
|
if (!model.id) return 'Every model needs an ID';
|
|
@@ -173,6 +240,23 @@
|
|
|
173
240
|
return '';
|
|
174
241
|
}
|
|
175
242
|
|
|
243
|
+
/** Task 147: registry-level validation, mirroring the gateway's own rules. */
|
|
244
|
+
function validateCustomProviderList(customProviders) {
|
|
245
|
+
var seen = {};
|
|
246
|
+
for (var i = 0; i < customProviders.length; i++) {
|
|
247
|
+
var provider = customProviders[i];
|
|
248
|
+
var id = String(provider.id || '').trim();
|
|
249
|
+
var baseUrl = String(provider.baseUrl || '').trim();
|
|
250
|
+
if (!id) return 'Every custom provider needs a name';
|
|
251
|
+
if (seen[id]) return 'Duplicate custom provider: ' + id;
|
|
252
|
+
seen[id] = true;
|
|
253
|
+
if (!/^https?:\/\/\S+$/.test(baseUrl)) {
|
|
254
|
+
return 'Base URL for ' + (provider.label || id) + ' must start with http:// or https://';
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return '';
|
|
258
|
+
}
|
|
259
|
+
|
|
176
260
|
function addCredentialPatch(payload, credentials) {
|
|
177
261
|
[
|
|
178
262
|
{ provider: 'openai', key: 'openaiApiKey' },
|
|
@@ -184,6 +268,15 @@
|
|
|
184
268
|
if (credential.clear) payload[entry.key] = null;
|
|
185
269
|
else if (value && value.indexOf('•') === -1) payload[entry.key] = value;
|
|
186
270
|
});
|
|
271
|
+
// Custom providers carry their own credential inside the registry entry
|
|
272
|
+
// (task 147), keyed in the same credential maps by `custom:<id>`. Same rule
|
|
273
|
+
// as the scalars above: null clears, a real value sets, absent preserves.
|
|
274
|
+
(payload.customProviders || []).forEach(function (provider) {
|
|
275
|
+
var credential = credentials['custom:' + provider.id] || {};
|
|
276
|
+
var value = String(credential.value || '').trim();
|
|
277
|
+
if (credential.clear) provider.apiKey = null;
|
|
278
|
+
else if (value && value.indexOf('•') === -1) provider.apiKey = value;
|
|
279
|
+
});
|
|
187
280
|
return payload;
|
|
188
281
|
}
|
|
189
282
|
|
|
@@ -1098,6 +1191,29 @@
|
|
|
1098
1191
|
' flex: 1; overflow: hidden;',
|
|
1099
1192
|
' text-overflow: ellipsis; white-space: nowrap;',
|
|
1100
1193
|
'}',
|
|
1194
|
+
/* Activity dots (task 146) — a thread is running a turn right now. Sits
|
|
1195
|
+
AFTER the name rather than replacing the status dot on the left, which
|
|
1196
|
+
already carries unread/active/inactive; overloading it would cost the
|
|
1197
|
+
unread signal exactly when a thread is working. */
|
|
1198
|
+
'.cumulus-thread-activity {',
|
|
1199
|
+
' display: flex; align-items: center; gap: 3px;',
|
|
1200
|
+
' flex-shrink: 0; margin-left: 4px;',
|
|
1201
|
+
'}',
|
|
1202
|
+
'.cumulus-thread-activity span {',
|
|
1203
|
+
' width: 4px; height: 4px; border-radius: 50%;',
|
|
1204
|
+
' background: #22c55e;',
|
|
1205
|
+
' animation: cumulus-thread-pulse 1.2s ease-in-out infinite;',
|
|
1206
|
+
'}',
|
|
1207
|
+
'.cumulus-thread-activity span:nth-child(2) { animation-delay: 0.2s; }',
|
|
1208
|
+
'.cumulus-thread-activity span:nth-child(3) { animation-delay: 0.4s; }',
|
|
1209
|
+
'@keyframes cumulus-thread-pulse {',
|
|
1210
|
+
' 0%, 80%, 100% { opacity: 0.25; transform: scale(0.8); }',
|
|
1211
|
+
' 40% { opacity: 1; transform: scale(1); }',
|
|
1212
|
+
'}',
|
|
1213
|
+
/* Honour a reduced-motion preference: keep the signal, drop the movement. */
|
|
1214
|
+
'@media (prefers-reduced-motion: reduce) {',
|
|
1215
|
+
' .cumulus-thread-activity span { animation: none; opacity: 0.9; }',
|
|
1216
|
+
'}',
|
|
1101
1217
|
/* Disclosure triangle. Always occupies its slot (even when empty) so
|
|
1102
1218
|
sibling names stay aligned; only the with-children state is clickable. */
|
|
1103
1219
|
'.cumulus-thread-twisty {',
|
|
@@ -1538,6 +1654,13 @@
|
|
|
1538
1654
|
' display: grid; grid-template-columns: 120px minmax(0,1fr) auto; align-items: center; gap: 10px;',
|
|
1539
1655
|
' padding: 11px 14px; background: #1b1c1f; border-top: 1px solid #2a2b30;',
|
|
1540
1656
|
'}',
|
|
1657
|
+
// Task 147: same band as the credential row, above the model list, so a
|
|
1658
|
+
// custom provider's endpoint reads as part of the provider, not a model.
|
|
1659
|
+
'.cumulus-settings-provider-endpoint {',
|
|
1660
|
+
' display: grid; grid-template-columns: 120px minmax(0,1fr) auto; align-items: center; gap: 10px;',
|
|
1661
|
+
' padding: 10px 14px; background: #1b1c1f; border-top: 1px solid #2a2b30;',
|
|
1662
|
+
'}',
|
|
1663
|
+
'.cumulus-settings-provider-endpoint label { color: #9a9da5; font-size: 11.5px; }',
|
|
1541
1664
|
'.cumulus-settings-credential-note {',
|
|
1542
1665
|
' color: #7d808a; font-size: 11.5px; padding: 11px 14px; background: #1b1c1f; border-top: 1px solid #2a2b30;',
|
|
1543
1666
|
'}',
|
|
@@ -1596,6 +1719,8 @@
|
|
|
1596
1719
|
' .cumulus-settings-add-row .cumulus-settings-add-btn { grid-column: 1 / -1; }',
|
|
1597
1720
|
' .cumulus-settings-provider-credential { grid-template-columns: 1fr auto; gap: 5px; }',
|
|
1598
1721
|
' .cumulus-settings-provider-credential label { grid-column: 1 / -1; }',
|
|
1722
|
+
' .cumulus-settings-provider-endpoint { grid-template-columns: 1fr auto; gap: 5px; }',
|
|
1723
|
+
' .cumulus-settings-provider-endpoint label { grid-column: 1 / -1; }',
|
|
1599
1724
|
' .cumulus-settings-default-card { flex-direction: column; align-items: stretch; gap: 6px; }',
|
|
1600
1725
|
' .cumulus-settings-edit-grid { grid-template-columns: 1fr 1fr; }',
|
|
1601
1726
|
'}',
|
|
@@ -4579,6 +4704,15 @@
|
|
|
4579
4704
|
// Unread threads: threadName -> true
|
|
4580
4705
|
var unreadThreads = {};
|
|
4581
4706
|
|
|
4707
|
+
// Threads running a turn right now: threadName -> true (task 146).
|
|
4708
|
+
//
|
|
4709
|
+
// Live state rather than a field read off `allThreads`, because the listing
|
|
4710
|
+
// is a snapshot: it seeds this map on arrival, and `thread_activity` pushes
|
|
4711
|
+
// keep it current between listings. The SERVER is the single authority — the
|
|
4712
|
+
// sidebar deliberately does not also infer activity from its own token
|
|
4713
|
+
// stream, or the two disagree about a thread another tab is driving.
|
|
4714
|
+
var busyThreads = {};
|
|
4715
|
+
|
|
4582
4716
|
// Currently visible panel names (ordered, max 3)
|
|
4583
4717
|
var visibleThreads = [];
|
|
4584
4718
|
|
|
@@ -5264,6 +5398,22 @@
|
|
|
5264
5398
|
item.appendChild(folderEl);
|
|
5265
5399
|
}
|
|
5266
5400
|
|
|
5401
|
+
// Running a turn right now (task 146). Keyed on the live map, not on
|
|
5402
|
+
// `thread.busy`, so a push that arrived after the last listing still shows.
|
|
5403
|
+
//
|
|
5404
|
+
// Appended LAST, after the folder icon, because this element comes and
|
|
5405
|
+
// goes: putting it before a static sibling would shove that sibling
|
|
5406
|
+
// sideways every time a thread starts or stops working.
|
|
5407
|
+
if (busyThreads[name]) {
|
|
5408
|
+
var activity = document.createElement('span');
|
|
5409
|
+
activity.className = 'cumulus-thread-activity';
|
|
5410
|
+
activity.setAttribute('data-testid', 'webchat-thread-activity');
|
|
5411
|
+
activity.setAttribute('title', 'Working…');
|
|
5412
|
+
activity.setAttribute('aria-label', 'Working');
|
|
5413
|
+
for (var d = 0; d < 3; d++) activity.appendChild(document.createElement('span'));
|
|
5414
|
+
item.appendChild(activity);
|
|
5415
|
+
}
|
|
5416
|
+
|
|
5267
5417
|
// Right-click context menu
|
|
5268
5418
|
item.addEventListener('contextmenu', function (e) {
|
|
5269
5419
|
e.preventDefault();
|
|
@@ -6344,16 +6494,8 @@
|
|
|
6344
6494
|
var addProvider = document.createElement('select');
|
|
6345
6495
|
addProvider.className = 'cumulus-settings-select';
|
|
6346
6496
|
addProvider.setAttribute('data-testid', 'webchat-gw-add-provider');
|
|
6347
|
-
|
|
6348
|
-
|
|
6349
|
-
{ value: 'openai', label: 'OpenAI' },
|
|
6350
|
-
{ value: 'huggingface', label: 'HuggingFace' },
|
|
6351
|
-
].forEach(function (provider) {
|
|
6352
|
-
var option = document.createElement('option');
|
|
6353
|
-
option.value = provider.value;
|
|
6354
|
-
option.textContent = provider.label;
|
|
6355
|
-
addProvider.appendChild(option);
|
|
6356
|
-
});
|
|
6497
|
+
// Options are built by renderAddProviderOptions() from providerDefs — the
|
|
6498
|
+
// one list that also drives the cards and the default dropdown (task 147).
|
|
6357
6499
|
var addId = document.createElement('input');
|
|
6358
6500
|
addId.type = 'text';
|
|
6359
6501
|
addId.className = 'cumulus-settings-input';
|
|
@@ -6384,6 +6526,51 @@
|
|
|
6384
6526
|
addCard.appendChild(addWrap);
|
|
6385
6527
|
body.appendChild(addCard);
|
|
6386
6528
|
|
|
6529
|
+
// ── Custom OpenAI-compatible providers (task 147) ────────────────────────
|
|
6530
|
+
// One mechanism for every third-party host that speaks chat/completions:
|
|
6531
|
+
// Vultr, Together, Groq, Fireworks, vLLM, LM Studio, Ollama. Each carries
|
|
6532
|
+
// its own credential — the OpenAI/HuggingFace keys belong to those hosts.
|
|
6533
|
+
var customCard = document.createElement('div');
|
|
6534
|
+
customCard.className = 'cumulus-settings-add-card';
|
|
6535
|
+
var customTitle = document.createElement('div');
|
|
6536
|
+
customTitle.className = 'cumulus-settings-add-title';
|
|
6537
|
+
customTitle.textContent = 'Add a custom provider';
|
|
6538
|
+
customCard.appendChild(customTitle);
|
|
6539
|
+
var customHint = document.createElement('div');
|
|
6540
|
+
customHint.className = 'cumulus-settings-subtitle';
|
|
6541
|
+
customHint.textContent =
|
|
6542
|
+
'Any OpenAI-compatible endpoint. Paste the base URL from its docs — /chat/completions is added if missing.';
|
|
6543
|
+
customCard.appendChild(customHint);
|
|
6544
|
+
var customWrap = document.createElement('div');
|
|
6545
|
+
customWrap.className = 'cumulus-settings-add-row';
|
|
6546
|
+
var customLabelInput = document.createElement('input');
|
|
6547
|
+
customLabelInput.type = 'text';
|
|
6548
|
+
customLabelInput.className = 'cumulus-settings-input';
|
|
6549
|
+
customLabelInput.placeholder = 'Name (e.g. Vultr)';
|
|
6550
|
+
customLabelInput.setAttribute('data-testid', 'webchat-gw-addprovider-label');
|
|
6551
|
+
var customUrlInput = document.createElement('input');
|
|
6552
|
+
customUrlInput.type = 'text';
|
|
6553
|
+
customUrlInput.className = 'cumulus-settings-input';
|
|
6554
|
+
customUrlInput.placeholder = 'https://api.example.com/v1';
|
|
6555
|
+
customUrlInput.setAttribute('data-testid', 'webchat-gw-addprovider-url');
|
|
6556
|
+
var customKeyInput = document.createElement('input');
|
|
6557
|
+
customKeyInput.type = 'password';
|
|
6558
|
+
customKeyInput.autocomplete = 'off';
|
|
6559
|
+
customKeyInput.className = 'cumulus-settings-input';
|
|
6560
|
+
customKeyInput.placeholder = 'API key';
|
|
6561
|
+
customKeyInput.setAttribute('data-testid', 'webchat-gw-addprovider-key');
|
|
6562
|
+
var customAddBtn = document.createElement('button');
|
|
6563
|
+
customAddBtn.type = 'button';
|
|
6564
|
+
customAddBtn.textContent = '+ Add';
|
|
6565
|
+
customAddBtn.className = 'cumulus-settings-add-btn';
|
|
6566
|
+
customAddBtn.setAttribute('data-testid', 'webchat-gw-addprovider-btn');
|
|
6567
|
+
customWrap.appendChild(customLabelInput);
|
|
6568
|
+
customWrap.appendChild(customUrlInput);
|
|
6569
|
+
customWrap.appendChild(customKeyInput);
|
|
6570
|
+
customWrap.appendChild(customAddBtn);
|
|
6571
|
+
customCard.appendChild(customWrap);
|
|
6572
|
+
body.appendChild(customCard);
|
|
6573
|
+
|
|
6387
6574
|
// ── License (task 129) — persist-only: the key lands in gateway.config.json
|
|
6388
6575
|
// but enforcement re-verifies at startup, so it takes effect on reload (127).
|
|
6389
6576
|
var licenseLabel = document.createElement('div');
|
|
@@ -6446,7 +6633,7 @@
|
|
|
6446
6633
|
});
|
|
6447
6634
|
document.body.appendChild(backdrop);
|
|
6448
6635
|
|
|
6449
|
-
var gwState = { catalog: [], sentinel: null };
|
|
6636
|
+
var gwState = { catalog: [], sentinel: null, customProviders: [] };
|
|
6450
6637
|
var credentialInputs = {};
|
|
6451
6638
|
var credentialValues = { openai: '', huggingface: '', license: '' };
|
|
6452
6639
|
var credentialClears = { openai: false, huggingface: false, license: false };
|
|
@@ -6478,27 +6665,45 @@
|
|
|
6478
6665
|
});
|
|
6479
6666
|
var gwLicenseMasked = '';
|
|
6480
6667
|
var gwLicenseInfo = null;
|
|
6481
|
-
|
|
6482
|
-
|
|
6483
|
-
|
|
6484
|
-
|
|
6485
|
-
|
|
6486
|
-
|
|
6487
|
-
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
6495
|
-
|
|
6496
|
-
|
|
6497
|
-
|
|
6498
|
-
|
|
6499
|
-
|
|
6500
|
-
|
|
6501
|
-
|
|
6668
|
+
// Task 147: the three built-in providers are fixed; custom
|
|
6669
|
+
// OpenAI-compatible endpoints are appended from the registry. Cards, the
|
|
6670
|
+
// default dropdown, the add-model select and the credential rows all read
|
|
6671
|
+
// this ONE list, so they cannot disagree about which providers exist.
|
|
6672
|
+
var providerDefs = [];
|
|
6673
|
+
function buildProviderDefs() {
|
|
6674
|
+
var defs = [
|
|
6675
|
+
{
|
|
6676
|
+
id: 'anthropic',
|
|
6677
|
+
title: 'Anthropic',
|
|
6678
|
+
description: 'Claude CLI models · authentication is managed by the Claude CLI',
|
|
6679
|
+
},
|
|
6680
|
+
{
|
|
6681
|
+
id: 'openai',
|
|
6682
|
+
title: 'OpenAI',
|
|
6683
|
+
description: 'Responses API models with direct tool use',
|
|
6684
|
+
credential: 'openaiApiKey',
|
|
6685
|
+
credentialTestId: 'webchat-gw-openaikey',
|
|
6686
|
+
},
|
|
6687
|
+
{
|
|
6688
|
+
id: 'huggingface',
|
|
6689
|
+
title: 'HuggingFace',
|
|
6690
|
+
description: 'OpenAI-compatible models served through the HuggingFace router',
|
|
6691
|
+
credential: 'hfApiKey',
|
|
6692
|
+
credentialTestId: 'webchat-gw-hfkey',
|
|
6693
|
+
},
|
|
6694
|
+
];
|
|
6695
|
+
(gwState.customProviders || []).forEach(function (provider) {
|
|
6696
|
+
defs.push({
|
|
6697
|
+
id: 'custom:' + provider.id,
|
|
6698
|
+
title: provider.label || provider.id,
|
|
6699
|
+
description: 'Custom OpenAI-compatible endpoint',
|
|
6700
|
+
credential: 'custom',
|
|
6701
|
+
credentialTestId: 'webchat-gw-customkey-' + provider.id,
|
|
6702
|
+
custom: provider,
|
|
6703
|
+
});
|
|
6704
|
+
});
|
|
6705
|
+
return defs;
|
|
6706
|
+
}
|
|
6502
6707
|
|
|
6503
6708
|
function setSettingsStatus(message, kind) {
|
|
6504
6709
|
gwStatus.textContent = message || '';
|
|
@@ -6666,6 +6871,59 @@
|
|
|
6666
6871
|
return row;
|
|
6667
6872
|
}
|
|
6668
6873
|
|
|
6874
|
+
// Task 147: a custom provider owns an endpoint and can be removed. Removal
|
|
6875
|
+
// is REFUSED while models still reference it — an orphaned `custom:<id>`
|
|
6876
|
+
// resolves fail-closed to the Claude path, so the model would silently run
|
|
6877
|
+
// somewhere else instead of erroring.
|
|
6878
|
+
function renderCustomProviderControls(card, providerDef) {
|
|
6879
|
+
var row = document.createElement('div');
|
|
6880
|
+
row.className = 'cumulus-settings-provider-endpoint';
|
|
6881
|
+
var label = document.createElement('label');
|
|
6882
|
+
label.textContent = 'Base URL';
|
|
6883
|
+
var input = document.createElement('input');
|
|
6884
|
+
input.type = 'text';
|
|
6885
|
+
input.className = 'cumulus-settings-input';
|
|
6886
|
+
input.value = providerDef.custom.baseUrl || '';
|
|
6887
|
+
input.placeholder = 'https://api.example.com/v1';
|
|
6888
|
+
input.setAttribute('aria-label', providerDef.title + ' base URL');
|
|
6889
|
+
input.setAttribute('data-testid', 'webchat-gw-customurl-' + providerDef.custom.id);
|
|
6890
|
+
input.addEventListener('input', function () {
|
|
6891
|
+
providerDef.custom.baseUrl = input.value;
|
|
6892
|
+
});
|
|
6893
|
+
var remove = document.createElement('button');
|
|
6894
|
+
remove.type = 'button';
|
|
6895
|
+
remove.className = 'cumulus-settings-clear-btn';
|
|
6896
|
+
remove.textContent = 'Remove provider';
|
|
6897
|
+
remove.setAttribute('data-testid', 'webchat-gw-removeprovider-' + providerDef.custom.id);
|
|
6898
|
+
remove.addEventListener('click', function () {
|
|
6899
|
+
var inUse = gwState.catalog.filter(function (model) {
|
|
6900
|
+
return model.provider === providerDef.id;
|
|
6901
|
+
});
|
|
6902
|
+
if (inUse.length) {
|
|
6903
|
+
setSettingsStatus(
|
|
6904
|
+
'Remove its ' +
|
|
6905
|
+
inUse.length +
|
|
6906
|
+
' model' +
|
|
6907
|
+
(inUse.length === 1 ? '' : 's') +
|
|
6908
|
+
' first — they would stop running on this endpoint',
|
|
6909
|
+
'error'
|
|
6910
|
+
);
|
|
6911
|
+
return;
|
|
6912
|
+
}
|
|
6913
|
+
gwState.customProviders = gwState.customProviders.filter(function (entry) {
|
|
6914
|
+
return entry.id !== providerDef.custom.id;
|
|
6915
|
+
});
|
|
6916
|
+
delete credentialValues[providerDef.id];
|
|
6917
|
+
delete credentialClears[providerDef.id];
|
|
6918
|
+
setSettingsStatus('', '');
|
|
6919
|
+
renderAll();
|
|
6920
|
+
});
|
|
6921
|
+
row.appendChild(label);
|
|
6922
|
+
row.appendChild(input);
|
|
6923
|
+
row.appendChild(remove);
|
|
6924
|
+
card.appendChild(row);
|
|
6925
|
+
}
|
|
6926
|
+
|
|
6669
6927
|
function renderCredential(card, provider) {
|
|
6670
6928
|
if (!provider.credential) {
|
|
6671
6929
|
var note = document.createElement('div');
|
|
@@ -6739,6 +6997,7 @@
|
|
|
6739
6997
|
head.appendChild(heading);
|
|
6740
6998
|
head.appendChild(count);
|
|
6741
6999
|
card.appendChild(head);
|
|
7000
|
+
if (provider.custom) renderCustomProviderControls(card, provider);
|
|
6742
7001
|
var list = document.createElement('div');
|
|
6743
7002
|
list.className = 'cumulus-settings-provider-models';
|
|
6744
7003
|
if (!providerModels.length) {
|
|
@@ -6817,7 +7076,27 @@
|
|
|
6817
7076
|
defaultSelect.value = current ? current.id : gwState.catalog[0].id;
|
|
6818
7077
|
}
|
|
6819
7078
|
|
|
7079
|
+
// Rebuild the provider list from state before anything reads it, so a
|
|
7080
|
+
// provider added or removed this session reaches the cards, the default
|
|
7081
|
+
// dropdown and the add-model select in the same pass (task 147).
|
|
7082
|
+
function renderAddProviderOptions() {
|
|
7083
|
+
var previous = addProvider.value;
|
|
7084
|
+
addProvider.innerHTML = '';
|
|
7085
|
+
providerDefs.forEach(function (provider) {
|
|
7086
|
+
var option = document.createElement('option');
|
|
7087
|
+
option.value = provider.id;
|
|
7088
|
+
option.textContent = provider.title;
|
|
7089
|
+
addProvider.appendChild(option);
|
|
7090
|
+
});
|
|
7091
|
+
addProvider.value = previous || 'anthropic';
|
|
7092
|
+
if (!addProvider.value) addProvider.value = 'anthropic';
|
|
7093
|
+
addContext.disabled = addProvider.value === 'anthropic';
|
|
7094
|
+
if (addContext.disabled) addContext.value = '';
|
|
7095
|
+
}
|
|
7096
|
+
|
|
6820
7097
|
function renderAll() {
|
|
7098
|
+
providerDefs = buildProviderDefs();
|
|
7099
|
+
renderAddProviderOptions();
|
|
6821
7100
|
renderDefaultSelect();
|
|
6822
7101
|
renderGwModels();
|
|
6823
7102
|
}
|
|
@@ -6826,6 +7105,41 @@
|
|
|
6826
7105
|
addContext.disabled = addProvider.value === 'anthropic';
|
|
6827
7106
|
if (addContext.disabled) addContext.value = '';
|
|
6828
7107
|
});
|
|
7108
|
+
|
|
7109
|
+
customAddBtn.addEventListener('click', function () {
|
|
7110
|
+
var label = (customLabelInput.value || '').trim();
|
|
7111
|
+
var baseUrl = (customUrlInput.value || '').trim();
|
|
7112
|
+
var id = slugifyProviderId(label);
|
|
7113
|
+
if (!label || !id) {
|
|
7114
|
+
setSettingsStatus('Provider name is required (letters or digits)', 'error');
|
|
7115
|
+
return;
|
|
7116
|
+
}
|
|
7117
|
+
if (!/^https?:\/\/\S+$/.test(baseUrl)) {
|
|
7118
|
+
setSettingsStatus('Base URL must start with http:// or https://', 'error');
|
|
7119
|
+
return;
|
|
7120
|
+
}
|
|
7121
|
+
if (
|
|
7122
|
+
gwState.customProviders.some(function (provider) {
|
|
7123
|
+
return provider.id === id;
|
|
7124
|
+
})
|
|
7125
|
+
) {
|
|
7126
|
+
setSettingsStatus('A provider named "' + label + '" already exists', 'error');
|
|
7127
|
+
return;
|
|
7128
|
+
}
|
|
7129
|
+
gwState.customProviders.push({ id: id, label: label, baseUrl: baseUrl, maskedKey: '' });
|
|
7130
|
+
// Key is held in the same credential maps as the built-in providers, so
|
|
7131
|
+
// it rides the one save path and the one masking rule.
|
|
7132
|
+
var key = (customKeyInput.value || '').trim();
|
|
7133
|
+
if (key) {
|
|
7134
|
+
credentialValues['custom:' + id] = key;
|
|
7135
|
+
credentialClears['custom:' + id] = false;
|
|
7136
|
+
}
|
|
7137
|
+
customLabelInput.value = '';
|
|
7138
|
+
customUrlInput.value = '';
|
|
7139
|
+
customKeyInput.value = '';
|
|
7140
|
+
setSettingsStatus('', '');
|
|
7141
|
+
renderAll();
|
|
7142
|
+
});
|
|
6829
7143
|
addBtn.addEventListener('click', function () {
|
|
6830
7144
|
var id = (addId.value || '').trim();
|
|
6831
7145
|
var provider = addProvider.value;
|
|
@@ -6867,6 +7181,28 @@
|
|
|
6867
7181
|
renderAll();
|
|
6868
7182
|
});
|
|
6869
7183
|
|
|
7184
|
+
// One place that turns a GET/PUT /api/config response into editor state,
|
|
7185
|
+
// so the load and save paths cannot drift on credential seeding (Rule #8).
|
|
7186
|
+
function adoptGatewayConfigResponse(response) {
|
|
7187
|
+
gwState = gatewayConfigToModelCatalog(response);
|
|
7188
|
+
credentialValues.openai = response.openaiApiKey || '';
|
|
7189
|
+
credentialValues.huggingface = response.hfApiKey || '';
|
|
7190
|
+
credentialValues.license = response.licenseKey || '';
|
|
7191
|
+
credentialClears.openai = false;
|
|
7192
|
+
credentialClears.huggingface = false;
|
|
7193
|
+
credentialClears.license = false;
|
|
7194
|
+
// Custom-provider keys arrive MASKED inside their registry entries. Seed
|
|
7195
|
+
// them the same way as the scalars: a masked value is ignored on save,
|
|
7196
|
+
// so an untouched key is preserved rather than read as "cleared".
|
|
7197
|
+
gwState.customProviders.forEach(function (provider) {
|
|
7198
|
+
var key = 'custom:' + provider.id;
|
|
7199
|
+
credentialValues[key] = provider.maskedKey || '';
|
|
7200
|
+
credentialClears[key] = false;
|
|
7201
|
+
});
|
|
7202
|
+
gwLicenseMasked = response.licenseKey || '';
|
|
7203
|
+
gwLicenseInfo = response.license || null;
|
|
7204
|
+
}
|
|
7205
|
+
|
|
6870
7206
|
function refreshOpenModelCatalogs() {
|
|
6871
7207
|
var refresh = new XMLHttpRequest();
|
|
6872
7208
|
refresh.open('GET', '/api/models');
|
|
@@ -6887,14 +7223,16 @@
|
|
|
6887
7223
|
}
|
|
6888
7224
|
|
|
6889
7225
|
saveBtn.addEventListener('click', function () {
|
|
6890
|
-
var validationError =
|
|
7226
|
+
var validationError =
|
|
7227
|
+
validateCustomProviderList(gwState.customProviders || []) ||
|
|
7228
|
+
validateModelCatalog(gwState.catalog, gwState.customProviders || []);
|
|
6891
7229
|
if (validationError) {
|
|
6892
7230
|
setSettingsStatus(validationError, 'error');
|
|
6893
7231
|
return;
|
|
6894
7232
|
}
|
|
6895
7233
|
saveBtn.disabled = true;
|
|
6896
7234
|
setSettingsStatus('Saving…', '');
|
|
6897
|
-
var
|
|
7235
|
+
var credentialMap = {
|
|
6898
7236
|
openai: {
|
|
6899
7237
|
value: credentialValues.openai,
|
|
6900
7238
|
clear: credentialClears.openai,
|
|
@@ -6907,7 +7245,15 @@
|
|
|
6907
7245
|
value: credentialValues.license,
|
|
6908
7246
|
clear: credentialClears.license,
|
|
6909
7247
|
},
|
|
7248
|
+
};
|
|
7249
|
+
(gwState.customProviders || []).forEach(function (provider) {
|
|
7250
|
+
var key = 'custom:' + provider.id;
|
|
7251
|
+
credentialMap[key] = {
|
|
7252
|
+
value: credentialValues[key],
|
|
7253
|
+
clear: credentialClears[key],
|
|
7254
|
+
};
|
|
6910
7255
|
});
|
|
7256
|
+
var payload = addCredentialPatch(modelCatalogToGatewayConfig(gwState), credentialMap);
|
|
6911
7257
|
var save = new XMLHttpRequest();
|
|
6912
7258
|
save.open('PUT', '/api/config');
|
|
6913
7259
|
save.setRequestHeader('Content-Type', 'application/json');
|
|
@@ -6918,15 +7264,7 @@
|
|
|
6918
7264
|
if (save.status === 200) {
|
|
6919
7265
|
try {
|
|
6920
7266
|
var response = JSON.parse(save.responseText);
|
|
6921
|
-
|
|
6922
|
-
credentialClears.openai = false;
|
|
6923
|
-
credentialClears.huggingface = false;
|
|
6924
|
-
credentialClears.license = false;
|
|
6925
|
-
credentialValues.openai = response.openaiApiKey || '';
|
|
6926
|
-
credentialValues.huggingface = response.hfApiKey || '';
|
|
6927
|
-
credentialValues.license = response.licenseKey || '';
|
|
6928
|
-
gwLicenseMasked = response.licenseKey || '';
|
|
6929
|
-
gwLicenseInfo = response.license || null;
|
|
7267
|
+
adoptGatewayConfigResponse(response);
|
|
6930
7268
|
renderAll();
|
|
6931
7269
|
renderLicenseState(gwLicenseMasked, gwLicenseInfo);
|
|
6932
7270
|
setSettingsStatus(
|
|
@@ -6946,6 +7284,10 @@
|
|
|
6946
7284
|
save.send(JSON.stringify(payload));
|
|
6947
7285
|
});
|
|
6948
7286
|
|
|
7287
|
+
// First paint before the config arrives: the three built-in providers are
|
|
7288
|
+
// known without the server, so the shell renders immediately.
|
|
7289
|
+
renderAll();
|
|
7290
|
+
|
|
6949
7291
|
(function loadGatewayConfig() {
|
|
6950
7292
|
var load = new XMLHttpRequest();
|
|
6951
7293
|
load.open('GET', '/api/config');
|
|
@@ -6955,12 +7297,7 @@
|
|
|
6955
7297
|
if (load.status === 200) {
|
|
6956
7298
|
try {
|
|
6957
7299
|
var response = JSON.parse(load.responseText);
|
|
6958
|
-
|
|
6959
|
-
credentialValues.openai = response.openaiApiKey || '';
|
|
6960
|
-
credentialValues.huggingface = response.hfApiKey || '';
|
|
6961
|
-
credentialValues.license = response.licenseKey || '';
|
|
6962
|
-
gwLicenseMasked = response.licenseKey || '';
|
|
6963
|
-
gwLicenseInfo = response.license || null;
|
|
7300
|
+
adoptGatewayConfigResponse(response);
|
|
6964
7301
|
renderAll();
|
|
6965
7302
|
renderLicenseState(gwLicenseMasked, gwLicenseInfo);
|
|
6966
7303
|
} catch (e) {
|
|
@@ -7070,9 +7407,18 @@
|
|
|
7070
7407
|
// Model id → provider ("huggingface" | "openai" | "cli"), filled by loadModels
|
|
7071
7408
|
// from /api/models so the breadcrumb names the real provider (task 118).
|
|
7072
7409
|
var modelProviders = {};
|
|
7410
|
+
// Custom-provider id → display label, from /api/models (task 147). That
|
|
7411
|
+
// endpoint serves id + label only — never the base URL, which is private
|
|
7412
|
+
// infrastructure and it accepts namespace-scoped app keys (097 P7).
|
|
7413
|
+
var customProviderLabels = {};
|
|
7073
7414
|
function providerLabel(id) {
|
|
7074
7415
|
if (id === 'claude') return 'Anthropic';
|
|
7075
|
-
|
|
7416
|
+
var provider = modelProviders[id];
|
|
7417
|
+
if (isCustomProvider(provider)) {
|
|
7418
|
+
var customId = provider.slice('custom:'.length);
|
|
7419
|
+
return customProviderLabels[customId] || customId;
|
|
7420
|
+
}
|
|
7421
|
+
if (provider === 'openai') return 'OpenAI';
|
|
7076
7422
|
return 'HuggingFace';
|
|
7077
7423
|
}
|
|
7078
7424
|
function updateBreadcrumb() {
|
|
@@ -7105,6 +7451,10 @@
|
|
|
7105
7451
|
var previousModel = preserveSelection ? modelSelect.value : '';
|
|
7106
7452
|
var previousClaudeModel = preserveSelection ? claudeModelSelect.value : '';
|
|
7107
7453
|
modelProviders = {};
|
|
7454
|
+
customProviderLabels = {};
|
|
7455
|
+
(response.customProviders || []).forEach(function (provider) {
|
|
7456
|
+
customProviderLabels[provider.id] = provider.label || provider.id;
|
|
7457
|
+
});
|
|
7108
7458
|
modelSelect.innerHTML = '';
|
|
7109
7459
|
(response.models || []).forEach(function (model) {
|
|
7110
7460
|
var option = document.createElement('option');
|
|
@@ -9160,6 +9510,25 @@
|
|
|
9160
9510
|
// Server response to { type: 'threads' } request
|
|
9161
9511
|
if (data.threads) {
|
|
9162
9512
|
allThreads = data.threads;
|
|
9513
|
+
// Reseed from the listing, so a tab opened mid-turn is correct at
|
|
9514
|
+
// once instead of waiting for the next transition (task 146).
|
|
9515
|
+
// Rebuilt rather than merged: the listing is authoritative for every
|
|
9516
|
+
// thread it contains, so a stale entry cannot survive a refresh.
|
|
9517
|
+
busyThreads = {};
|
|
9518
|
+
for (var ti = 0; ti < data.threads.length; ti++) {
|
|
9519
|
+
if (data.threads[ti].busy) busyThreads[data.threads[ti].name] = true;
|
|
9520
|
+
}
|
|
9521
|
+
renderSidebar();
|
|
9522
|
+
}
|
|
9523
|
+
break;
|
|
9524
|
+
|
|
9525
|
+
// A thread started or finished a turn. Not gated on watching the thread,
|
|
9526
|
+
// which is the point: the sidebar shows work you did not start — a
|
|
9527
|
+
// scheduled trigger, an agent message, a finished job (task 146).
|
|
9528
|
+
case 'thread_activity':
|
|
9529
|
+
if (data.threadName) {
|
|
9530
|
+
if (data.busy) busyThreads[data.threadName] = true;
|
|
9531
|
+
else delete busyThreads[data.threadName];
|
|
9163
9532
|
renderSidebar();
|
|
9164
9533
|
}
|
|
9165
9534
|
break;
|