@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
@@ -15,9 +15,50 @@ import { modelEnvRef, maskModelEnvValue, COPILOT_TERMS_VERSION, UPSTREAM_PROVIDE
15
15
  import {
16
16
  startDeviceFlow, pollDeviceFlow, githubLogin, copilotToken, invalidateCopilotToken,
17
17
  listCopilotModels, copilotUsage, catalogEntryForCopilotModel, copilotApiFor,
18
+ copilotHeaders, normalizeCopilotModel,
18
19
  } from './providers/copilot.mjs';
19
20
  import { keyOptional } from './registry.mjs';
20
21
  import { listEndpointModels, catalogEntryForEndpointModel, importableModel } from './providers/endpoint.mjs';
22
+ import { brokerEnabled, brokerInfo, cachedBrokerInfo, mintSpawnToken, revokeSpawnToken, slotBaseUrl, personSlots } from '../broker-client.mjs';
23
+ import { routeUpstream } from '../broker-routing.mjs';
24
+ import { resolveBillTo } from '../billing.mjs';
25
+
26
+ // ── credential broker (docs/credential-broker.md) ────────────────────────────
27
+
28
+ /** With the broker on, provider keys and sign-ins live on its key page, never in worca. */
29
+ function refuseWithBroker(what) {
30
+ if (!brokerEnabled()) return;
31
+ const keyPage = cachedBrokerInfo()?.publicUrl;
32
+ throw Object.assign(new Error(`worca can't ${what}: with the credential broker, each person does that on the key page${keyPage ? ` (${keyPage})` : ''}`), { code: 'BROKER' });
33
+ }
34
+
35
+ /**
36
+ * Run `fn(token)` with a short-lived broker token for `slot`, billed to the person behind
37
+ * the current request (billing.mjs), revoked afterwards. Model discovery and imports use it.
38
+ */
39
+ async function withAuxBrokerToken(slot, fn) {
40
+ const info = await brokerInfo();
41
+ let billTo = resolveBillTo(null);
42
+ if (info.mode === 'multi' && (!billTo || billTo === 'local')) {
43
+ throw Object.assign(new Error('sign in through the identity proxy first: the provider is asked with your own key'), { code: 'BROKER' });
44
+ }
45
+ const minted = await mintSpawnToken({ billTo: billTo || 'local', slots: [slot], kind: 'aux' });
46
+ try { return await fn(minted.token); } finally { revokeSpawnToken(minted.spawnId); }
47
+ }
48
+
49
+ /** A broker refusal's own words (`worca-broker: …`), else a generic status line. */
50
+ async function brokerFailure(res, what) {
51
+ try {
52
+ const j = await res.json();
53
+ const m = j?.error?.message;
54
+ if (typeof m === 'string' && m) return m;
55
+ } catch { /* not JSON */ }
56
+ return `${what} answered ${res.status}`;
57
+ }
58
+ import { isOpenRouter } from './openrouter.mjs';
59
+
60
+ /** The OpenRouter API base a preset / `worca models set openrouter` points the openai provider at. */
61
+ export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
21
62
 
22
63
  // ── Copilot sign-in sessions (in memory; a device code lives ~15 min) ────────
23
64
  const sessions = new Map(); // deviceCode -> { startedAt, expiresAt, interval, lastPoll }
@@ -32,6 +73,7 @@ function sweepSessions(now = Date.now()) {
32
73
  * @returns {Promise<{deviceCode, userCode, verificationUri, interval, expiresIn}>}
33
74
  */
34
75
  export async function beginCopilotLogin({ fetch: f } = {}) {
76
+ refuseWithBroker('sign in to GitHub Copilot');
35
77
  if (!copilotTermsAcknowledged()) {
36
78
  throw Object.assign(new Error('acknowledge the GitHub Copilot notice before signing in'), { code: 'TERMS' });
37
79
  }
@@ -87,7 +129,7 @@ const secretSource = (v) => (!v ? null : modelEnvRef(v) ? 'env' : 'stored');
87
129
  * a network call to GitHub — and is null when the account exposes none.
88
130
  * @param {{quota?:boolean, fetch?:typeof fetch}} [opts]
89
131
  */
90
- export async function providersState({ quota = false, fetch: f } = {}) {
132
+ export async function providersState({ quota = false, fetch: f, person = resolveBillTo(null) } = {}) {
91
133
  const all = allProviders();
92
134
  const c = all.copilot;
93
135
  const token = resolveProviderSecret(c.githubToken);
@@ -119,7 +161,45 @@ export async function providersState({ quota = false, fetch: f } = {}) {
119
161
  maxConcurrent: p.maxConcurrent,
120
162
  };
121
163
  };
122
- return { copilot, openai: keyed('openai'), anthropic: keyed('anthropic') };
164
+ // With the credential broker on, keys and the Copilot sign-in are per person on its key page:
165
+ // each provider's state is the viewer's own slot there, not worca's (empty) settings.
166
+ const broker = brokerEnabled() ? await brokerProviders(all, person) : { enabled: false };
167
+ return { copilot, openai: keyed('openai'), anthropic: keyed('anthropic'), broker };
168
+ }
169
+
170
+ /**
171
+ * The Providers card's view of the broker: which slot each provider spends from and the viewer's
172
+ * state in it — {slot, state:'set'|'missing'|'invalid'|'operator'|'keyless'|'none', suffix?, error?}.
173
+ * Never throws: an unreachable broker leaves the states out and says why.
174
+ */
175
+ async function brokerProviders(all, person) {
176
+ const info = cachedBrokerInfo() || await brokerInfo().catch(() => null);
177
+ const out = { enabled: true, keyPage: info?.publicUrl || null, mode: info?.mode || null, providers: {} };
178
+ const slots = info?.slots || [];
179
+ const route = (name) => {
180
+ if (name === 'copilot') {
181
+ const s = slots.find((x) => x.auth === 'copilot');
182
+ return s ? { slot: s.id } : { state: 'none', error: 'the credential broker has no GitHub Copilot slot' };
183
+ }
184
+ const r = routeUpstream({ provider: name, baseUrl: all[name].baseUrl || null }, { slots });
185
+ return r.slot ? { slot: r.slot } : r.keyless ? { state: 'keyless' } : { state: 'none', error: r.error };
186
+ };
187
+ for (const name of ['copilot', 'openai', 'anthropic']) out.providers[name] = route(name);
188
+ const who = out.mode === 'multi' ? person : 'local';
189
+ if (!who || (out.mode === 'multi' && who === 'local')) { out.signInNeeded = true; return out; }
190
+ try {
191
+ const mine = new Map(((await personSlots(who)).slots || []).map((x) => [x.id, x]));
192
+ for (const p of Object.values(out.providers)) {
193
+ if (!p.slot) continue;
194
+ const s = mine.get(p.slot);
195
+ p.state = s ? s.state : 'missing';
196
+ if (s && s.suffix) p.suffix = s.suffix;
197
+ if (s && s.label) p.label = s.label;
198
+ }
199
+ } catch (err) {
200
+ out.error = err.message;
201
+ }
202
+ return out;
123
203
  }
124
204
 
125
205
  /** Patch a provider from the UI/CLI; masked key echoes are dropped ("keep"). */
@@ -127,6 +207,8 @@ export async function patchProvider(name, patch = {}) {
127
207
  if (!UPSTREAM_PROVIDERS.includes(name)) throw new Error(`unknown provider ${JSON.stringify(name)}`);
128
208
  const p = { ...patch };
129
209
  for (const k of ['apiKey', 'githubToken']) if (typeof p[k] === 'string' && p[k].startsWith('••')) delete p[k];
210
+ // Credential broker: keys are per person, on the key page; worca stores none.
211
+ if (brokerEnabled() && ['apiKey', 'githubToken'].some((k) => typeof p[k] === 'string' && p[k].trim())) refuseWithBroker('store a provider key');
130
212
  return updateProvider(name, p);
131
213
  }
132
214
 
@@ -134,10 +216,24 @@ export async function patchProvider(name, patch = {}) {
134
216
 
135
217
  /** Copilot's models for the import sheet, each with `inCatalog` (§8.4). */
136
218
  export async function copilotModelsForImport({ fetch: f } = {}) {
137
- const c = providerConfig('copilot');
138
- const token = resolveProviderSecret(c.githubToken);
139
- if (!token) throw Object.assign(new Error('not signed in to GitHub Copilot'), { code: 'NOT_SIGNED_IN' });
140
- const list = await listCopilotModels(token, { accountType: c.accountType, fetch: f });
219
+ let list;
220
+ if (brokerEnabled()) {
221
+ // The signed-in person's Copilot, through the broker (their GitHub sign-in lives there).
222
+ const route = routeUpstream({ provider: 'copilot' }, { slots: (await brokerInfo()).slots || [] });
223
+ if (route.error) throw new Error(route.error);
224
+ list = await withAuxBrokerToken(route.slot, async (token) => {
225
+ const r = await (f || globalThis.fetch)(`${slotBaseUrl(route.slot)}/models`, { headers: copilotHeaders(token) });
226
+ if (!r.ok) throw Object.assign(new Error(await brokerFailure(r, 'Copilot /models')), { status: r.status });
227
+ const j = await r.json();
228
+ const data = Array.isArray(j.data) ? j.data : (Array.isArray(j) ? j : []);
229
+ return data.map(normalizeCopilotModel).filter((m) => m && (!m.type || m.type === 'chat'));
230
+ });
231
+ } else {
232
+ const c = providerConfig('copilot');
233
+ const token = resolveProviderSecret(c.githubToken);
234
+ if (!token) throw Object.assign(new Error('not signed in to GitHub Copilot'), { code: 'NOT_SIGNED_IN' });
235
+ list = await listCopilotModels(token, { accountType: c.accountType, fetch: f });
236
+ }
141
237
  const have = new Set(listGlobalModels().map((m) => m.id.toLowerCase()));
142
238
  return list.map((m) => ({ ...m, api: copilotApiFor(m), catalogId: `copilot-${m.id}`, inCatalog: have.has(`copilot-${m.id}`.toLowerCase()) }))
143
239
  .sort((a, b) => (Number(b.pickerEnabled) - Number(a.pickerEnabled)) || a.name.localeCompare(b.name));
@@ -204,19 +300,47 @@ function endpointBase(baseUrl) {
204
300
  export async function endpointModelsForImport({ baseUrl, fetch: f } = {}) {
205
301
  const base = endpointBase(baseUrl);
206
302
  const p = providerConfig('openai');
207
- const key = resolveProviderSecret(p.apiKey);
208
- const out = await listEndpointModels(base, { apiKey: key, fetch: f });
303
+ let out;
304
+ const route = brokerEnabled() ? routeUpstream({ provider: 'openai', baseUrl: base }, { slots: (await brokerInfo()).slots || [] }) : null;
305
+ if (route && route.error) throw new Error(route.error);
306
+ if (route && route.slot) {
307
+ // An endpoint behind a broker slot is asked with the clicking person's key; the listing
308
+ // then names the REAL base URL, so the imported entries point at it.
309
+ out = await withAuxBrokerToken(route.slot, (token) => listEndpointModels(`${slotBaseUrl(route.slot)}${route.prefix}`, { apiKey: token, fetch: f, realBaseUrl: base }));
310
+ out = { ...out, baseUrl: base };
311
+ } else {
312
+ // Keyless local endpoints are reached directly, as without the broker.
313
+ const key = brokerEnabled() ? '' : resolveProviderSecret(p.apiKey);
314
+ out = await listEndpointModels(base, { apiKey: key, fetch: f });
315
+ }
209
316
  const have = new Set(listGlobalModels().map((m) => m.id.toLowerCase()));
210
317
  return {
211
318
  ...out,
212
319
  models: out.models.map((m) => {
213
320
  const entry = catalogEntryForEndpointModel(m, { server: out.server, baseUrl: out.baseUrl, providerBaseUrl: p.baseUrl });
214
321
  const usable = importableModel(m);
215
- return { ...m, catalogId: entry.id, inCatalog: have.has(entry.id.toLowerCase()), importable: usable.ok, ...(usable.ok ? {} : { blocked: usable.why }) };
322
+ const existing = existingEndpointEntry(m.id, out.baseUrl, p.baseUrl);
323
+ const catalogId = existing ? existing.id : entry.id;
324
+ return { ...m, catalogId, inCatalog: !!existing || have.has(entry.id.toLowerCase()), importable: usable.ok, ...(usable.ok ? {} : { blocked: usable.why }) };
216
325
  }),
217
326
  };
218
327
  }
219
328
 
329
+ const trimUrl = (u) => String(u || '').trim().replace(/\/+$/, '').toLowerCase();
330
+
331
+ /**
332
+ * The catalog entry that already imports `upstreamId` from this endpoint, whatever its id says.
333
+ * Ids are derived (and their prefix changed: a remote endpoint's models were `local-…`), so a
334
+ * re-import matches on what the entry POINTS AT — the openai provider, this upstream id, and this
335
+ * base URL (its own, or the provider's when it carries none) — and refreshes the entry the user
336
+ * already has instead of adding a twin under the new id.
337
+ */
338
+ function existingEndpointEntry(upstreamId, baseUrl, providerBaseUrl) {
339
+ const want = trimUrl(baseUrl);
340
+ return listGlobalModels().find((x) => x.upstream && x.upstream.provider === 'openai'
341
+ && x.upstream.model === upstreamId && trimUrl(x.upstream.baseUrl || providerBaseUrl) === want) || null;
342
+ }
343
+
220
344
  /**
221
345
  * Import endpoint models into the catalog. A new id gets the full entry; an existing one keeps the
222
346
  * label, efforts and pricing you edited and only has its upstream refreshed.
@@ -235,7 +359,8 @@ export async function importEndpointModels(ids, { baseUrl, fetch: f } = {}) {
235
359
  if (!m) { skipped.push({ id, why: 'the endpoint does not serve it' }); continue; }
236
360
  if (!m.importable) { skipped.push({ id, why: m.blocked || 'not usable in a pipeline' }); continue; }
237
361
  const entry = catalogEntryForEndpointModel(m, { server: out.server, baseUrl: out.baseUrl, providerBaseUrl: p.baseUrl });
238
- const current = listGlobalModels().find((x) => x.id.toLowerCase() === entry.id.toLowerCase());
362
+ const current = existingEndpointEntry(m.id, out.baseUrl, p.baseUrl)
363
+ || listGlobalModels().find((x) => x.id.toLowerCase() === entry.id.toLowerCase());
239
364
  if (current) {
240
365
  if (!current.upstream || current.upstream.provider !== 'openai') { skipped.push({ id, why: `"${current.id}" already exists and is not an OpenAI-compatible entry` }); continue; }
241
366
  await updateGlobalModel(current.id, { upstream: { ...current.upstream, model: entry.upstream.model, ...(entry.upstream.baseUrl ? { baseUrl: entry.upstream.baseUrl } : {}), ...(entry.upstream.capabilities ? { capabilities: entry.upstream.capabilities } : {}) } });
@@ -248,6 +373,60 @@ export async function importEndpointModels(ids, { baseUrl, fetch: f } = {}) {
248
373
  return { created, updated, skipped, server: out.server, serverLabel: out.serverLabel, baseUrl: out.baseUrl, warnings: out.warnings };
249
374
  }
250
375
 
376
+ // ── OpenRouter: the key's own limits ────────────────────────────────────────
377
+
378
+ const finiteOrNull = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
379
+
380
+ /**
381
+ * What OpenRouter says about the key (GET {base}/key): credit limit and what is left, spend, the
382
+ * free-model daily allowance and the rate limit. The `label` is dropped — OpenRouter builds it from
383
+ * the key itself (`sk-or-v1-abc…xyz`). Never throws: null when there is no key or no answer.
384
+ * @returns {Promise<null|{limit:number|null, limitRemaining:number|null, usage:number|null, usageDaily:number|null,
385
+ * isFreeTier:boolean, freeDaily:{used:number,limit:number,remaining:number}|null, rateLimit:object|null}>}
386
+ */
387
+ export async function openRouterKeyInfo(baseUrl, apiKey, { fetch: f = globalThis.fetch, timeoutMs = 10_000 } = {}) {
388
+ if (!apiKey) return null;
389
+ try {
390
+ const r = await f(`${String(baseUrl || '').replace(/\/+$/, '')}/key`, { headers: { authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(timeoutMs) });
391
+ if (!r.ok) return null;
392
+ const j = await r.json();
393
+ const d = j && j.data;
394
+ if (!d || typeof d !== 'object') return null;
395
+ const fd = d.free_model_daily_requests;
396
+ const rl = d.rate_limit;
397
+ return {
398
+ limit: finiteOrNull(d.limit),
399
+ limitRemaining: finiteOrNull(d.limit_remaining),
400
+ usage: finiteOrNull(d.usage),
401
+ usageDaily: finiteOrNull(d.usage_daily),
402
+ isFreeTier: d.is_free_tier === true,
403
+ freeDaily: fd && typeof fd === 'object' && finiteOrNull(fd.limit) !== null
404
+ ? { used: finiteOrNull(fd.used) ?? 0, limit: fd.limit, remaining: finiteOrNull(fd.remaining) ?? Math.max(0, fd.limit - (finiteOrNull(fd.used) ?? 0)) }
405
+ : null,
406
+ // OpenRouter answers `requests: -1` for a key with no request-rate limit of its own.
407
+ rateLimit: rl && typeof rl === 'object' && finiteOrNull(rl.requests) > 0 ? { requests: rl.requests, interval: String(rl.interval || '') } : null,
408
+ };
409
+ } catch { return null; }
410
+ }
411
+
412
+ /** One line for the Providers card and `worca models test`. Pure. */
413
+ export function formatOpenRouterKeyInfo(info) {
414
+ if (!info) return '';
415
+ const usd = (n) => `$${n.toFixed(2)}`;
416
+ const bits = [];
417
+ if (info.limit !== null && info.limit !== undefined) {
418
+ const left = info.limitRemaining ?? (info.usage !== null && info.usage !== undefined ? Math.max(0, info.limit - info.usage) : null);
419
+ bits.push(left !== null ? `credit ${usd(left)} of ${usd(info.limit)} left` : `credit limit ${usd(info.limit)}`);
420
+ } else {
421
+ bits.push('no credit limit');
422
+ if (info.usage !== null && info.usage !== undefined) bits.push(`${usd(info.usage)} used`);
423
+ }
424
+ if (info.freeDaily) bits.push(`free-model requests today ${info.freeDaily.remaining} / ${info.freeDaily.limit}`);
425
+ if (info.isFreeTier) bits.push('free tier');
426
+ if (info.rateLimit) bits.push(`rate limit ${info.rateLimit.requests} per ${info.rateLimit.interval}`);
427
+ return bits.join(' · ');
428
+ }
429
+
251
430
  // ── key-based providers: connection test ────────────────────────────────────
252
431
 
253
432
  /**
@@ -261,6 +440,11 @@ export async function importEndpointModels(ids, { baseUrl, fetch: f } = {}) {
261
440
  * @returns {Promise<{ok:true, models?:number}|{ok:false, message:string}>}
262
441
  */
263
442
  export async function testProviderConnection(name, { fetch: f = globalThis.fetch, baseUrl = '', apiKey } = {}) {
443
+ // Credential broker: keys are per person and tested where they are saved.
444
+ if (brokerEnabled()) {
445
+ const keyPage = cachedBrokerInfo()?.publicUrl;
446
+ return { ok: false, message: `keys are held by the credential broker: test yours on the key page${keyPage ? ` (${keyPage})` : ''}` };
447
+ }
264
448
  if (name === 'copilot') {
265
449
  const c = providerConfig('copilot');
266
450
  const token = resolveProviderSecret(c.githubToken);
@@ -287,6 +471,14 @@ export async function testProviderConnection(name, { fetch: f = globalThis.fetch
287
471
  if (!r.ok) return { ok: false, message: `endpoint answered ${r.status}` };
288
472
  const j = await r.json().catch(() => null);
289
473
  const n = j && Array.isArray(j.data) ? j.data.length : undefined;
474
+ // OpenRouter lists its models without a key, so a reachable list proves nothing about the key:
475
+ // its /key does, and says what the key may still spend. A key it rejects fails the test.
476
+ if (name === 'openai' && isOpenRouter(base)) {
477
+ if (!key) return { ok: false, message: 'no API key configured — OpenRouter lists models without one, but every call needs it' };
478
+ const info = await openRouterKeyInfo(base, key, { fetch: f });
479
+ if (!info) return { ok: false, message: 'authentication failed — OpenRouter did not accept the key (its /key check failed)' };
480
+ return { ok: true, ...(n !== undefined ? { models: n } : {}), openrouter: info, detail: formatOpenRouterKeyInfo(info) };
481
+ }
290
482
  return { ok: true, ...(n !== undefined ? { models: n } : {}) };
291
483
  } catch (err) {
292
484
  return { ok: false, message: `endpoint unreachable — ${err && err.message ? err.message : String(err)}` };
@@ -18,6 +18,10 @@
18
18
  // LM Studio GET {root}/api/v0/models type (llm | vlm | embeddings), state, max_context_length,
19
19
  // loaded_context_length.
20
20
  // vLLM / other GET {base}/models ids, plus max_model_len where the server sets it.
21
+ // OpenRouter GET {base}/models recognised by its host and never probed: context_length and
22
+ // top_provider (the window it serves, the output cap),
23
+ // supported_parameters (tools, reasoning), modalities and
24
+ // per-token pricing — everything the generic list lacks.
21
25
  //
22
26
  // The window a model is SERVED with and the one it was TRAINED for are different numbers, and only
23
27
  // the served one may become a prompt limit: Ollama serves 4096 by default however large the model
@@ -25,11 +29,33 @@
25
29
  // built WITHOUT a prompt limit and the caller's warning says so — a wrong window is worse than
26
30
  // none, because the CLI would compact against a number the endpoint never had.
27
31
 
32
+ import { isLocalBaseUrl } from '../../model-env.mjs';
33
+ import { isOpenRouter } from '../openrouter.mjs';
34
+
28
35
  const SERVERS = Object.freeze({
29
- 'llama.cpp': 'llama.cpp', ollama: 'Ollama', lmstudio: 'LM Studio', vllm: 'vLLM', 'openai-compatible': 'OpenAI-compatible',
36
+ 'llama.cpp': 'llama.cpp', ollama: 'Ollama', lmstudio: 'LM Studio', vllm: 'vLLM', openrouter: 'OpenRouter', 'openai-compatible': 'OpenAI-compatible',
30
37
  });
31
38
  /** The catalog-id prefix per server, so two endpoints' models never collide. */
32
- const ID_PREFIX = Object.freeze({ 'llama.cpp': 'llama', ollama: 'ollama', lmstudio: 'lmstudio', vllm: 'vllm', 'openai-compatible': 'local' });
39
+ const ID_PREFIX = Object.freeze({ 'llama.cpp': 'llama', ollama: 'ollama', lmstudio: 'lmstudio', vllm: 'vllm', openrouter: 'openrouter', 'openai-compatible': 'local' });
40
+
41
+ // Re-exported for the import's callers; the one host rule lives in model-env (via ../openrouter.mjs).
42
+ export { isOpenRouter };
43
+
44
+ /**
45
+ * The catalog-id prefix for a server at a base URL. A generic list used to be `local-` wherever it
46
+ * lived, so a model on a hosted gateway read as a local one; a remote host now names its own
47
+ * prefix (`api.groq.com` → `groq`), and only a local / private URL keeps `local`.
48
+ */
49
+ export function endpointIdPrefix(server, baseUrl) {
50
+ if (server !== 'openai-compatible') return ID_PREFIX[server] || 'local';
51
+ if (isLocalBaseUrl(baseUrl)) return 'local';
52
+ let host = '';
53
+ try { host = new URL(String(baseUrl || '').trim()).hostname.toLowerCase(); } catch { return 'local'; }
54
+ const labels = host.split('.').filter(Boolean);
55
+ if (labels.length < 2) return 'local'; // a single-label name (`http://gw/v1`) is on your network
56
+ while (labels.length > 2 && ['api', 'www', 'llm', 'inference'].includes(labels[0])) labels.shift();
57
+ return (labels[0] || '').replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 16) || 'remote';
58
+ }
33
59
  /** Below this a pipeline thrashes the CLI's auto-compact (docs/models.md Troubleshooting). */
34
60
  export const MIN_PIPELINE_WINDOW = 65536;
35
61
  const DEFAULT_TIMEOUT_MS = 10_000;
@@ -142,6 +168,56 @@ function openAiModels(list) {
142
168
  })).filter((m) => m.id);
143
169
  }
144
170
 
171
+ /** A USD-per-token price string → USD per million tokens; null for a missing or negative one. */
172
+ function perMillion(v) {
173
+ const n = Number.parseFloat(v);
174
+ if (!Number.isFinite(n) || n < 0) return null;
175
+ return Math.round(n * 1e12) / 1e6; // 0.000003 × 1e6 is 3.0000000000000004 in floating point
176
+ }
177
+
178
+ /**
179
+ * OpenRouter's listed price as a catalog `cost`: `{free:true}` for a zero price, `{perMtok}` for a
180
+ * real one, null when the listing has none or a variable one (the Auto router lists -1) — an
181
+ * unknown price is left unset ("cost not verified"), never claimed free.
182
+ */
183
+ function openRouterPricing(p) {
184
+ if (!p || typeof p !== 'object') return null;
185
+ const input = perMillion(p.prompt); const output = perMillion(p.completion);
186
+ if (input === null || output === null) return null;
187
+ const cacheRead = perMillion(p.input_cache_read); const cacheWrite = perMillion(p.input_cache_write);
188
+ if (!input && !output && !cacheRead && !cacheWrite) return { free: true };
189
+ return { perMtok: { input, output, ...(cacheRead ? { cacheRead } : {}), ...(cacheWrite ? { cacheWrite } : {}) } };
190
+ }
191
+
192
+ const money = (n) => `$${Number(n.toFixed(2))}`;
193
+
194
+ function openRouterModels(list) {
195
+ return (Array.isArray(list && list.data) ? list.data : []).map((m) => {
196
+ const arch = m.architecture || {};
197
+ const top = m.top_provider || {};
198
+ const params = Array.isArray(m.supported_parameters) ? m.supported_parameters : [];
199
+ const outputs = Array.isArray(arch.output_modalities) ? arch.output_modalities : ['text'];
200
+ const pricing = openRouterPricing(m.pricing);
201
+ const ctx = num(top.context_length) ?? num(m.context_length);
202
+ return {
203
+ id: String(m.id ?? ''),
204
+ name: String(m.name || m.id || ''),
205
+ kind: outputs.includes('text') ? 'llm' : 'other',
206
+ // OpenRouter serves the window it lists — unlike Ollama, there is no smaller default behind it.
207
+ servedContext: ctx,
208
+ trainedContext: null,
209
+ maxOutputTokens: num(top.max_completion_tokens),
210
+ toolCalls: params.includes('tools'),
211
+ vision: has(arch.input_modalities, 'image'),
212
+ reasoning: params.includes('reasoning') || params.includes('reasoning_effort'),
213
+ loaded: null,
214
+ pricing,
215
+ free: !!(pricing && pricing.free),
216
+ detail: !pricing ? 'variable price' : pricing.free ? 'free' : `${money(pricing.perMtok.input)} / ${money(pricing.perMtok.output)} per M`,
217
+ };
218
+ }).filter((m) => m.id);
219
+ }
220
+
145
221
  /** What the caller must know before pinning a prompt limit on what this server reports. */
146
222
  function warningsFor(server, models, props) {
147
223
  const w = [];
@@ -157,6 +233,9 @@ function warningsFor(server, models, props) {
157
233
  if (server === 'lmstudio' && models.some((m) => m.kind !== 'embedding' && !m.servedContext)) {
158
234
  w.push('LM Studio reports a model\'s real window only while it is loaded; for the others the number shown is what the model supports, and the prompt limit is left unset.');
159
235
  }
236
+ if (server === 'openrouter' && models.some((m) => m.free)) {
237
+ w.push(':free models run on a pool OpenRouter shares with every user: while it is busy EVERY request gets a 429, however few you send — Max concurrent requests cannot help. For pipelines use the paid variant, add your own provider key on OpenRouter (BYOK), or give the model a fallback in its Connection.');
238
+ }
160
239
  if (server === 'openai-compatible' && models.every((m) => !m.servedContext)) {
161
240
  w.push('This endpoint does not report context windows — set each model\'s prompt limit by hand after importing.');
162
241
  }
@@ -172,7 +251,7 @@ function warningsFor(server, models, props) {
172
251
  * @returns {Promise<{server:string, serverLabel:string, baseUrl:string, models:Array<object>, warnings:string[]}>}
173
252
  * @throws {Error} when nothing answers at all (the caller shows it as the connection failure it is)
174
253
  */
175
- export async function listEndpointModels(baseUrl, { apiKey = '', fetch: f = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
254
+ export async function listEndpointModels(baseUrl, { apiKey = '', fetch: f = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS, realBaseUrl = null } = {}) {
176
255
  const base = String(baseUrl || '').trim().replace(/\/+$/, '');
177
256
  if (!base) throw new Error('baseUrl is required');
178
257
  const root = endpointRoot(base);
@@ -182,6 +261,18 @@ export async function listEndpointModels(baseUrl, { apiKey = '', fetch: f = glob
182
261
  let models = [];
183
262
  let props = null;
184
263
 
264
+ // `realBaseUrl`: the provider URL when `baseUrl` is the credential broker's slot for it —
265
+ // what the server IS (OpenRouter or not) is decided by the real one.
266
+ if (isOpenRouter(realBaseUrl || base)) {
267
+ // A hosted API: probing it for llama.cpp / Ollama / LM Studio would cost three requests for
268
+ // three 404s. Its own /models is the whole answer.
269
+ const list = await json(f, `${base}/models`, opt);
270
+ if (!list) throw new Error(`no OpenAI-compatible model list at ${base}/models — is the base URL right?`);
271
+ models = openRouterModels(list);
272
+ models.sort((a, b) => (Number(b.kind === 'llm') - Number(a.kind === 'llm')) || a.id.localeCompare(b.id));
273
+ return { server: 'openrouter', serverLabel: SERVERS.openrouter, baseUrl: base, models, warnings: warningsFor('openrouter', models, null) };
274
+ }
275
+
185
276
  const props0 = await json(f, `${root}/props`, opt);
186
277
  if (props0 && (props0.default_generation_settings || props0.chat_template_caps)) {
187
278
  server = 'llama.cpp';
@@ -221,21 +312,34 @@ export async function listEndpointModels(baseUrl, { apiKey = '', fetch: f = glob
221
312
  * A discovered model as a catalog entry (§8.4's Copilot shape, for a server you run).
222
313
  * `baseUrl` rides the ENTRY when it differs from the provider's, so one catalog can hold an Ollama
223
314
  * and a llama.cpp model at once. A prompt limit is pinned only from a window the server really
224
- * serves; `maxOutputTokens` is never guessed — no local server reports one.
315
+ * serves; `maxOutputTokens` is never guessed — no local server reports one. OpenRouter does, and
316
+ * its cap is pinned only while it leaves at least half the window for the prompt: a cap that is
317
+ * most of the window (235929 of 262144) becomes the CLI's max_tokens, and prompt + max_tokens
318
+ * then overflows the window on the first large turn.
225
319
  * @param {object} m a row from listEndpointModels
226
320
  * @param {{server:string, baseUrl:string, providerBaseUrl?:string}} ctx
227
321
  */
228
322
  export function catalogEntryForEndpointModel(m, { server = 'openai-compatible', baseUrl, providerBaseUrl = '' } = {}) {
323
+ const window = num(m.servedContext);
324
+ const outCap = num(m.maxOutputTokens);
229
325
  const capabilities = {
230
326
  ...(m.toolCalls === true || m.toolCalls === false ? { toolCalls: m.toolCalls } : {}),
231
327
  ...(m.vision === true ? { vision: true } : {}),
232
328
  ...(m.reasoning === true ? { reasoning: true } : {}),
233
- ...(num(m.servedContext) ? { maxPromptTokens: num(m.servedContext) } : {}),
329
+ ...(window ? { maxPromptTokens: window } : {}),
330
+ ...(outCap && window && outCap <= window / 2 ? { maxOutputTokens: outCap } : {}),
234
331
  };
235
332
  const own = String(baseUrl || '').replace(/\/+$/, '');
236
333
  const provider = String(providerBaseUrl || '').replace(/\/+$/, '');
334
+ const hosted = server === 'openrouter';
335
+ // A hosted catalog keeps the vendor in the id (`qwen/…`, `anthropic/…`): the last path segment
336
+ // alone collides across vendors there. A local server's ids stay as they were.
337
+ const stem = hosted ? String(m.id).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'model' : slugModelId(m.id);
338
+ // A model on your own machine bills nothing; OpenRouter lists its price, and one it lists as
339
+ // variable is left unset rather than claimed free.
340
+ const cost = hosted ? (m.pricing || undefined) : { free: true };
237
341
  return {
238
- id: `${ID_PREFIX[server] || 'local'}-${slugModelId(m.id)}`,
342
+ id: `${endpointIdPrefix(server, own)}-${stem}`,
239
343
  label: `${m.name || m.id} (${SERVERS[server] || 'local'})`,
240
344
  ...(m.reasoning === true ? {} : { efforts: ['medium'] }),
241
345
  upstream: {
@@ -243,13 +347,14 @@ export function catalogEntryForEndpointModel(m, { server = 'openai-compatible',
243
347
  ...(own && own !== provider ? { baseUrl: own } : {}),
244
348
  ...(Object.keys(capabilities).length ? { capabilities } : {}),
245
349
  },
246
- cost: { free: true }, // a model on your own machine bills nothing; never "cost not verified"
350
+ ...(cost ? { cost } : {}),
247
351
  };
248
352
  }
249
353
 
250
354
  /** Whether a discovered model can carry a pipeline at all (the sheet greys the rest). */
251
355
  export function importableModel(m) {
252
356
  if (!m || m.kind === 'embedding') return { ok: false, why: 'an embedding model — not a chat model' };
357
+ if (m.kind === 'other') return { ok: false, why: 'does not reply in text — not a chat model' };
253
358
  if (m.toolCalls === false) return { ok: false, why: 'no tool calls — a pipeline agent cannot run without them' };
254
359
  return { ok: true };
255
360
  }
@@ -9,6 +9,8 @@ import { listGlobalModels, providerConfig, providerSecretSet, resolveProviderSec
9
9
  import { listPluginModels } from '../plugin-models.mjs';
10
10
  import { policyCatalogModels } from '../policy/cache.mjs';
11
11
  import { isLocalBaseUrl } from '../model-env.mjs';
12
+ import { brokerEnabled } from '../broker-client.mjs';
13
+ import { routeBridgedUpstream } from '../broker-routing.mjs';
12
14
 
13
15
  /** An OpenAI-compatible endpoint on this machine / a private network needs no key. */
14
16
  export function keyOptional(provider, baseUrl) {
@@ -42,6 +44,16 @@ export function findBridgedEntry(id) {
42
44
  export function providerReadiness(upstream) {
43
45
  if (!upstream) return { ok: true };
44
46
  const p = upstream.provider;
47
+ // Credential broker: worca holds no provider key or GitHub sign-in. Ready when the model
48
+ // maps to a broker slot (or is a keyless local endpoint); whether the PERSON has a key
49
+ // is the broker's question, answered per spawn. Copilot's terms stay an install setting.
50
+ if (brokerEnabled()) {
51
+ if (p === 'copilot' && !copilotTermsAcknowledged()) {
52
+ return { ok: false, reason: 'terms', message: 'provider copilot: terms not acknowledged — open Settings › Providers' };
53
+ }
54
+ const r = routeBridgedUpstream(upstream);
55
+ return r.error ? { ok: false, reason: 'no_key', message: `provider ${p}: ${r.error}` } : { ok: true };
56
+ }
45
57
  if (p === 'copilot') {
46
58
  if (!copilotTermsAcknowledged()) {
47
59
  return { ok: false, reason: 'terms', message: 'provider copilot: terms not acknowledged — open Settings › Providers' };
@@ -83,6 +95,7 @@ export function upstreamSettings(upstream) {
83
95
  accountType: p === 'copilot' ? cfg.accountType : null,
84
96
  headers: upstream.headers || {},
85
97
  capabilities: upstream.capabilities || {},
98
+ ...(upstream.openrouter ? { openrouter: upstream.openrouter } : {}),
86
99
  maxConcurrent: cfg.maxConcurrent,
87
100
  };
88
101
  }
@@ -18,6 +18,7 @@ import { findBridgedEntry } from './registry.mjs';
18
18
  import { handleMessages } from './upstream.mjs';
19
19
  import { estimateInputTokens } from './translate/response.mjs';
20
20
  import { bridgeErrors, PAYLOAD_CEILING_BYTES } from './errors.mjs';
21
+ import { brokerEnabled } from '../broker-client.mjs';
21
22
 
22
23
  let state = null; // { server, port, secret, listening: Promise<number>, log }
23
24
  const ROUTE_RE = /^\/m\/([^/]+)(?:\/r\/([^/]+))?\/v1\/(messages|messages\/count_tokens|models)\/?$/;
@@ -132,18 +133,30 @@ function sendJson(res, status, obj, headers) {
132
133
  res.end(JSON.stringify(obj));
133
134
  }
134
135
 
136
+ const BROKER_TOKEN_RE = /^wbt_[A-Za-z0-9_-]{43}$/;
137
+
138
+ /**
139
+ * The caller's credential: the bridge's own secret, or — with the credential broker on —
140
+ * the spawn's broker token, which the bridge forwards to the broker (it is the broker
141
+ * that checks it; the bridge never holds a provider key then). null = refused.
142
+ * @returns {{brokerToken:string|null}|null}
143
+ */
135
144
  function authorized(req) {
136
145
  const h = req.headers;
137
146
  const bearer = typeof h.authorization === 'string' && /^bearer\s+/i.test(h.authorization) ? h.authorization.replace(/^bearer\s+/i, '').trim() : '';
138
147
  const key = typeof h['x-api-key'] === 'string' ? h['x-api-key'].trim() : '';
139
- return (bearer && bearer === state.secret) || (key && key === state.secret);
148
+ if ((bearer && bearer === state.secret) || (key && key === state.secret)) return { brokerToken: null };
149
+ const tok = bearer || key;
150
+ if (brokerEnabled() && BROKER_TOKEN_RE.test(tok)) return { brokerToken: tok };
151
+ return null;
140
152
  }
141
153
 
142
154
  async function handle(req, res) {
143
155
  const url = new URL(req.url || '/', 'http://127.0.0.1');
144
156
  const m = ROUTE_RE.exec(url.pathname);
145
157
  if (!m) { const e = bridgeErrors.notFound(); return sendJson(res, e.status, e.body); }
146
- if (!authorized(req)) { const e = bridgeErrors.unauthorized(); return sendJson(res, e.status, e.body); }
158
+ const auth = authorized(req);
159
+ if (!auth) { const e = bridgeErrors.unauthorized(); return sendJson(res, e.status, e.body); }
147
160
  const catalogId = decodeURIComponent(m[1]);
148
161
  const tag = m[2] ? decodeURIComponent(m[2]) : '';
149
162
  const route = m[3];
@@ -180,5 +193,5 @@ async function handle(req, res) {
180
193
  };
181
194
  const headers = {};
182
195
  for (const [k, v] of Object.entries(req.headers)) if (typeof v === 'string') headers[k.toLowerCase()] = v;
183
- await handleMessages({ entry, body, requestHeaders: headers, tag, signal: ctrl.signal, fetch: state.fetch, log: state.log }, reply);
196
+ await handleMessages({ entry, body, requestHeaders: headers, tag, signal: ctrl.signal, fetch: state.fetch, log: state.log, brokerToken: auth.brokerToken }, reply);
184
197
  }