@worca/app 1.5.0 → 1.6.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +7 -1
  2. package/docker/compose.broker.yml +79 -0
  3. package/docker/compose.isolation.yml +40 -0
  4. package/package.json +2 -1
  5. package/src/broker/config.mjs +200 -0
  6. package/src/broker/copilot.mjs +124 -0
  7. package/src/broker/limits.mjs +88 -0
  8. package/src/broker/main.mjs +129 -0
  9. package/src/broker/scrub.mjs +45 -0
  10. package/src/broker/service.mjs +558 -0
  11. package/src/broker/slots.mjs +180 -0
  12. package/src/broker/store.mjs +171 -0
  13. package/src/broker/tokens.mjs +116 -0
  14. package/src/broker/ui/page.css +54 -0
  15. package/src/broker/ui/page.html +25 -0
  16. package/src/broker/ui/page.mjs +202 -0
  17. package/src/broker/ui-server.mjs +217 -0
  18. package/src/broker/usage.mjs +108 -0
  19. package/src/broker/vault.mjs +37 -0
  20. package/src/cli/models.mjs +46 -14
  21. package/src/cli/render.mjs +21 -0
  22. package/src/cli/runs.mjs +296 -0
  23. package/src/cli/worca-cc.mjs +14 -2
  24. package/src/core/agent-pool.mjs +76 -0
  25. package/src/core/artifacts.mjs +40 -8
  26. package/src/core/ask/events.mjs +11 -0
  27. package/src/core/ask/html-text.mjs +113 -0
  28. package/src/core/ask/limits.mjs +5 -0
  29. package/src/core/ask/mcp-stdio.mjs +74 -20
  30. package/src/core/ask/prompt.mjs +25 -6
  31. package/src/core/ask/spawn.mjs +55 -5
  32. package/src/core/ask/store.mjs +1 -1
  33. package/src/core/ask/tools.mjs +66 -1
  34. package/src/core/ask/turn.mjs +52 -3
  35. package/src/core/ask/web-access.mjs +26 -0
  36. package/src/core/ask/web-deps.mjs +56 -0
  37. package/src/core/ask/web-fetch.mjs +271 -0
  38. package/src/core/ask/web-proposal.mjs +76 -0
  39. package/src/core/auto/classify.mjs +20 -8
  40. package/src/core/auto/runnable.mjs +28 -0
  41. package/src/core/billing.mjs +53 -0
  42. package/src/core/bridge/errors.mjs +77 -5
  43. package/src/core/bridge/openrouter.mjs +59 -0
  44. package/src/core/bridge/provider-ops.mjs +165 -9
  45. package/src/core/bridge/providers/endpoint.mjs +112 -7
  46. package/src/core/bridge/registry.mjs +13 -0
  47. package/src/core/bridge/server.mjs +16 -3
  48. package/src/core/bridge/telemetry.mjs +44 -12
  49. package/src/core/bridge/translate/common.mjs +31 -0
  50. package/src/core/bridge/translate/request.mjs +4 -1
  51. package/src/core/bridge/translate/response.mjs +3 -0
  52. package/src/core/bridge/translate/schema-keywords.mjs +75 -0
  53. package/src/core/bridge/translate/stream.mjs +50 -4
  54. package/src/core/bridge/upstream.mjs +102 -17
  55. package/src/core/broker-boot.mjs +57 -0
  56. package/src/core/broker-client.mjs +206 -0
  57. package/src/core/broker-guard.mjs +112 -0
  58. package/src/core/broker-routing.mjs +138 -0
  59. package/src/core/claude-auth.mjs +29 -0
  60. package/src/core/claude-runner.mjs +200 -9
  61. package/src/core/config.mjs +9 -12
  62. package/src/core/failure-policy.mjs +4 -2
  63. package/src/core/git-info.mjs +49 -5
  64. package/src/core/github-credentials.mjs +44 -1
  65. package/src/core/graph/script-runner.mjs +3 -1
  66. package/src/core/list-prices.mjs +29 -0
  67. package/src/core/mcp-secrets.mjs +80 -0
  68. package/src/core/metrics/sync.mjs +2 -1
  69. package/src/core/model-env.mjs +72 -0
  70. package/src/core/model-test.mjs +17 -5
  71. package/src/core/onboarding.mjs +12 -6
  72. package/src/core/openrouter-free.mjs +159 -0
  73. package/src/core/orchestrator.mjs +100 -12
  74. package/src/core/policy/effective.mjs +18 -1
  75. package/src/core/policy/local.mjs +4 -1
  76. package/src/core/policy/registry.mjs +12 -2
  77. package/src/core/preflight.mjs +116 -0
  78. package/src/core/recoverable-error.mjs +95 -0
  79. package/src/core/recovery-backoff.mjs +84 -0
  80. package/src/core/redact.mjs +25 -0
  81. package/src/core/run-context.mjs +6 -0
  82. package/src/core/run-harness.mjs +112 -30
  83. package/src/core/run-report.mjs +2 -1
  84. package/src/core/settings.mjs +90 -5
  85. package/src/core/title.mjs +7 -2
  86. package/src/core/web-allowlist.mjs +95 -0
  87. package/ui/public/app.js +244 -43
  88. package/ui/public/ask-model.mjs +1 -0
  89. package/ui/public/ask-panel.mjs +160 -23
  90. package/ui/public/bridge-view.mjs +175 -6
  91. package/ui/public/chat-settings-view.mjs +52 -1
  92. package/ui/public/credential-badges.mjs +63 -0
  93. package/ui/public/credentials-view.mjs +57 -0
  94. package/ui/public/index.html +33 -6
  95. package/ui/public/models-view.mjs +19 -1
  96. package/ui/public/openrouter-free-view.mjs +118 -0
  97. package/ui/public/stats-view.mjs +56 -0
  98. package/ui/public/style.css +99 -50
  99. package/ui/public/team-policy-view.mjs +2 -1
  100. package/ui/public/ws-seq.mjs +24 -0
  101. package/ui/server.mjs +319 -23
@@ -7,6 +7,7 @@
7
7
  import { openScheduleSheet, browserTimeZone } from './schedule-sheet.mjs';
8
8
  import { formatInstant, describeRule } from '../../src/shared/schedule/recurrence.mjs';
9
9
  import { createThreadModel } from './ask-model.mjs';
