@worca/app 1.5.0 → 1.6.0-rc.2

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 +8 -2
  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 +56 -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 +202 -10
  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 +271 -54
  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 +216 -9
  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 +303 -244
  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 +110 -54
  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
@@ -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
 
@@ -103,11 +106,48 @@ export function needsSignInPill(m, { doc = globalThis.document } = {}) {
103
106
  // ── Providers card (§8.1) ────────────────────────────────────────────────────
104
107
 
105
108
  function providerStatePill(doc, c) {
106
- if (!c.termsCurrent) return h(doc, 'span', 'badge grey', 'sign-in blocked until acknowledged');
109
+ if (!c.termsCurrent) {
110
+ // What unblocks sign-in, in the "Read notice" button's own words — and it opens that notice.
111
+ const pill = h(doc, 'button', 'badge grey mv-cp-terms mv-cp-terms-pill',
112
+ c.acknowledgedTerms ? 'please re-read the updated notice' : 'please read the notice first');
113
+ pill.type = 'button';
114
+ pill.title = 'Sign-in is blocked until you acknowledge the GitHub Copilot notice. Click to read it.';
115
+ return pill;
116
+ }
107
117
  if (!c.connected) return h(doc, 'span', 'badge grey', 'not connected');
108
118
  return h(doc, 'span', 'badge green', `connected${c.login ? ` as @${c.login}` : ''}`);
109
119
  }
110
120
 
121
+ /**
122
+ * With the credential broker: the viewer's own state in the slot `name` spends from (key page),
123
+ * not worca's settings, which hold no key. A missing key links to the key page.
124
+ */
125
+ function brokerPill(doc, b, name) {
126
+ const p = (b.providers || {})[name] || {};
127
+ const what = name === 'copilot' ? 'sign-in' : 'key';
128
+ if (b.signInNeeded) return h(doc, 'span', 'badge grey', `sign in to see your ${what}`);
129
+ if (p.state === 'none') {
130
+ const x = h(doc, 'span', 'badge red', 'no broker slot for this URL');
131
+ x.title = p.error || 'The credential broker has no slot for this base URL.';
132
+ return x;
133
+ }
134
+ if (p.state === 'keyless') return h(doc, 'span', 'badge green', 'local — no key needed');
135
+ if (b.error) {
136
+ const x = h(doc, 'span', 'badge grey', 'key page unreachable');
137
+ x.title = b.error;
138
+ return x;
139
+ }
140
+ if (p.state === 'set') return h(doc, 'span', 'badge green', `your ${what}${p.suffix ? ` ••••${p.suffix}` : ''}`);
141
+ if (p.state === 'operator') return h(doc, 'span', 'badge green', 'team key');
142
+ const label = p.state === 'invalid' ? `your ${what} was rejected` : `add your ${what} on the key page`;
143
+ const cls = p.state === 'invalid' ? 'badge red' : 'badge grey';
144
+ if (!b.keyPage) return h(doc, 'span', cls, label);
145
+ const a = h(doc, 'a', `${cls} mv-broker-pill`, label);
146
+ a.href = b.keyPage; a.target = '_blank'; a.rel = 'noopener';
147
+ a.title = 'Opens the key page in a new tab. Reopen this tab afterwards to see the change.';
148
+ return a;
149
+ }
150
+
111
151
  function quotaLine(q) {
112
152
  if (!q) return '';
113
153
  if (q.unlimited) return 'Premium requests: unlimited on this plan';
@@ -148,6 +188,19 @@ export function renderProvidersCard(providers, { doc = globalThis.document, sign
148
188
  }
149
189
  root.appendChild(h(doc, 'small', 'hint mv-providers-hint',
150
190
  "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."));
191
+ // Credential broker: keys and the Copilot sign-in are per person, on the broker's key page.
192
+ // This card keeps the shared settings (base URLs, concurrency, the Copilot notice, imports);
193
+ // its key fields, sign-in and key test are hidden (style.css .mv-providers--broker).
194
+ if (p.broker && p.broker.enabled) {
195
+ root.classList.add('mv-providers--broker');
196
+ 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.');
197
+ if (p.broker.keyPage) {
198
+ const a = h(doc, 'a', 'mv-broker-link', 'Open the key page');
199
+ a.href = p.broker.keyPage; a.target = '_blank'; a.rel = 'noopener';
200
+ note.append(' ', a);
201
+ }
202
+ root.appendChild(note);
203
+ }
151
204
  /** One provider's row, in its own card when the tab hosts it. */
152
205
  const place = (row, title) => {
153
206
  if (!split) { root.appendChild(row); return; }
@@ -165,7 +218,9 @@ export function renderProvidersCard(providers, { doc = globalThis.document, sign
165
218
  const cpMain = h(doc, 'div', 'mv-pv-main');
166
219
  const cpHead = h(doc, 'div', 'mv-head');
167
220
  cpHead.appendChild(h(doc, 'b', 'mv-name', 'GitHub Copilot'));
168
- cpHead.appendChild(providerStatePill(doc, c));
221
+ const brokered = !!(p.broker && p.broker.enabled);
222
+ // The notice gates Copilot models either way; past it, the broker's view of your sign-in.
223
+ cpHead.appendChild(brokered && c.termsCurrent ? brokerPill(doc, p.broker, 'copilot') : providerStatePill(doc, c));
169
224
  cpMain.appendChild(cpHead);
170
225
  cpMain.appendChild(h(doc, 'small', 'hint',
171
226
  c.acknowledgedTerms
@@ -198,8 +253,9 @@ export function renderProvidersCard(providers, { doc = globalThis.document, sign
198
253
 
199
254
  const cpBtns = h(doc, 'div', 'mv-pv-btns');
200
255
  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';
256
+ // With the broker, the import runs with the viewer's own Copilot sign-in (on the key page).
257
+ imp.type = 'button'; imp.disabled = !c.connected && !brokered;
258
+ if (imp.disabled) imp.title = 'Sign in first';
203
259
  cpBtns.appendChild(imp);
204
260
  const quotaBtn = h(doc, 'button', 'btn-ghost mv-cp-quota-refresh', 'Refresh usage');
205
261
  quotaBtn.type = 'button'; quotaBtn.disabled = !c.connected;
@@ -227,7 +283,7 @@ export function renderProvidersCard(providers, { doc = globalThis.document, sign
227
283
  const main = h(doc, 'div', 'mv-pv-main');
228
284
  const rh = h(doc, 'div', 'mv-head');
229
285
  rh.appendChild(h(doc, 'b', 'mv-name', PROVIDER_LABELS[name]));
230
- rh.appendChild(k.configured ? h(doc, 'span', 'badge green', 'key set')
286
+ rh.appendChild(p.broker && p.broker.enabled ? brokerPill(doc, p.broker, name) : k.configured ? h(doc, 'span', 'badge green', 'key set')
231
287
  : k.keySet ? h(doc, 'span', 'badge red', 'key ${VAR} not set')
232
288
  : k.keyOptional ? h(doc, 'span', 'badge green', 'local — no key needed') : h(doc, 'span', 'badge grey', 'no key'));
233
289
  main.appendChild(rh);
@@ -245,6 +301,16 @@ export function renderProvidersCard(providers, { doc = globalThis.document, sign
245
301
  buIn.type = 'text'; buIn.value = k.baseUrl || ''; buIn.dataset.provider = name;
246
302
  buIn.placeholder = name === 'openai' ? 'https://api.openai.com/v1' : 'https://api.anthropic.com';
247
303
  bu.appendChild(buIn);
304
+ if (name === 'openai') {
305
+ // One click to a hosted catalog: fills the URL (and a ${VAR} key when none is set), then the
306
+ // user Tests and Saves as for any endpoint. Nothing is stored until Save.
307
+ const pre = h(doc, 'small', 'hint mv-pv-presets', 'Preset: ');
308
+ const or = h(doc, 'button', 'link-btn mv-pv-preset', 'OpenRouter');
309
+ or.type = 'button'; or.dataset.provider = name; or.dataset.preset = 'openrouter';
310
+ or.title = `Point this provider at OpenRouter (${OPENROUTER_PRESET.baseUrl})`;
311
+ pre.appendChild(or);
312
+ bu.appendChild(pre);
313
+ }
248
314
  ctl.appendChild(bu);
249
315
  const key = h(doc, 'label', 'mv-field');
250
316
  key.appendChild(h(doc, 'span', 'mv-field-label', 'API key'));
@@ -282,6 +348,25 @@ export function renderProvidersCard(providers, { doc = globalThis.document, sign
282
348
  return root;
283
349
  }
284
350
 
351
+ /** The OpenRouter preset (docs/models.md "OpenRouter"); mirrors provider-ops OPENROUTER_BASE_URL. */
352
+ export const OPENROUTER_PRESET = Object.freeze({ baseUrl: 'https://openrouter.ai/api/v1', keyRef: '${OPENROUTER_KEY}' });
353
+
354
+ /**
355
+ * Apply a preset to a provider row's fields — the base URL always, the key only when the row has
356
+ * none (a stored or typed key is never replaced). Returns whether anything changed.
357
+ */
358
+ export function applyProviderPreset(rootEl, name, preset) {
359
+ const p = preset === 'openrouter' ? OPENROUTER_PRESET : null;
360
+ const row = p && rootEl.querySelector(`.mv-pv-row[data-provider="${name}"]`);
361
+ if (!row) return false;
362
+ const bu = row.querySelector('.mv-pv-baseurl');
363
+ const key = row.querySelector('.mv-pv-key');
364
+ let changed = false;
365
+ if (bu && bu.value.trim() !== p.baseUrl) { bu.value = p.baseUrl; changed = true; }
366
+ if (key && !key.value.trim()) { key.value = p.keyRef; changed = true; }
367
+ return changed;
368
+ }
369
+
285
370
  /** Collect a key-based provider row into a PATCH body (masked echo dropped). */
286
371
  export function collectProviderRow(rootEl, name) {
287
372
  const row = rootEl.querySelector(`.mv-pv-row[data-provider="${name}"]`);
@@ -398,12 +483,38 @@ export function renderImportSheet(models, { doc = globalThis.document } = {}) {
398
483
  export function renderEndpointSheet(payload, { doc = globalThis.document } = {}) {
399
484
  const p = payload || {};
400
485
  const list = Array.isArray(p.models) ? p.models : [];
486
+ const hosted = p.server === 'openrouter';
401
487
  const root = h(doc, 'section', 'card mv-editor mvi mvi-ep');
402
488
  root.dataset.source = 'endpoint';
403
489
  root.dataset.baseurl = p.baseUrl || '';
490
+ if (p.server) root.dataset.server = p.server;
404
491
  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.`));
492
+ root.appendChild(h(doc, 'small', 'hint', hosted
493
+ ? `${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.`
494
+ : `${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
495
  for (const w of Array.isArray(p.warnings) ? p.warnings : []) root.appendChild(h(doc, 'small', 'hint mvi-warn', w));
496
+ // A hosted catalog lists hundreds of models: beside the dialog's text filter, narrow by what a
497
+ // pipeline decides on. The controls only mark the view — endpointRowMatches reads them.
498
+ if (hosted && list.length) {
499
+ const bar = h(doc, 'div', 'chip-select mvi-or-filters');
500
+ const chip = (cls, text) => {
501
+ const l = h(doc, 'label', 'mvi-chip');
502
+ const cb = h(doc, 'input', cls); cb.type = 'checkbox';
503
+ l.appendChild(cb); l.appendChild(h(doc, 'span', null, text));
504
+ bar.appendChild(l);
505
+ };
506
+ chip('mvi-f-free', 'Free');
507
+ chip('mvi-f-tools', 'Tools');
508
+ const ctxL = h(doc, 'label', 'mvi-chip');
509
+ ctxL.appendChild(h(doc, 'span', null, 'Window ≥'));
510
+ const ctx = h(doc, 'select', 'select mvi-f-ctx');
511
+ for (const [v, t] of [['0', 'any'], ['65536', '64k'], ['131072', '128k'], ['262144', '256k'], ['1000000', '1M']]) {
512
+ const o = h(doc, 'option', null, t); o.value = v; ctx.appendChild(o);
513
+ }
514
+ ctxL.appendChild(ctx);
515
+ bar.appendChild(ctxL);
516
+ root.appendChild(bar);
517
+ }
407
518
  if (!list.length) {
408
519
  root.appendChild(h(doc, 'div', 'hist-empty', 'This endpoint lists no models.'));
409
520
  } else {
@@ -421,6 +532,9 @@ export function renderEndpointSheet(payload, { doc = globalThis.document } = {})
421
532
  for (const m of list) {
422
533
  const tr = h(doc, 'tr');
423
534
  tr.dataset.id = m.id;
535
+ tr.dataset.free = m.free ? '1' : '0';
536
+ tr.dataset.tools = m.toolCalls === true ? '1' : '0';
537
+ tr.dataset.ctx = String(m.servedContext || 0);
424
538
  const blocked = m.importable === false;
425
539
  if (blocked) tr.className = 'mvi-disabled';
426
540
  const cbTd = h(doc, 'td');
@@ -470,6 +584,21 @@ export function collectImportSheet(rootEl) {
470
584
  return [...rootEl.querySelectorAll('.mvi-cb')].filter((c) => c.checked && !c.disabled).map((c) => c.value);
471
585
  }
472
586
 
587
+ /**
588
+ * Whether an import-sheet row passes the dialog's text query and, on a hosted sheet, its Free /
589
+ * Tools / window filters. A view filter only — the tick on a hidden row is kept. Pure over the DOM.
590
+ */
591
+ export function endpointRowMatches(tr, sheet, query = '') {
592
+ const q = String(query || '').trim().toLowerCase();
593
+ if (q && !tr.textContent.toLowerCase().includes(q) && !String(tr.dataset.id || '').toLowerCase().includes(q)) return false;
594
+ const bar = sheet && sheet.querySelector('.mvi-or-filters');
595
+ if (!bar) return true;
596
+ if (bar.querySelector('.mvi-f-free')?.checked && tr.dataset.free !== '1') return false;
597
+ if (bar.querySelector('.mvi-f-tools')?.checked && tr.dataset.tools !== '1') return false;
598
+ const min = Number(bar.querySelector('.mvi-f-ctx')?.value || 0);
599
+ return !(min > 0 && Number(tr.dataset.ctx || 0) < min);
600
+ }
601
+
473
602
  /** The select-all box: tick every importable row. */
474
603
  export function applyImportSelectAll(rootEl, on) {
475
604
  for (const c of rootEl.querySelectorAll('.mvi-cb')) if (!c.disabled) c.checked = !!on;
@@ -477,6 +606,50 @@ export function applyImportSelectAll(rootEl, on) {
477
606
 
478
607
  // ── Editor: Connection section (§8.3) ───────────────────────────────────────
479
608
 
609
+ /** Whether a base URL is OpenRouter's (mirrors model-env.mjs isOpenRouterBaseUrl; this module imports nothing). */
610
+ export function isOpenRouterUrl(v) {
611
+ if (typeof v !== 'string' || !v.trim()) return false;
612
+ let host;
613
+ try { host = new URL(v.trim()).hostname.toLowerCase(); } catch { return false; }
614
+ return host === 'openrouter.ai' || host.endsWith('.openrouter.ai');
615
+ }
616
+
617
+ /** Whether the Connection form resolves to OpenRouter chat completions: its own base URL, else the provider's. */
618
+ function openRouterApplies(conn) {
619
+ if ((conn.querySelector('.mv-conn-mode-rb:checked')?.value || 'direct') !== 'provider') return false;
620
+ if (conn.querySelector('.mv-conn-provider')?.value !== 'openai' || conn.querySelector('.mv-conn-api')?.value !== 'openai-chat') return false;
621
+ const own = (conn.querySelector('.mv-conn-baseurl')?.value || '').trim();
622
+ return isOpenRouterUrl(own || conn._providers?.openai?.baseUrl || '');
623
+ }
624
+
625
+ /** Load an `upstream.openrouter` block (or nothing: the defaults) into the routing fields. */
626
+ function fillOpenRouter(root, or) {
627
+ const o = or || {};
628
+ const p = o.provider || {};
629
+ const set = (sel, v) => { const el = root.querySelector(sel); if (el) el.value = v; };
630
+ set('.mv-conn-or-models', Array.isArray(o.models) ? o.models.join(', ') : '');
631
+ set('.mv-conn-or-order', Array.isArray(p.order) ? p.order.join(', ') : '');
632
+ set('.mv-conn-or-sort', p.sort || '');
633
+ const fb = root.querySelector('.mv-conn-or-fallbacks');
634
+ if (fb) fb.checked = p.allow_fallbacks !== false; // OpenRouter's default is on
635
+ }
636
+
637
+ /** The routing fields as an `upstream.openrouter` block, or undefined when all are at their defaults. */
638
+ function collectOpenRouter(conn) {
639
+ const list = (sel) => (conn.querySelector(sel)?.value || '').split(',').map((s) => s.trim()).filter(Boolean);
640
+ const out = {};
641
+ const models = list('.mv-conn-or-models');
642
+ if (models.length) out.models = models;
643
+ const provider = {};
644
+ const order = list('.mv-conn-or-order');
645
+ if (order.length) provider.order = order;
646
+ if (conn.querySelector('.mv-conn-or-fallbacks')?.checked === false) provider.allow_fallbacks = false;
647
+ const sort = conn.querySelector('.mv-conn-or-sort')?.value || '';
648
+ if (sort) provider.sort = sort;
649
+ if (Object.keys(provider).length) out.provider = provider;
650
+ return Object.keys(out).length ? out : undefined;
651
+ }
652
+
480
653
  /**
481
654
  * The Connection block for the model editor. `model` is the MASKED entry or
482
655
  * null; `providers` the GET /api/providers payload (for the readiness hints);
@@ -552,7 +725,13 @@ export function renderConnectionSection(model, { doc = globalThis.document, prov
552
725
 
553
726
  // Advanced: per-entry overrides for the key-based providers.
554
727
  const adv = h(doc, 'details', 'advanced mv-conn-adv');
555
- const sum = h(doc, 'summary', null, 'Advanced — base URL, API key, extra headers');
728
+ // Same summary as New pipeline's Advanced (index.html #advanced-config): the chevron is
729
+ // what says "expandable" — without it the label reads as a plain heading.
730
+ const sum = h(doc, 'summary');
731
+ const chev = h(doc, 'span', 'adv-chev');
732
+ chev.setAttribute('aria-hidden', 'true');
733
+ sum.appendChild(chev);
734
+ sum.appendChild(h(doc, 'span', null, 'Advanced — base URL, API key, extra headers'));
556
735
  adv.appendChild(sum);
557
736
  const advBody = h(doc, 'div', 'advanced-body mv-conn-adv-body');
558
737
  const bu = h(doc, 'input', 'input mv-conn-baseurl');
@@ -567,6 +746,28 @@ export function renderConnectionSection(model, { doc = globalThis.document, prov
567
746
  hdr.rows = 2; hdr.placeholder = 'One per line: Header-Name: value';
568
747
  hdr.value = upstream && upstream.headers ? Object.entries(upstream.headers).map(([k, v]) => `${k}: ${v}`).join('\n') : '';
569
748
  advBody.appendChild(field('Extra headers', hdr));
749
+ // OpenRouter routing: shown only while the entry resolves to OpenRouter (applyConnectionMode).
750
+ const orBox = h(doc, 'div', 'mv-conn-or');
751
+ 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.'));
752
+ const orModels = h(doc, 'input', 'input mv-conn-or-models');
753
+ orModels.type = 'text'; orModels.placeholder = 'e.g. qwen/qwen3.8-27b (comma-separated)';
754
+ orBox.appendChild(field('Fallback models', orModels));
755
+ const orOrder = h(doc, 'input', 'input mv-conn-or-order');
756
+ orOrder.type = 'text'; orOrder.placeholder = 'provider names, comma-separated — empty lets OpenRouter choose';
757
+ orBox.appendChild(field('Provider order', orOrder));
758
+ const orSort = h(doc, 'select', 'select mv-conn-or-sort');
759
+ for (const [value, text] of [['', 'OpenRouter default'], ['price', 'Price'], ['throughput', 'Throughput'], ['latency', 'Latency']]) {
760
+ const o = h(doc, 'option', null, text); o.value = value; orSort.appendChild(o);
761
+ }
762
+ orBox.appendChild(field('Sort providers by', orSort));
763
+ const orFbLab = h(doc, 'label', 'mv-conn-cap');
764
+ const orFb = h(doc, 'input', 'mv-conn-or-fallbacks');
765
+ orFb.type = 'checkbox';
766
+ orFbLab.appendChild(orFb);
767
+ orFbLab.appendChild(h(doc, 'span', null, 'Allow other providers when these are unavailable'));
768
+ orBox.appendChild(orFbLab);
769
+ advBody.appendChild(orBox);
770
+ fillOpenRouter(orBox, upstream && upstream.openrouter);
570
771
  adv.appendChild(advBody);
571
772
  body.appendChild(adv);
572
773
 
@@ -640,6 +841,8 @@ export function applyConnectionMode(connEl) {
640
841
  const api = apiSel ? apiSel.value : '';
641
842
  const adv = conn.querySelector('.mv-conn-adv');
642
843
  if (adv) adv.hidden = provider === 'copilot';
844
+ const orBox = conn.querySelector('.mv-conn-or');
845
+ if (orBox) orBox.hidden = !openRouterApplies(conn);
643
846
  const hint = conn.querySelector('.mv-conn-provider-hint');
644
847
  const p = conn._providers;
645
848
  if (hint) {
@@ -652,7 +855,7 @@ export function applyConnectionMode(connEl) {
652
855
  if (note) {
653
856
  const copilotPricing = provider === 'copilot' ? ' Copilot bills premium requests, so Pricing defaults to Free.' : '';
654
857
  note.textContent = mode !== 'provider' ? '' : api === 'openai-chat'
655
- ? `Translated: no thinking blocks, WebSearch/WebFetch withheld, prompt limit per the capabilities above.${copilotPricing}`
858
+ ? `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
859
  : api === 'openai-responses'
657
860
  ? `Translated to the Responses API: reasoning summaries arrive as thinking, WebSearch/WebFetch withheld, prompt limit per the capabilities above.${copilotPricing}`
658
861
  : 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 +907,7 @@ export function setModelUpstream(connEl, upstream) {
704
907
  const bu = conn.querySelector('.mv-conn-baseurl'); if (bu) bu.value = upstream.baseUrl || '';
705
908
  const key = conn.querySelector('.mv-conn-key'); if (key) { key.value = upstream.apiKey || ''; if (key.value) key.dataset.original = key.value; }
706
909
  const hdr = conn.querySelector('.mv-conn-headers'); if (hdr) hdr.value = upstream.headers ? Object.entries(upstream.headers).map(([k, v]) => `${k}: ${v}`).join('\n') : '';
910
+ fillOpenRouter(conn, upstream.openrouter);
707
911
  const caps = upstream.capabilities || {};
708
912
  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
913
  for (const inp of conn.querySelectorAll('.mv-conn-limit-in')) inp.value = caps[inp.dataset.limit] ? String(caps[inp.dataset.limit]) : '';
@@ -737,6 +941,9 @@ export function collectConnection(connEl) {
737
941
  if (i > 0) headers[l.slice(0, i).trim()] = l.slice(i + 1).trim();
738
942
  }
739
943
  if (Object.keys(headers).length) upstream.headers = headers;
944
+ // Only while the entry resolves to OpenRouter: another endpoint would reject the fields.
945
+ const or = openRouterApplies(conn) ? collectOpenRouter(conn) : undefined;
946
+ if (or) upstream.openrouter = or;
740
947
  }
741
948
  const capabilities = {};
742
949
  for (const cb of conn.querySelectorAll('.mv-conn-cap-cb')) capabilities[cb.dataset.cap] = !!cb.checked;
@@ -2,7 +2,8 @@
2
2
  // Pure DOM renderers for the Settings "Chat notifications" card
3
3
  // (chat-connectivity-design.md §4.8). Same contract as plugins-view.mjs:
4
4
  // detached elements, no fetch, no listeners — app.js owns I/O and mounting;
5
- // node:test drives these via jsdom.
5
+ // node:test drives these via jsdom. (One exception: the Ask web fields keep their own
6
+ // dirty flag with input/change listeners on their detached wrapper — no I/O.)
6
7
 
7
8
  function h(doc, tag, cls, text) {
8
9
  const n = doc.createElement(tag);
@@ -115,3 +116,53 @@ export function collectScriptToolsToggle(root) {
115
116
  const cb = root && typeof root.querySelector === 'function' ? root.querySelector('input.ask-script-tools') : null;
116
117
  return { scriptTools: cb ? cb.checked : true };
117
118
  }
119
+
120
+ /** Ask Worca → Web access (docs/guardrails.md "Web access"): off by default; the allowlist is enforced by worca's server. */
121
+ export function renderAskWebFields({ askWeb } = {}, { doc = globalThis.document } = {}) {
122
+ const w = askWeb || { enabled: false, anyHost: false, allowedDomains: [], search: null };
123
+ const wrap = h(doc, 'div', 'ask-web');
124
+ wrap.append(h(doc, 'div', 'label-row', 'Web access'));
125
+ const row = h(doc, 'label', 'check-row'); row.setAttribute('for', 'askWebEnabled');
126
+ const cb = h(doc, 'input'); cb.type = 'checkbox'; cb.id = 'askWebEnabled'; cb.checked = w.enabled === true;
127
+ row.append(cb, doc.createTextNode(' Let Ask Worca read web pages'));
128
+ const anyRow = h(doc, 'label', 'check-row'); anyRow.setAttribute('for', 'askWebAnyHost');
129
+ const any = h(doc, 'input'); any.type = 'checkbox'; any.id = 'askWebAnyHost'; any.checked = w.anyHost === true;
130
+ anyRow.append(any, doc.createTextNode(' Any site, without asking'));
131
+ const anyHint = h(doc, 'small', 'hint', 'Risky: a web page or a file Ask reads can then make it send data to any site in a URL. Leave off to approve each new site from the chat.');
132
+ const domains = h(doc, 'textarea', 'input'); domains.id = 'askWebDomains'; domains.rows = 4;
133
+ domains.placeholder = 'docs.python.org\n*.mozilla.org'; domains.value = (w.allowedDomains || []).join('\n');
134
+ const dHint = h(doc, 'small', 'hint', 'Sites Ask may read without asking. One host per line: example.com, or *.example.com for its subdomains. For any other site Ask shows a card in the chat: allow it for that chat, always (it is added here), or deny. https only. A site sees every URL Ask requests from it, so only allow sites you trust.');
135
+ const field = (id, label, placeholder, value) => {
136
+ const box = h(doc, 'div', 'field');
137
+ const l = h(doc, 'label', null, label); l.setAttribute('for', id);
138
+ const i = h(doc, 'input', 'input'); i.id = id; i.placeholder = placeholder; i.value = value || '';
139
+ box.append(l, i); return box;
140
+ };
141
+ const s = w.search || {};
142
+ // Dirty flag: the card posts askWeb only after the user touched a web field, so saving the other
143
+ // Ask limits never rewrites the stored web settings.
144
+ const markDirty = () => { wrap.dataset.dirty = '1'; };
145
+ wrap.addEventListener('input', markDirty);
146
+ wrap.addEventListener('change', markDirty);
147
+ wrap.append(row, anyRow, anyHint, domains, dHint,
148
+ field('askWebSearchUrl', 'Search endpoint (optional)', 'https://api.search.brave.com/res/v1/web/search?q={query}', s.url),
149
+ field('askWebSearchKey', 'Search key variable', '${BRAVE_API_KEY}', s.key),
150
+ field('askWebSearchHeader', 'Key header', 'X-Subscription-Token', s.keyHeader),
151
+ field('askWebSearchPrefix', 'Key prefix', 'Bearer ', s.keyPrefix),
152
+ h(doc, 'small', 'hint', 'Any GET search API that returns JSON. Use {query} (and optionally {key}) in the URL; the key is always a ${VAR} read from worca\'s environment.'));
153
+ return wrap;
154
+ }
155
+
156
+ /** The askWeb POST value, or null when the user did not touch the web fields (nothing to send). */
157
+ export function collectAskWebFields(root) {
158
+ if (root.querySelector('.ask-web')?.dataset.dirty !== '1') return null;
159
+ const q = (id) => root.querySelector(`#${id}`);
160
+ const val = (id) => (q(id) ? q(id).value.trim() : '');
161
+ const url = val('askWebSearchUrl');
162
+ return {
163
+ enabled: !!q('askWebEnabled')?.checked,
164
+ anyHost: !!q('askWebAnyHost')?.checked,
165
+ allowedDomains: (q('askWebDomains')?.value || '').split('\n').map((x) => x.trim()).filter(Boolean),
166
+ search: url ? { url, key: val('askWebSearchKey'), keyHeader: val('askWebSearchHeader'), keyPrefix: q('askWebSearchPrefix') ? q('askWebSearchPrefix').value : '' } : null,
167
+ };
168
+ }
@@ -0,0 +1,63 @@
1
+ // ui/public/credential-badges.mjs
2
+ // "Your key / No key" badges on model pickers (credential broker, docs/credential-broker.md).
3
+ // One cache of GET /api/credentials — which slot each catalog model spends from, and the
4
+ // signed-in person's key state per slot — that every picker asks for a model's badge.
5
+ // Nothing here without a broker: credentialBadge() returns null and no picker changes.
6
+
7
+ let data = null;
8
+ let pending = null;
9
+ const listeners = new Set();
10
+
11
+ /** Replace the cached payload (app.js after a fetch; tests directly). */
12
+ export function setCredentialData(d) {
13
+ data = d && d.enabled ? d : null;
14
+ for (const fn of listeners) { try { fn(); } catch { /* a listener must not break the rest */ } }
15
+ }
16
+
17
+ /** Repaint hook: called whenever the cached payload changes. */
18
+ export function onCredentialsChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }
19
+
20
+ /** Fetch /api/credentials once at a time; never throws (no broker or no answer = no badges). */
21
+ export function loadCredentials(fetchImpl = globalThis.fetch) {
22
+ if (pending) return pending;
23
+ pending = (async () => {
24
+ try {
25
+ const r = await fetchImpl('/api/credentials');
26
+ setCredentialData(r.ok ? await r.json() : null);
27
+ } catch { setCredentialData(null); } finally { pending = null; }
28
+ return data;
29
+ })();
30
+ return pending;
31
+ }
32
+
33
+ /** The key page URL, when the broker has one. */
34
+ export function keyPageUrl() { return data?.keyPage || null; }
35
+
36
+ /**
37
+ * The badge for one model, or null (no broker, unknown model, nobody signed in).
38
+ * @returns {{text:string, tone:'green'|'blue'|'red', title:string, missing:boolean}|null}
39
+ */
40
+ export function credentialBadge(modelId, d = data) {
41
+ if (!d || !d.enabled || !d.models) return null;
42
+ const m = d.models[modelId];
43
+ if (!m) return null;
44
+ if (m.keyless) return { text: 'local', tone: 'blue', title: 'A local model: no key needed.', missing: false };
45
+ if (m.error) return { text: 'no route', tone: 'red', title: m.error, missing: true };
46
+ if (d.mode === 'multi' && !d.person) return null;
47
+ const s = (d.slots || []).find((x) => x.id === m.slot);
48
+ const label = s?.label || m.slot;
49
+ const where = d.keyPage ? ' on the key page' : ' to the credential broker';
50
+ switch (s?.state) {
51
+ case 'set': return { text: 'your key', tone: 'green', title: `Runs with your ${label}.`, missing: false };
52
+ case 'operator': return { text: 'team key', tone: 'blue', title: `Runs with the team's ${label}.`, missing: false };
53
+ case 'keyless': return { text: 'local', tone: 'blue', title: 'No key needed.', missing: false };
54
+ case 'invalid': return { text: 'key rejected', tone: 'red', title: `The provider rejected your ${label}: replace it${where}.`, missing: true };
55
+ default: return { text: 'no key', tone: 'red', title: `Add your ${label}${where} to use this model.`, missing: true };
56
+ }
57
+ }
58
+
59
+ /** A short suffix for a plain <option> label (a select can't hold a styled badge). */
60
+ export function credentialSuffix(modelId, d = data) {
61
+ const b = credentialBadge(modelId, d);
62
+ return b && b.missing ? ` · ${b.text}` : '';
63
+ }
@@ -0,0 +1,57 @@
1
+ // ui/public/credentials-view.mjs
2
+ // Pure DOM renderer for the Settings "My model credentials" card
3
+ // (docs/credential-broker.md). Same contract as chat-settings-view.mjs: a detached
4
+ // element, no fetch, no listeners — app.js owns I/O and mounting; node:test drives
5
+ // it via jsdom. Status only: which of the signed-in person's credential slots hold a
6
+ // key, as the credential broker reports it (GET /api/credentials). Keys are entered
7
+ // on the broker's own key page, never here.
8
+
9
+ function h(doc, tag, cls, text) {
10
+ const n = doc.createElement(tag);
11
+ if (cls) n.className = cls;
12
+ if (text != null) n.textContent = text;
13
+ return n;
14
+ }
15
+
16
+ export const CREDENTIAL_STATE_TEXT = Object.freeze({
17
+ set: 'Set', missing: 'Not set', invalid: 'Rejected', keyless: 'No key needed', operator: 'Provided by the team',
18
+ });
19
+ const STATE_TONE = Object.freeze({ set: 'green', missing: 'slate', invalid: 'red', keyless: 'blue', operator: 'blue' });
20
+
21
+ /**
22
+ * @param {{enabled:boolean, mode?:string, keyPage?:string|null, person?:string|null, slots?:object[], error?:string}} data
23
+ * @param {Document} [doc]
24
+ * @returns {HTMLElement}
25
+ */
26
+ export function renderCredentials(data, doc = document) {
27
+ const box = h(doc, 'div', 'cred-view');
28
+ if (data?.error) {
29
+ box.append(h(doc, 'p', 'hint err', `The credential broker didn't answer: ${data.error}`));
30
+ return box;
31
+ }
32
+ if (data?.mode === 'single') {
33
+ box.append(h(doc, 'p', 'hint', 'Model keys are held by the credential broker. To change them, update the broker\'s secrets and restart it.'));
34
+ } else if (!data?.person) {
35
+ box.append(h(doc, 'p', 'hint', 'Sign in through your team\'s sign-in page to see your credentials.'));
36
+ return box;
37
+ }
38
+ const list = h(doc, 'ul', 'cred-list');
39
+ for (const s of data.slots || []) {
40
+ const text = s.state === 'set' && s.suffix ? `Set ••••${s.suffix}` : (CREDENTIAL_STATE_TEXT[s.state] || s.state);
41
+ const row = h(doc, 'li', 'cred-row');
42
+ row.append(h(doc, 'span', 'cred-name', s.label || s.id), h(doc, 'span', `cred-chip tone-${STATE_TONE[s.state] || 'slate'}`, text));
43
+ list.append(row);
44
+ }
45
+ box.append(list);
46
+ if (data.mode === 'multi' && data.keyPage) {
47
+ const missing = (data.slots || []).some((s) => s.state === 'missing' || s.state === 'invalid');
48
+ const actions = h(doc, 'div', 'cred-actions');
49
+ const a = h(doc, 'a', `btn ${missing ? 'btn-primary' : 'btn-ghost'} btn-mini`, 'Manage keys');
50
+ a.href = data.keyPage;
51
+ a.target = '_blank';
52
+ a.rel = 'noopener';
53
+ actions.append(a, h(doc, 'small', 'hint', 'Opens the key page in a new tab. Reopen Settings afterwards to see the change.'));
54
+ box.append(actions);
55
+ }
56
+ return box;
57
+ }