@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.
@@ -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 === 'openai' ? 'openai' : 'huggingface',
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 { catalog: catalog, sentinel: Object.assign({}, sentinel) };
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 === 'openai' ? 'openai' : 'huggingface';
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
- function validateModelCatalog(catalog) {
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
- { value: 'anthropic', label: 'Anthropic' },
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
- var providerDefs = [
6482
- {
6483
- id: 'anthropic',
6484
- title: 'Anthropic',
6485
- description: 'Claude CLI models · authentication is managed by the Claude CLI',
6486
- },
6487
- {
6488
- id: 'openai',
6489
- title: 'OpenAI',
6490
- description: 'Responses API models with direct tool use',
6491
- credential: 'openaiApiKey',
6492
- credentialTestId: 'webchat-gw-openaikey',
6493
- },
6494
- {
6495
- id: 'huggingface',
6496
- title: 'HuggingFace',
6497
- description: 'OpenAI-compatible models served through the HuggingFace router',
6498
- credential: 'hfApiKey',
6499
- credentialTestId: 'webchat-gw-hfkey',
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 = validateModelCatalog(gwState.catalog);
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 payload = addCredentialPatch(modelCatalogToGatewayConfig(gwState), {
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
- gwState = gatewayConfigToModelCatalog(response);
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
- gwState = gatewayConfigToModelCatalog(response);
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
- if (modelProviders[id] === 'openai') return 'OpenAI';
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;