10
+ import { credentialBadge } from './credential-badges.mjs';
10
11
  import { createMarkdownRenderer } from './ask-markdown.mjs';
11
12
  import { createThinkingOrb } from './thinking-orb.mjs';
12
13
  import { workflowPickerLabel } from './results-view.mjs';
@@ -164,21 +165,22 @@ const PILL_MORPH_IN_MS = 520;
164
165
  const PILL_MORPH_OUT_MS = 800;
165
166
  const PILL_SETTLE_FALLBACK_MS = PILL_MORPH_OUT_MS + 150;
166
167
 
167
- export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContext, openNewPipeline, openComposer = null, loadMarkdown, hljsLoader, storage, raf, now, runStore = null }) {
168
- const storedPick = readStoredModel(); // hoisted declaration (defined below); null when nothing is stored
168
+ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContext, openNewPipeline, openComposer = null, openClaudeSetup = null, loadMarkdown, hljsLoader, storage, raf, now, runStore = null }) {
169
+ const homePick = browserPick(); // hoisted declaration (defined below)
169
170
  const st = {
170
171
  open: false,
171
172
  threadId: null,
172
173
  model: null, // createThreadModel for the active thread (Task 4+)
173
- picker: {
174
- model: storedPick && storedPick.model ? storedPick.model : FALLBACK_PICK.model,
175
- effort: storedPick ? storedPick.effort : FALLBACK_PICK.effort,
176
- },
174
+ picker: homePick.picker,
177
175
  // D11 provenance, tracked per slot: only a MODEL the user actually picked
178
176
  // outranks the backend default. An effort-only record leaves the model slot
179
177
  // unclaimed, so a later change to ASK_LIMITS.defaultModel still reaches here.
180
- pickerFromStore: !!(storedPick && storedPick.model),
181
- effortFromStore: storedPick !== null,
178
+ pickerFromStore: homePick.pickerFromStore,
179
+ effortFromStore: homePick.effortFromStore,
180
+ // The picker shows the open thread's own last pick (its row's model/effort).
181
+ // It outranks the backend default like a stored pick, but it is the chat's,
182
+ // not the browser's: nothing sourced from a thread reaches worca-cc.ask.model.
183
+ pickerFromThread: false,
182
184
  catalog: null,
183
185
  // #397: the thread's project/workspace scope. pinned:false = Auto (follow the
184
186
  // page — today's behaviour). label caches the display name once resolved.
@@ -237,11 +239,28 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
237
239
  } catch { /* storage unavailable */ }
238
240
  return null; // no stored pick — the catalog decides (D5/D6/D11)
239
241
  }
242
+ /** The browser-level pick — what a new chat starts with: the stored record over the cold-start literal, with each slot's provenance. */
243
+ function browserPick() {
244
+ const stored = readStoredModel();
245
+ return {
246
+ picker: {
247
+ model: stored && stored.model ? stored.model : FALLBACK_PICK.model,
248
+ effort: stored ? stored.effort : FALLBACK_PICK.effort,
249
+ },
250
+ pickerFromStore: !!(stored && stored.model),
251
+ effortFromStore: stored !== null,
252
+ };
253
+ }
240
254
  function storeModel() {
241
255
  // Provenance travels with the record: writing st.picker.model when the user never
242
256
  // chose one would pin the cold-start literal (or a default they merely saw), and
243
- // the backend would be authoritative exactly once per browser.
244
- const rec = { model: st.pickerFromStore ? st.picker.model : null, effort: st.picker.effort };
257
+ // the backend would be authoritative exactly once per browser. A thread's model
258
+ // is not the user's browser-level choice either: an effort picked on it leaves
259
+ // the record's model slot as it was.
260
+ const model = st.pickerFromThread
261
+ ? (readStoredModel() || { model: null }).model
262
+ : (st.pickerFromStore ? st.picker.model : null);
263
+ const rec = { model, effort: st.picker.effort };
245
264
  try { storage.setItem('worca-cc.ask.model', JSON.stringify(rec)); } catch { /* ignore */ }
246
265
  }
247
266
  function readStoredThread() { try { return storage.getItem('worca-cc.ask.thread') || null; } catch { return null; } }
@@ -1231,15 +1250,20 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1231
1250
  }
1232
1251
 
1233
1252
  function applyCatalogToPicker() {
1253
+ const list = st.catalog && Array.isArray(st.catalog.models) ? st.catalog.models : [];
1254
+ // A thread's model the catalog no longer has (a removed user model) falls back
1255
+ // to the browser-level pick, then the backend default. The thread is not
1256
+ // patched: its next send stores whatever is used.
1257
+ if (st.pickerFromThread && list.length && !catalogEntry(st.picker.model)) { restoreBrowserPick(); return; }
1234
1258
  const fallback = catalogDefault();
1235
1259
  // Each slot is decided by its own provenance: a stored MODEL outranks the backend
1236
1260
  // default, and a stored EFFORT survives even when the model comes from the default.
1237
1261
  // (effortFromStore ⊇ pickerFromStore — a stored model always carries its effort.)
1262
+ // A thread's pick claims both slots.
1238
1263
  const wanted = {
1239
- model: st.pickerFromStore ? st.picker.model : fallback.model,
1240
- effort: st.effortFromStore ? st.picker.effort : fallback.effort,
1264
+ model: st.pickerFromThread || st.pickerFromStore ? st.picker.model : fallback.model,
1265
+ effort: st.pickerFromThread || st.effortFromStore ? st.picker.effort : fallback.effort,
1241
1266
  };
1242
- const list = st.catalog && Array.isArray(st.catalog.models) ? st.catalog.models : [];
1243
1267
  const wantedEntry = catalogEntry(wanted.model);
1244
1268
  // Unknown stored/default id -> the backend default -> the first model we do
1245
1269
  // have that is not a hidden built-in (#422; a hidden id is still a valid pick).
@@ -1250,11 +1274,43 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1250
1274
  const changed = next.model !== st.picker.model || next.effort !== st.picker.effort;
1251
1275
  st.picker = next;
1252
1276
  // D11: persist ONLY a repair of a pick the user actually made. Writing the
1253
- // backend default here would make it authoritative exactly once, ever.
1254
- if (changed && st.pickerFromStore) storeModel();
1277
+ // backend default here would make it authoritative exactly once, ever — and a
1278
+ // thread's pick is never the browser's.
1279
+ if (changed && st.pickerFromStore && !st.pickerFromThread) storeModel();
1255
1280
  updatePickerButton();
1256
1281
  }
1257
1282
 
1283
+ /** Back to the browser-level pick (new chat, a thread with no model of its own). */
1284
+ function restoreBrowserPick() {
1285
+ const home = browserPick();
1286
+ st.picker = home.picker;
1287
+ st.pickerFromStore = home.pickerFromStore;
1288
+ st.effortFromStore = home.effortFromStore;
1289
+ st.pickerFromThread = false;
1290
+ if (st.catalog) applyCatalogToPicker();
1291
+ else updatePickerButton();
1292
+ }
1293
+
1294
+ /** A thread SWITCH shows that thread's last model/effort; a row without one gets the browser-level pick. */
1295
+ function applyThreadPick(thread) {
1296
+ const t = thread && typeof thread === 'object' ? thread : null;
1297
+ if (!t || typeof t.model !== 'string' || !t.model || typeof t.effort !== 'string' || !t.effort) { restoreBrowserPick(); return; }
1298
+ st.picker = { model: t.model, effort: t.effort };
1299
+ st.pickerFromThread = true;
1300
+ if (st.catalog) applyCatalogToPicker(); // effort coerced against the entry; a dropped model falls back
1301
+ else updatePickerButton(); // the raw id shows until the catalog lands
1302
+ }
1303
+
1304
+ /** Keep the pick on the open thread, so switching away and back finds it before any send. */
1305
+ function persistThreadPick() {
1306
+ const id = st.threadId;
1307
+ if (!id) return; // a brand-new chat: the first send stores it
1308
+ const body = JSON.stringify({ model: st.picker.model, effort: st.picker.effort });
1309
+ Promise.resolve()
1310
+ .then(() => fetch(`/api/ask/threads/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body }))
1311
+ .catch(() => { /* the next message stores the pick anyway */ });
1312
+ }
1313
+
1258
1314
  function loadCatalog() {
1259
1315
  if (st.catalog) return Promise.resolve(st.catalog);
1260
1316
  if (st.catalogLoading) return st.catalogLoading;
@@ -1313,7 +1369,9 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1313
1369
  st.picker = { model: id, effort: coerceEffort(catalogEntry(id), st.picker.effort) };
1314
1370
  st.pickerFromStore = true; // an explicit model choice claims the slot (D11)
1315
1371
  st.effortFromStore = true;
1372
+ st.pickerFromThread = false; // the user's own pick now, not the thread's
1316
1373
  storeModel();
1374
+ persistThreadPick();
1317
1375
  updatePickerButton();
1318
1376
  closePopover({ focusTrigger: false });
1319
1377
  focusComposer();
@@ -1323,6 +1381,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1323
1381
  st.picker = { ...st.picker, effort };
1324
1382
  st.effortFromStore = true; // the effort only — the model slot is untouched (D11)
1325
1383
  storeModel();
1384
+ persistThreadPick();
1326
1385
  updatePickerButton();
1327
1386
  closePopover({ focusTrigger: false });
1328
1387
  focusComposer();
@@ -1355,6 +1414,9 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1355
1414
  if (m.needsSignIn) {
1356
1415
  item.appendChild(tag('needs sign-in', 'is-err', m.signInMessage || 'The provider behind this model is not usable yet — Settings › Models › Providers.'));
1357
1416
  }
1417
+ // Credential broker: whether the signed-in person has the key this model spends from.
1418
+ const cb = credentialBadge(m.id);
1419
+ if (cb) item.appendChild(tag(cb.text, cb.missing ? 'is-err' : 'is-key', cb.title));
1358
1420
  if (m.id === st.picker.model) item.appendChild(make('span', 'ask-model-check', '✓'));
1359
1421
  return item;
1360
1422
  };
@@ -1640,6 +1702,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
1640
1702
  storeThread(null);
1641
1703
  el.title.textContent = 'Ask Worca';
1642
1704
  applyThreadScope(null); // #397: a brand-new chat starts on Auto
1705
+ restoreBrowserPick(); // …and on the browser-level pick, not the last chat's
1643
1706
  pruneCardEls(); // st.model is already null — renderTranscript's keep set cannot see the old ids
1644
1707
  renderTranscript();
1645
1708
  updateMeters();
@@ -2404,6 +2467,55 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
2404
2467
  return { el: rootEl };
2405
2468
  }
2406
2469
 
2470
+ /** The web card (propose_web_access): proposed → applied | failed, or declined. The exact URL is shown, so a
2471
+ * request that smuggles data is visible before the click. Every value is text. */
2472
+ function buildWebCard(block) {
2473
+ const card = block.card || {};
2474
+ const host = card.host || 'a website';
2475
+ if (block.state === 'declined') return { el: make('div', 'ask-card-stub', `Declined — ${card.summary || `Read ${host}`}`) };
2476
+ const rootEl = make('div', `ask-card ask-mcard ask-webcard is-${block.state}`);
2477
+ rootEl.setAttribute('data-ask-webcard', block.state);
2478
+ const result = card.result || null;
2479
+ const head = make('div', 'ask-mcard-head');
2480
+ const title = block.state === 'failed' ? 'Web access not granted'
2481
+ : block.state === 'applied' ? (result && result.scope === 'always' ? 'Always allowed' : 'Allowed for this chat')
2482
+ : 'Ask Worca wants to read a new site';
2483
+ head.appendChild(make('span', 'ask-mcard-title', title));
2484
+ head.appendChild(make('span', 'ask-mcard-kind', 'Web'));
2485
+ rootEl.appendChild(head);
2486
+ const body = make('div', 'ask-mcard-body');
2487
+ const sum = make('div', 'ask-mcard-summary');
2488
+ if (block.state === 'applied') sum.appendChild(svgIcon(WF_ICO.check, 15, 2.4));
2489
+ sum.appendChild(make('span', null, host));
2490
+ body.appendChild(sum);
2491
+ if (card.reason) body.appendChild(make('div', 'ask-mcard-note', card.reason));
2492
+ const ul = make('ul', 'ask-mcard-changes');
2493
+ const li = make('li');
2494
+ li.appendChild(make('span', 'ask-mcard-change-label', 'URL'));
2495
+ const val = make('span', 'ask-mcard-change-val');
2496
+ val.appendChild(make('span', 'ask-mcard-after', String(card.url || '')));
2497
+ li.appendChild(val);
2498
+ ul.appendChild(li);
2499
+ body.appendChild(ul);
2500
+ if (block.state === 'failed') body.appendChild(make('div', 'ask-mcard-failed', `Could not allow: ${block.error || (result && result.error) || 'unknown error'}`));
2501
+ rootEl.appendChild(body);
2502
+ rootEl.appendChild(make('div', 'ask-card-err'));
2503
+ if (block.state === 'proposed') {
2504
+ const actions = make('div', 'ask-mcard-actions');
2505
+ const btn = (cls, text, attr) => { const b = make('button', cls, text); b.type = 'button'; b.setAttribute(attr, ''); return b; };
2506
+ const decline = btn('ask-card-not-now', 'Deny', 'data-ask-web-decline');
2507
+ decline.addEventListener('click', () => postCard(block, rootEl, { state: 'declined' }, decline));
2508
+ const always = btn('ask-card-not-now', 'Always allow', 'data-ask-web-always');
2509
+ always.title = `Adds ${host} to Settings → Ask Worca → Web access`;
2510
+ always.addEventListener('click', () => postCard(block, rootEl, { state: 'applied', scope: 'always' }, always));
2511
+ const chat = btn('ask-card-start', 'Allow for this chat', 'data-ask-web-chat');
2512
+ chat.addEventListener('click', () => postCard(block, rootEl, { state: 'applied', scope: 'chat' }, chat));
2513
+ actions.append(make('span', 'ask-card-actions-spacer'), decline, always, chat);
2514
+ rootEl.appendChild(actions);
2515
+ }
2516
+ return { el: rootEl };
2517
+ }
2518
+
2407
2519
  /** The clone card (propose_clone_project): proposed → cloning → applied | failed, or declined. Every value is text. */
2408
2520
  function buildCloneCard(block) {
2409
2521
  const card = block.card || {};
@@ -2480,6 +2592,12 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
2480
2592
  closePopover({ focusTrigger: true });
2481
2593
  });
2482
2594
  item.appendChild(make('span', 'ask-model-name', m.label || m.id));
2595
+ const cb = credentialBadge(m.id);
2596
+ if (cb) {
2597
+ const t = make('span', `ask-model-tag ${cb.missing ? 'is-err' : 'is-key'}`, cb.text);
2598
+ t.title = cb.title;
2599
+ item.appendChild(t);
2600
+ }
2483
2601
  if (m.id === cur().model) item.appendChild(make('span', 'ask-model-check', '✓'));
2484
2602
  p.appendChild(item);
2485
2603
  }
@@ -3122,7 +3240,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
3122
3240
  function isProgressBlock(block) {
3123
3241
  const card = block.card || {};
3124
3242
  if (card.type === PROGRESS_CARD_TYPE) return true;
3125
- if (card.type === 'workflow' || card.type === 'metrics' || card.type === 'policy' || card.type === 'schedule' || card.type === 'model' || card.type === 'clone') return false;
3243
+ if (card.type === 'workflow' || card.type === 'metrics' || card.type === 'policy' || card.type === 'schedule' || card.type === 'model' || card.type === 'clone' || card.type === 'web') return false;
3126
3244
  return block.state === 'started' || (block.state === 'failed' && !!block.runId);
3127
3245
  }
3128
3246
  function buildCard(block) {
@@ -3134,14 +3252,16 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
3134
3252
  const isSchedule = !!(block.card && block.card.type === 'schedule');
3135
3253
  const isModel = !!(block.card && block.card.type === 'model');
3136
3254
  const isClone = !!(block.card && block.card.type === 'clone');
3255
+ const isWeb = !!(block.card && block.card.type === 'web');
3137
3256
  const isProgress = isProgressBlock(block);
3138
- if (cached && cached.state === block.state && (isWorkflow || isMetrics || isSchedule || isModel || isClone || isProgress || block.state === 'proposed')) return cached.el;
3257
+ if (cached && cached.state === block.state && (isWorkflow || isMetrics || isSchedule || isModel || isClone || isWeb || isProgress || block.state === 'proposed')) return cached.el;
3139
3258
  if (cached) disposeCardEntry(cached);
3140
3259
  const built = isWorkflow ? buildWorkflowCard(block, cached)
3141
3260
  : isMetrics ? buildMetricsCard(block)
3142
3261
  : isSchedule ? buildScheduleCard(block)
3143
3262
  : isModel ? buildModelCard(block)
3144
3263
  : isClone ? buildCloneCard(block)
3264
+ : isWeb ? buildWebCard(block)
3145
3265
  : isProgress ? buildProgressCard(block)
3146
3266
  : { el: block.state === 'proposed' ? buildCardForm(block) : buildCardTerminal(block) };
3147
3267
  st.cardEls.set(block.id, { el: built.el, state: block.state, handle: built.handle || null, dispose: built.dispose || null, animate: !!built.animate, cancelAnim: null, lastW: -1 });
@@ -3386,10 +3506,15 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
3386
3506
  // Stopped after — and nothing more while the turn is live: the orb row at the
3387
3507
  // bottom of the message owns the elapsed and the meter, and printing either
3388
3508
  // set twice is the noise this replaced. A turn that ended badly says so
3389
- // instead of Done; nothing else marks a stop.
3509
+ // instead of Done; nothing else marks a stop. A turn that ended before any
3510
+ // result, or within a few ms (a signed-out CLI answers in ~20 ms), has no
3511
+ // duration worth printing: plain Stopped, never a dangling "Stopped after"
3512
+ // or "Stopped after 0.0s".
3390
3513
  if (!isLive) {
3391
- if (stopped) parts.push(make('span', 'ask-activity-label', 'Stopped after'));
3392
- parts.push(make('span', 'ask-activity-elapsed', fmtElapsed(r.durationMs) || ''));
3514
+ const shown = fmtElapsed(r.durationMs);
3515
+ const elapsed = shown && shown !== '0.0s' ? shown : '';
3516
+ if (stopped) parts.push(make('span', 'ask-activity-label', elapsed ? 'Stopped after' : 'Stopped'));
3517
+ parts.push(make('span', 'ask-activity-elapsed', elapsed));
3393
3518
  parts.push(make('span', 'ask-activity-spacer'));
3394
3519
  const meter = [fmtCtx(r.usage && r.usage.ctx), fmtUsd(r.costUsd)].filter(Boolean).join(' · ');
3395
3520
  parts.push(make('span', 'ask-activity-meter', meter));
@@ -3515,7 +3640,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
3515
3640
  if (b.kind === 'notice') parts.push(`n:${b.id ?? ''}:${b.text || ''}:${b.href || ''}`);
3516
3641
  else if (b.kind === 'card') parts.push(`c:${b.id}:${b.state || ''}:${(b.card && b.card.type) || ''}:${b.runId || ''}:${b.error || ''}`);
3517
3642
  }
3518
- parts.push(`r:${row.status || ''}:${row.errorMessage || ''}:${isLiveRow(row) ? 1 : 0}`);
3643
+ parts.push(`r:${row.status || ''}:${row.errorMessage || ''}:${row.errorCode || ''}:${isLiveRow(row) ? 1 : 0}`);
3519
3644
  return parts.join('|');
3520
3645
  }
3521
3646
 
@@ -3565,7 +3690,15 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
3565
3690
  }
3566
3691
  if (cur.status === 'error') {
3567
3692
  const explained = (cur.blocks || []).some((b) => b && b.kind === 'notice');
3568
- if (cur.errorMessage) wrap.appendChild(make('div', 'ask-error-line', cur.errorMessage));
3693
+ if (cur.errorCode === 'claude-signed-out' && typeof openClaudeSetup === 'function') {
3694
+ // The CLI's raw "Not logged in" → one line whose link opens Connect Claude Code.
3695
+ const line = make('div', 'ask-error-line', "Claude Code isn't signed in. ");
3696
+ const link = make('a', '', 'Sign in…');
3697
+ link.href = '#';
3698
+ link.addEventListener('click', (e) => { e.preventDefault(); openClaudeSetup(); });
3699
+ line.appendChild(link);
3700
+ wrap.appendChild(line);
3701
+ } else if (cur.errorMessage) wrap.appendChild(make('div', 'ask-error-line', cur.errorMessage));
3569
3702
  else if (!explained) wrap.appendChild(make('div', 'ask-error-line', 'This turn ended with an error.'));
3570
3703
  }
3571
3704
  if (isLiveRow(cur)) {
@@ -3643,12 +3776,16 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
3643
3776
  // A SWITCH starts a fresh ledger, so the new chat rises in. A resync or a
3644
3777
  // reconnect re-loads the SAME thread and must keep it: those repaint rows the
3645
3778
  // user is already reading, mid-turn.
3646
- if (st.threadId !== id) st.seenRows = new Set();
3779
+ const switched = st.threadId !== id;
3780
+ if (switched) st.seenRows = new Set();
3647
3781
  st.threadId = id;
3648
3782
  st.model = createThreadModel({ threadId: id });
3649
3783
  st.model.load(snap);
3650
3784
  el.title.textContent = (snap.thread && snap.thread.title) || 'Ask Worca';
3651
3785
  applyThreadScope(snap.thread && snap.thread.context); // #397: restore the pin
3786
+ // The picker follows the chat — on a SWITCH only: a resync of the same thread
3787
+ // would otherwise clobber a pick the user just made (its PATCH may not have landed).
3788
+ if (switched) applyThreadPick(snap.thread);
3652
3789
  renderTranscript();
3653
3790
  updateMeters();
3654
3791
  // P4: the count rides the snapshot loadThread ALREADY fetched — no extra GET.
@@ -74,7 +74,10 @@ export function degradationLine(m) {
74
74
  const limit = caps.maxPromptTokens ? `, prompt limit ~${Math.round(caps.maxPromptTokens / 1000)}k tokens` : '';
75
75
  const lead = m.upstream.api === 'openai-responses'
76
76
  ? 'translated — reasoning summaries only, no WebSearch/WebFetch'
77
- : 'translated — no thinking blocks, no WebSearch/WebFetch';
77
+ : caps.reasoning
78
+ // A reasoning model's streamed reasoning arrives as thinking; chat completions takes none back.
79
+ ? 'translated — reasoning shown but not carried across turns, no WebSearch/WebFetch'
80
+ : 'translated — no thinking blocks, no WebSearch/WebFetch';
78
81
  return `${lead}${limit}`;
79
82
  }
80
83
 
@@ -148,6 +151,19 @@ export function renderProvidersCard(providers, { doc = globalThis.document, sign
148
151
  }
149
152
  root.appendChild(h(doc, 'small', 'hint mv-providers-hint',
150
153
  "Providers let Worca run models that don't speak the Anthropic API — through its own in-process bridge. Sign in or set a key once; then import or add models on the Models tab and pick them anywhere a model is picked."));
154
+ // Credential broker: keys and the Copilot sign-in are per person, on the broker's key page.
155
+ // This card keeps the shared settings (base URLs, concurrency, the Copilot notice, imports);
156
+ // its key fields, sign-in and key test are hidden (style.css .mv-providers--broker).
157
+ if (p.broker && p.broker.enabled) {
158
+ root.classList.add('mv-providers--broker');
159
+ const note = h(doc, 'p', 'hint mv-broker-note', 'Keys and the GitHub Copilot sign-in are held by the credential broker: each person adds their own on the key page. Imports list models with your own key.');
160
+ if (p.broker.keyPage) {
161
+ const a = h(doc, 'a', 'mv-broker-link', 'Open the key page');
162
+ a.href = p.broker.keyPage; a.target = '_blank'; a.rel = 'noopener';
163
+ note.append(' ', a);
164
+ }
165
+ root.appendChild(note);
166
+ }
151
167
  /** One provider's row, in its own card when the tab hosts it. */
152
168
  const place = (row, title) => {
153
169
  if (!split) { root.appendChild(row); return; }
@@ -198,8 +214,10 @@ export function renderProvidersCard(providers, { doc = globalThis.document, sign
198
214
 
199
215
  const cpBtns = h(doc, 'div', 'mv-pv-btns');
200
216
  const imp = h(doc, 'button', 'btn-ghost mv-cp-fetch-models', 'Import models…');
201
- imp.type = 'button'; imp.disabled = !c.connected;
202
- if (!c.connected) imp.title = 'Sign in first';
217
+ // With the broker, the import runs with the viewer's own Copilot sign-in (on the key page).
218
+ const brokered = !!(p.broker && p.broker.enabled);
219
+ imp.type = 'button'; imp.disabled = !c.connected && !brokered;
220
+ if (imp.disabled) imp.title = 'Sign in first';
203
221
  cpBtns.appendChild(imp);
204
222
  const quotaBtn = h(doc, 'button', 'btn-ghost mv-cp-quota-refresh', 'Refresh usage');
205
223
  quotaBtn.type = 'button'; quotaBtn.disabled = !c.connected;
@@ -245,6 +263,16 @@ export function renderProvidersCard(providers, { doc = globalThis.document, sign
245
263
  buIn.type = 'text'; buIn.value = k.baseUrl || ''; buIn.dataset.provider = name;
246
264
  buIn.placeholder = name === 'openai' ? 'https://api.openai.com/v1' : 'https://api.anthropic.com';
247
265
  bu.appendChild(buIn);
266
+ if (name === 'openai') {
267
+ // One click to a hosted catalog: fills the URL (and a ${VAR} key when none is set), then the
268
+ // user Tests and Saves as for any endpoint. Nothing is stored until Save.
269
+ const pre = h(doc, 'small', 'hint mv-pv-presets', 'Preset: ');
270
+ const or = h(doc, 'button', 'link-btn mv-pv-preset', 'OpenRouter');
271
+ or.type = 'button'; or.dataset.provider = name; or.dataset.preset = 'openrouter';
272
+ or.title = `Point this provider at OpenRouter (${OPENROUTER_PRESET.baseUrl})`;
273
+ pre.appendChild(or);
274
+ bu.appendChild(pre);
275
+ }
248
276
  ctl.appendChild(bu);
249
277
  const key = h(doc, 'label', 'mv-field');
250
278
  key.appendChild(h(doc, 'span', 'mv-field-label', 'API key'));
@@ -282,6 +310,25 @@ export function renderProvidersCard(providers, { doc = globalThis.document, sign
282
310
  return root;
283
311
  }
284
312
 
313
+ /** The OpenRouter preset (docs/models.md "OpenRouter"); mirrors provider-ops OPENROUTER_BASE_URL. */
314
+ export const OPENROUTER_PRESET = Object.freeze({ baseUrl: 'https://openrouter.ai/api/v1', keyRef: '${OPENROUTER_KEY}' });
315
+
316
+ /**
317
+ * Apply a preset to a provider row's fields — the base URL always, the key only when the row has
318
+ * none (a stored or typed key is never replaced). Returns whether anything changed.
319
+ */
320
+ export function applyProviderPreset(rootEl, name, preset) {
321
+ const p = preset === 'openrouter' ? OPENROUTER_PRESET : null;
322
+ const row = p && rootEl.querySelector(`.mv-pv-row[data-provider="${name}"]`);
323
+ if (!row) return false;
324
+ const bu = row.querySelector('.mv-pv-baseurl');
325
+ const key = row.querySelector('.mv-pv-key');
326
+ let changed = false;
327
+ if (bu && bu.value.trim() !== p.baseUrl) { bu.value = p.baseUrl; changed = true; }
328
+ if (key && !key.value.trim()) { key.value = p.keyRef; changed = true; }
329
+ return changed;
330
+ }
331
+
285
332
  /** Collect a key-based provider row into a PATCH body (masked echo dropped). */
286
333
  export function collectProviderRow(rootEl, name) {
287
334
  const row = rootEl.querySelector(`.mv-pv-row[data-provider="${name}"]`);
@@ -398,12 +445,38 @@ export function renderImportSheet(models, { doc = globalThis.document } = {}) {
398
445
  export function renderEndpointSheet(payload, { doc = globalThis.document } = {}) {
399
446
  const p = payload || {};
400
447
  const list = Array.isArray(p.models) ? p.models : [];
448
+ const hosted = p.server === 'openrouter';
401
449
  const root = h(doc, 'section', 'card mv-editor mvi mvi-ep');
402
450
  root.dataset.source = 'endpoint';
403
451
  root.dataset.baseurl = p.baseUrl || '';
452
+ if (p.server) root.dataset.server = p.server;
404
453
  root.appendChild(h(doc, 'h3', 'mv-editor-title', `Import from ${p.serverLabel || 'this endpoint'}`));
405
- root.appendChild(h(doc, 'small', 'hint', `${p.baseUrl || ''} — these run through the translation layer (no thinking blocks, no web tools) and are priced free: a model on your own machine bills nothing. Worca pins the prompt limit only when the server reports the window it really serves.`));
454
+ root.appendChild(h(doc, 'small', 'hint', hosted
455
+ ? `${p.baseUrl || ''} — these run through the translation layer (no web tools). Worca reads each model's window, output cap, tool and reasoning support and its listed price, and pins them on the entry.`
456
+ : `${p.baseUrl || ''} — these run through the translation layer (no thinking blocks, no web tools) and are priced free: a model on your own machine bills nothing. Worca pins the prompt limit only when the server reports the window it really serves.`));
406
457
  for (const w of Array.isArray(p.warnings) ? p.warnings : []) root.appendChild(h(doc, 'small', 'hint mvi-warn', w));
458
+ // A hosted catalog lists hundreds of models: beside the dialog's text filter, narrow by what a
459
+ // pipeline decides on. The controls only mark the view — endpointRowMatches reads them.
460
+ if (hosted && list.length) {
461
+ const bar = h(doc, 'div', 'chip-select mvi-or-filters');
462
+ const chip = (cls, text) => {
463
+ const l = h(doc, 'label', 'mvi-chip');
464
+ const cb = h(doc, 'input', cls); cb.type = 'checkbox';
465
+ l.appendChild(cb); l.appendChild(h(doc, 'span', null, text));
466
+ bar.appendChild(l);
467
+ };
468
+ chip('mvi-f-free', 'Free');
469
+ chip('mvi-f-tools', 'Tools');
470
+ const ctxL = h(doc, 'label', 'mvi-chip');
471
+ ctxL.appendChild(h(doc, 'span', null, 'Window ≥'));
472
+ const ctx = h(doc, 'select', 'select mvi-f-ctx');
473
+ for (const [v, t] of [['0', 'any'], ['65536', '64k'], ['131072', '128k'], ['262144', '256k'], ['1000000', '1M']]) {
474
+ const o = h(doc, 'option', null, t); o.value = v; ctx.appendChild(o);
475
+ }
476
+ ctxL.appendChild(ctx);
477
+ bar.appendChild(ctxL);
478
+ root.appendChild(bar);
479
+ }
407
480
  if (!list.length) {
408
481
  root.appendChild(h(doc, 'div', 'hist-empty', 'This endpoint lists no models.'));
409
482
  } else {
@@ -421,6 +494,9 @@ export function renderEndpointSheet(payload, { doc = globalThis.document } = {})
421
494
  for (const m of list) {
422
495
  const tr = h(doc, 'tr');
423
496
  tr.dataset.id = m.id;
497
+ tr.dataset.free = m.free ? '1' : '0';
498
+ tr.dataset.tools = m.toolCalls === true ? '1' : '0';
499
+ tr.dataset.ctx = String(m.servedContext || 0);
424
500
  const blocked = m.importable === false;
425
501
  if (blocked) tr.className = 'mvi-disabled';
426
502
  const cbTd = h(doc, 'td');
@@ -470,6 +546,21 @@ export function collectImportSheet(rootEl) {
470
546
  return [...rootEl.querySelectorAll('.mvi-cb')].filter((c) => c.checked && !c.disabled).map((c) => c.value);
471
547
  }
472
548
 
549
+ /**
550
+ * Whether an import-sheet row passes the dialog's text query and, on a hosted sheet, its Free /
551
+ * Tools / window filters. A view filter only — the tick on a hidden row is kept. Pure over the DOM.
552
+ */
553
+ export function endpointRowMatches(tr, sheet, query = '') {
554
+ const q = String(query || '').trim().toLowerCase();
555
+ if (q && !tr.textContent.toLowerCase().includes(q) && !String(tr.dataset.id || '').toLowerCase().includes(q)) return false;
556
+ const bar = sheet && sheet.querySelector('.mvi-or-filters');
557
+ if (!bar) return true;
558
+ if (bar.querySelector('.mvi-f-free')?.checked && tr.dataset.free !== '1') return false;
559
+ if (bar.querySelector('.mvi-f-tools')?.checked && tr.dataset.tools !== '1') return false;
560
+ const min = Number(bar.querySelector('.mvi-f-ctx')?.value || 0);
561
+ return !(min > 0 && Number(tr.dataset.ctx || 0) < min);
562
+ }
563
+
473
564
  /** The select-all box: tick every importable row. */
474
565
  export function applyImportSelectAll(rootEl, on) {
475
566
  for (const c of rootEl.querySelectorAll('.mvi-cb')) if (!c.disabled) c.checked = !!on;
@@ -477,6 +568,50 @@ export function applyImportSelectAll(rootEl, on) {
477
568
 
478
569
  // ── Editor: Connection section (§8.3) ───────────────────────────────────────
479
570
 
571
+ /** Whether a base URL is OpenRouter's (mirrors model-env.mjs isOpenRouterBaseUrl; this module imports nothing). */
572
+ export function isOpenRouterUrl(v) {
573
+ if (typeof v !== 'string' || !v.trim()) return false;
574
+ let host;
575
+ try { host = new URL(v.trim()).hostname.toLowerCase(); } catch { return false; }
576
+ return host === 'openrouter.ai' || host.endsWith('.openrouter.ai');
577
+ }
578
+
579
+ /** Whether the Connection form resolves to OpenRouter chat completions: its own base URL, else the provider's. */
580
+ function openRouterApplies(conn) {
581
+ if ((conn.querySelector('.mv-conn-mode-rb:checked')?.value || 'direct') !== 'provider') return false;
582
+ if (conn.querySelector('.mv-conn-provider')?.value !== 'openai' || conn.querySelector('.mv-conn-api')?.value !== 'openai-chat') return false;
583
+ const own = (conn.querySelector('.mv-conn-baseurl')?.value || '').trim();
584
+ return isOpenRouterUrl(own || conn._providers?.openai?.baseUrl || '');
585
+ }
586
+
587
+ /** Load an `upstream.openrouter` block (or nothing: the defaults) into the routing fields. */
588
+ function fillOpenRouter(root, or) {
589
+ const o = or || {};
590
+ const p = o.provider || {};
591
+ const set = (sel, v) => { const el = root.querySelector(sel); if (el) el.value = v; };
592
+ set('.mv-conn-or-models', Array.isArray(o.models) ? o.models.join(', ') : '');
593
+ set('.mv-conn-or-order', Array.isArray(p.order) ? p.order.join(', ') : '');
594
+ set('.mv-conn-or-sort', p.sort || '');
595
+ const fb = root.querySelector('.mv-conn-or-fallbacks');
596
+ if (fb) fb.checked = p.allow_fallbacks !== false; // OpenRouter's default is on
597
+ }
598
+
599
+ /** The routing fields as an `upstream.openrouter` block, or undefined when all are at their defaults. */
600
+ function collectOpenRouter(conn) {
601
+ const list = (sel) => (conn.querySelector(sel)?.value || '').split(',').map((s) => s.trim()).filter(Boolean);
602
+ const out = {};
603
+ const models = list('.mv-conn-or-models');
604
+ if (models.length) out.models = models;
605
+ const provider = {};
606
+ const order = list('.mv-conn-or-order');
607
+ if (order.length) provider.order = order;
608
+ if (conn.querySelector('.mv-conn-or-fallbacks')?.checked === false) provider.allow_fallbacks = false;
609
+ const sort = conn.querySelector('.mv-conn-or-sort')?.value || '';
610
+ if (sort) provider.sort = sort;
611
+ if (Object.keys(provider).length) out.provider = provider;
612
+ return Object.keys(out).length ? out : undefined;
613
+ }
614
+
480
615
  /**
481
616
  * The Connection block for the model editor. `model` is the MASKED entry or
482
617
  * null; `providers` the GET /api/providers payload (for the readiness hints);
@@ -552,7 +687,13 @@ export function renderConnectionSection(model, { doc = globalThis.document, prov
552
687
 
553
688
  // Advanced: per-entry overrides for the key-based providers.
554
689
  const adv = h(doc, 'details', 'advanced mv-conn-adv');
555
- const sum = h(doc, 'summary', null, 'Advanced — base URL, API key, extra headers');
690
+ // Same summary as New pipeline's Advanced (index.html #advanced-config): the chevron is
691
+ // what says "expandable" — without it the label reads as a plain heading.
692
+ const sum = h(doc, 'summary');
693
+ const chev = h(doc, 'span', 'adv-chev');
694
+ chev.setAttribute('aria-hidden', 'true');
695
+ sum.appendChild(chev);
696
+ sum.appendChild(h(doc, 'span', null, 'Advanced — base URL, API key, extra headers'));
556
697
  adv.appendChild(sum);
557
698
  const advBody = h(doc, 'div', 'advanced-body mv-conn-adv-body');
558
699
  const bu = h(doc, 'input', 'input mv-conn-baseurl');
@@ -567,6 +708,28 @@ export function renderConnectionSection(model, { doc = globalThis.document, prov
567
708
  hdr.rows = 2; hdr.placeholder = 'One per line: Header-Name: value';
568
709
  hdr.value = upstream && upstream.headers ? Object.entries(upstream.headers).map(([k, v]) => `${k}: ${v}`).join('\n') : '';
569
710
  advBody.appendChild(field('Extra headers', hdr));
711
+ // OpenRouter routing: shown only while the entry resolves to OpenRouter (applyConnectionMode).
712
+ const orBox = h(doc, 'div', 'mv-conn-or');
713
+ orBox.appendChild(h(doc, 'small', 'hint', 'OpenRouter routing — fallback models are tried in order when this one is rate-limited or down (a :free model’s shared pool often is); provider order and sort pick who serves it.'));
714
+ const orModels = h(doc, 'input', 'input mv-conn-or-models');
715
+ orModels.type = 'text'; orModels.placeholder = 'e.g. qwen/qwen3.8-27b (comma-separated)';
716
+ orBox.appendChild(field('Fallback models', orModels));
717
+ const orOrder = h(doc, 'input', 'input mv-conn-or-order');
718
+ orOrder.type = 'text'; orOrder.placeholder = 'provider names, comma-separated — empty lets OpenRouter choose';
719
+ orBox.appendChild(field('Provider order', orOrder));
720
+ const orSort = h(doc, 'select', 'select mv-conn-or-sort');
721
+ for (const [value, text] of [['', 'OpenRouter default'], ['price', 'Price'], ['throughput', 'Throughput'], ['latency', 'Latency']]) {
722
+ const o = h(doc, 'option', null, text); o.value = value; orSort.appendChild(o);
723
+ }
724
+ orBox.appendChild(field('Sort providers by', orSort));
725
+ const orFbLab = h(doc, 'label', 'mv-conn-cap');
726
+ const orFb = h(doc, 'input', 'mv-conn-or-fallbacks');
727
+ orFb.type = 'checkbox';
728
+ orFbLab.appendChild(orFb);
729
+ orFbLab.appendChild(h(doc, 'span', null, 'Allow other providers when these are unavailable'));
730
+ orBox.appendChild(orFbLab);
731
+ advBody.appendChild(orBox);
732
+ fillOpenRouter(orBox, upstream && upstream.openrouter);
570
733
  adv.appendChild(advBody);
571
734
  body.appendChild(adv);
572
735
 
@@ -640,6 +803,8 @@ export function applyConnectionMode(connEl) {
640
803
  const api = apiSel ? apiSel.value : '';
641
804
  const adv = conn.querySelector('.mv-conn-adv');
642
805
  if (adv) adv.hidden = provider === 'copilot';
806
+ const orBox = conn.querySelector('.mv-conn-or');
807
+ if (orBox) orBox.hidden = !openRouterApplies(conn);
643
808
  const hint = conn.querySelector('.mv-conn-provider-hint');
644
809
  const p = conn._providers;
645
810
  if (hint) {
@@ -652,7 +817,7 @@ export function applyConnectionMode(connEl) {
652
817
  if (note) {
653
818
  const copilotPricing = provider === 'copilot' ? ' Copilot bills premium requests, so Pricing defaults to Free.' : '';
654
819
  note.textContent = mode !== 'provider' ? '' : api === 'openai-chat'
655
- ? `Translated: no thinking blocks, WebSearch/WebFetch withheld, prompt limit per the capabilities above.${copilotPricing}`
820
+ ? `Translated: reasoning the endpoint streams (OpenRouter, vLLM) is shown as thinking but not carried across turns, WebSearch/WebFetch withheld, prompt limit per the capabilities above.${copilotPricing}`
656
821
  : api === 'openai-responses'
657
822
  ? `Translated to the Responses API: reasoning summaries arrive as thinking, WebSearch/WebFetch withheld, prompt limit per the capabilities above.${copilotPricing}`
658
823
  : provider === 'copilot' ? 'Copilot’s native Anthropic endpoint: thinking blocks and cache accounting arrive intact. Copilot bills premium requests, so Pricing defaults to Free.'
@@ -704,6 +869,7 @@ export function setModelUpstream(connEl, upstream) {
704
869
  const bu = conn.querySelector('.mv-conn-baseurl'); if (bu) bu.value = upstream.baseUrl || '';
705
870
  const key = conn.querySelector('.mv-conn-key'); if (key) { key.value = upstream.apiKey || ''; if (key.value) key.dataset.original = key.value; }
706
871
  const hdr = conn.querySelector('.mv-conn-headers'); if (hdr) hdr.value = upstream.headers ? Object.entries(upstream.headers).map(([k, v]) => `${k}: ${v}`).join('\n') : '';
872
+ fillOpenRouter(conn, upstream.openrouter);
707
873
  const caps = upstream.capabilities || {};
708
874
  for (const cb of conn.querySelectorAll('.mv-conn-cap-cb')) cb.checked = caps[cb.dataset.cap] === undefined ? cb.dataset.cap === 'toolCalls' : !!caps[cb.dataset.cap];
709
875
  for (const inp of conn.querySelectorAll('.mv-conn-limit-in')) inp.value = caps[inp.dataset.limit] ? String(caps[inp.dataset.limit]) : '';
@@ -737,6 +903,9 @@ export function collectConnection(connEl) {
737
903
  if (i > 0) headers[l.slice(0, i).trim()] = l.slice(i + 1).trim();
738
904
  }
739
905
  if (Object.keys(headers).length) upstream.headers = headers;
906
+ // Only while the entry resolves to OpenRouter: another endpoint would reject the fields.
907
+ const or = openRouterApplies(conn) ? collectOpenRouter(conn) : undefined;
908
+ if (or) upstream.openrouter = or;
740
909
  }
741
910
  const capabilities = {};
742
911
  for (const cb of conn.querySelectorAll('.mv-conn-cap-cb')) capabilities[cb.dataset.cap] = !!cb.checked;