@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
@@ -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 } 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
  }
@@ -119,7 +161,9 @@ 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
+ const broker = brokerEnabled() ? { enabled: true, keyPage: cachedBrokerInfo()?.publicUrl || null, mode: cachedBrokerInfo()?.mode || null } : { enabled: false };
166
+ return { copilot, openai: keyed('openai'), anthropic: keyed('anthropic'), broker };
123
167
  }
124
168
 
125
169
  /** Patch a provider from the UI/CLI; masked key echoes are dropped ("keep"). */
@@ -127,6 +171,8 @@ export async function patchProvider(name, patch = {}) {
127
171
  if (!UPSTREAM_PROVIDERS.includes(name)) throw new Error(`unknown provider ${JSON.stringify(name)}`);
128
172
  const p = { ...patch };
129
173
  for (const k of ['apiKey', 'githubToken']) if (typeof p[k] === 'string' && p[k].startsWith('••')) delete p[k];
174
+ // Credential broker: keys are per person, on the key page; worca stores none.
175
+ if (brokerEnabled() && ['apiKey', 'githubToken'].some((k) => typeof p[k] === 'string' && p[k].trim())) refuseWithBroker('store a provider key');
130
176
  return updateProvider(name, p);
131
177
  }
132
178
 
@@ -134,10 +180,24 @@ export async function patchProvider(name, patch = {}) {
134
180
 
135
181
  /** Copilot's models for the import sheet, each with `inCatalog` (§8.4). */
136
182
  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 });
183
+ let list;
184
+ if (brokerEnabled()) {
185
+ // The signed-in person's Copilot, through the broker (their GitHub sign-in lives there).
186
+ const route = routeUpstream({ provider: 'copilot' }, { slots: (await brokerInfo()).slots || [] });
187
+ if (route.error) throw new Error(route.error);
188
+ list = await withAuxBrokerToken(route.slot, async (token) => {
189
+ const r = await (f || globalThis.fetch)(`${slotBaseUrl(route.slot)}/models`, { headers: copilotHeaders(token) });
190
+ if (!r.ok) throw Object.assign(new Error(await brokerFailure(r, 'Copilot /models')), { status: r.status });
191
+ const j = await r.json();
192
+ const data = Array.isArray(j.data) ? j.data : (Array.isArray(j) ? j : []);
193
+ return data.map(normalizeCopilotModel).filter((m) => m && (!m.type || m.type === 'chat'));
194
+ });
195
+ } else {
196
+ const c = providerConfig('copilot');
197
+ const token = resolveProviderSecret(c.githubToken);
198
+ if (!token) throw Object.assign(new Error('not signed in to GitHub Copilot'), { code: 'NOT_SIGNED_IN' });
199
+ list = await listCopilotModels(token, { accountType: c.accountType, fetch: f });
200
+ }
141
201
  const have = new Set(listGlobalModels().map((m) => m.id.toLowerCase()));
142
202
  return list.map((m) => ({ ...m, api: copilotApiFor(m), catalogId: `copilot-${m.id}`, inCatalog: have.has(`copilot-${m.id}`.toLowerCase()) }))
143
203
  .sort((a, b) => (Number(b.pickerEnabled) - Number(a.pickerEnabled)) || a.name.localeCompare(b.name));
@@ -204,19 +264,47 @@ function endpointBase(baseUrl) {
204
264
  export async function endpointModelsForImport({ baseUrl, fetch: f } = {}) {
205
265
  const base = endpointBase(baseUrl);
206
266
  const p = providerConfig('openai');
207
- const key = resolveProviderSecret(p.apiKey);
208
- const out = await listEndpointModels(base, { apiKey: key, fetch: f });
267
+ let out;
268
+ const route = brokerEnabled() ? routeUpstream({ provider: 'openai', baseUrl: base }, { slots: (await brokerInfo()).slots || [] }) : null;
269
+ if (route && route.error) throw new Error(route.error);
270
+ if (route && route.slot) {
271
+ // An endpoint behind a broker slot is asked with the clicking person's key; the listing
272
+ // then names the REAL base URL, so the imported entries point at it.
273
+ out = await withAuxBrokerToken(route.slot, (token) => listEndpointModels(`${slotBaseUrl(route.slot)}${route.prefix}`, { apiKey: token, fetch: f, realBaseUrl: base }));
274
+ out = { ...out, baseUrl: base };
275
+ } else {
276
+ // Keyless local endpoints are reached directly, as without the broker.
277
+ const key = brokerEnabled() ? '' : resolveProviderSecret(p.apiKey);
278
+ out = await listEndpointModels(base, { apiKey: key, fetch: f });
279
+ }
209
280
  const have = new Set(listGlobalModels().map((m) => m.id.toLowerCase()));
210
281
  return {
211
282
  ...out,
212
283
  models: out.models.map((m) => {
213
284
  const entry = catalogEntryForEndpointModel(m, { server: out.server, baseUrl: out.baseUrl, providerBaseUrl: p.baseUrl });
214
285
  const usable = importableModel(m);
215
- return { ...m, catalogId: entry.id, inCatalog: have.has(entry.id.toLowerCase()), importable: usable.ok, ...(usable.ok ? {} : { blocked: usable.why }) };
286
+ const existing = existingEndpointEntry(m.id, out.baseUrl, p.baseUrl);
287
+ const catalogId = existing ? existing.id : entry.id;
288
+ return { ...m, catalogId, inCatalog: !!existing || have.has(entry.id.toLowerCase()), importable: usable.ok, ...(usable.ok ? {} : { blocked: usable.why }) };
216
289
  }),
217
290
  };
218
291
  }
219
292
 
293
+ const trimUrl = (u) => String(u || '').trim().replace(/\/+$/, '').toLowerCase();
294
+
295
+ /**
296
+ * The catalog entry that already imports `upstreamId` from this endpoint, whatever its id says.
297
+ * Ids are derived (and their prefix changed: a remote endpoint's models were `local-…`), so a
298
+ * re-import matches on what the entry POINTS AT — the openai provider, this upstream id, and this
299
+ * base URL (its own, or the provider's when it carries none) — and refreshes the entry the user
300
+ * already has instead of adding a twin under the new id.
301
+ */
302
+ function existingEndpointEntry(upstreamId, baseUrl, providerBaseUrl) {
303
+ const want = trimUrl(baseUrl);
304
+ return listGlobalModels().find((x) => x.upstream && x.upstream.provider === 'openai'
305
+ && x.upstream.model === upstreamId && trimUrl(x.upstream.baseUrl || providerBaseUrl) === want) || null;
306
+ }
307
+
220
308
  /**
221
309
  * Import endpoint models into the catalog. A new id gets the full entry; an existing one keeps the
222
310
  * label, efforts and pricing you edited and only has its upstream refreshed.
@@ -235,7 +323,8 @@ export async function importEndpointModels(ids, { baseUrl, fetch: f } = {}) {
235
323
  if (!m) { skipped.push({ id, why: 'the endpoint does not serve it' }); continue; }
236
324
  if (!m.importable) { skipped.push({ id, why: m.blocked || 'not usable in a pipeline' }); continue; }
237
325
  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());
326
+ const current = existingEndpointEntry(m.id, out.baseUrl, p.baseUrl)
327
+ || listGlobalModels().find((x) => x.id.toLowerCase() === entry.id.toLowerCase());
239
328
  if (current) {
240
329
  if (!current.upstream || current.upstream.provider !== 'openai') { skipped.push({ id, why: `"${current.id}" already exists and is not an OpenAI-compatible entry` }); continue; }
241
330
  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 +337,60 @@ export async function importEndpointModels(ids, { baseUrl, fetch: f } = {}) {
248
337
  return { created, updated, skipped, server: out.server, serverLabel: out.serverLabel, baseUrl: out.baseUrl, warnings: out.warnings };
249
338
  }
250
339
 
340
+ // ── OpenRouter: the key's own limits ────────────────────────────────────────
341
+
342
+ const finiteOrNull = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
343
+
344
+ /**
345
+ * What OpenRouter says about the key (GET {base}/key): credit limit and what is left, spend, the
346
+ * free-model daily allowance and the rate limit. The `label` is dropped — OpenRouter builds it from
347
+ * the key itself (`sk-or-v1-abc…xyz`). Never throws: null when there is no key or no answer.
348
+ * @returns {Promise<null|{limit:number|null, limitRemaining:number|null, usage:number|null, usageDaily:number|null,
349
+ * isFreeTier:boolean, freeDaily:{used:number,limit:number,remaining:number}|null, rateLimit:object|null}>}
350
+ */
351
+ export async function openRouterKeyInfo(baseUrl, apiKey, { fetch: f = globalThis.fetch, timeoutMs = 10_000 } = {}) {
352
+ if (!apiKey) return null;
353
+ try {
354
+ const r = await f(`${String(baseUrl || '').replace(/\/+$/, '')}/key`, { headers: { authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(timeoutMs) });
355
+ if (!r.ok) return null;
356
+ const j = await r.json();
357
+ const d = j && j.data;
358
+ if (!d || typeof d !== 'object') return null;
359
+ const fd = d.free_model_daily_requests;
360
+ const rl = d.rate_limit;
361
+ return {
362
+ limit: finiteOrNull(d.limit),
363
+ limitRemaining: finiteOrNull(d.limit_remaining),
364
+ usage: finiteOrNull(d.usage),
365
+ usageDaily: finiteOrNull(d.usage_daily),
366
+ isFreeTier: d.is_free_tier === true,
367
+ freeDaily: fd && typeof fd === 'object' && finiteOrNull(fd.limit) !== null
368
+ ? { used: finiteOrNull(fd.used) ?? 0, limit: fd.limit, remaining: finiteOrNull(fd.remaining) ?? Math.max(0, fd.limit - (finiteOrNull(fd.used) ?? 0)) }
369
+ : null,
370
+ // OpenRouter answers `requests: -1` for a key with no request-rate limit of its own.
371
+ rateLimit: rl && typeof rl === 'object' && finiteOrNull(rl.requests) > 0 ? { requests: rl.requests, interval: String(rl.interval || '') } : null,
372
+ };
373
+ } catch { return null; }
374
+ }
375
+
376
+ /** One line for the Providers card and `worca models test`. Pure. */
377
+ export function formatOpenRouterKeyInfo(info) {
378
+ if (!info) return '';
379
+ const usd = (n) => `$${n.toFixed(2)}`;
380
+ const bits = [];
381
+ if (info.limit !== null && info.limit !== undefined) {
382
+ const left = info.limitRemaining ?? (info.usage !== null && info.usage !== undefined ? Math.max(0, info.limit - info.usage) : null);
383
+ bits.push(left !== null ? `credit ${usd(left)} of ${usd(info.limit)} left` : `credit limit ${usd(info.limit)}`);
384
+ } else {
385
+ bits.push('no credit limit');
386
+ if (info.usage !== null && info.usage !== undefined) bits.push(`${usd(info.usage)} used`);
387
+ }
388
+ if (info.freeDaily) bits.push(`free-model requests today ${info.freeDaily.remaining} / ${info.freeDaily.limit}`);
389
+ if (info.isFreeTier) bits.push('free tier');
390
+ if (info.rateLimit) bits.push(`rate limit ${info.rateLimit.requests} per ${info.rateLimit.interval}`);
391
+ return bits.join(' · ');
392
+ }
393
+
251
394
  // ── key-based providers: connection test ────────────────────────────────────
252
395
 
253
396
  /**
@@ -261,6 +404,11 @@ export async function importEndpointModels(ids, { baseUrl, fetch: f } = {}) {
261
404
  * @returns {Promise<{ok:true, models?:number}|{ok:false, message:string}>}
262
405
  */
263
406
  export async function testProviderConnection(name, { fetch: f = globalThis.fetch, baseUrl = '', apiKey } = {}) {
407
+ // Credential broker: keys are per person and tested where they are saved.
408
+ if (brokerEnabled()) {
409
+ const keyPage = cachedBrokerInfo()?.publicUrl;
410
+ return { ok: false, message: `keys are held by the credential broker: test yours on the key page${keyPage ? ` (${keyPage})` : ''}` };
411
+ }
264
412
  if (name === 'copilot') {
265
413
  const c = providerConfig('copilot');
266
414
  const token = resolveProviderSecret(c.githubToken);
@@ -287,6 +435,14 @@ export async function testProviderConnection(name, { fetch: f = globalThis.fetch
287
435
  if (!r.ok) return { ok: false, message: `endpoint answered ${r.status}` };
288
436
  const j = await r.json().catch(() => null);
289
437
  const n = j && Array.isArray(j.data) ? j.data.length : undefined;
438
+ // OpenRouter lists its models without a key, so a reachable list proves nothing about the key:
439
+ // its /key does, and says what the key may still spend. A key it rejects fails the test.
440
+ if (name === 'openai' && isOpenRouter(base)) {
441
+ if (!key) return { ok: false, message: 'no API key configured — OpenRouter lists models without one, but every call needs it' };
442
+ const info = await openRouterKeyInfo(base, key, { fetch: f });
443
+ if (!info) return { ok: false, message: 'authentication failed — OpenRouter did not accept the key (its /key check failed)' };
444
+ return { ok: true, ...(n !== undefined ? { models: n } : {}), openrouter: info, detail: formatOpenRouterKeyInfo(info) };
445
+ }
290
446
  return { ok: true, ...(n !== undefined ? { models: n } : {}) };
291
447
  } catch (err) {
292
448
  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
  }
@@ -13,7 +13,7 @@ import { EventEmitter } from 'node:events';
13
13
  export const bridgeEvents = new EventEmitter();
14
14
  bridgeEvents.setMaxListeners(50);
15
15
 
16
- const calls = new Map(); // tag -> { initiated, continued, errors }
16
+ const calls = new Map(); // tag -> { initiated, continued, errors, free }
17
17
  const MAX_TAGS = 5000;
18
18
 
19
19
  function slot(tag) {
@@ -21,33 +21,65 @@ function slot(tag) {
21
21
  let s = calls.get(k);
22
22
  if (!s) {
23
23
  if (calls.size >= MAX_TAGS) calls.delete(calls.keys().next().value);
24
- s = { initiated: 0, continued: 0, errors: 0 };
24
+ s = { initiated: 0, continued: 0, errors: 0, free: 0 };
25
25
  calls.set(k, s);
26
26
  }
27
27
  return s;
28
28
  }
29
29
 
30
- /** Book one upstream call. `initiator` is 'user' | 'agent' (§7.1). */
31
- export function recordBridgeCall({ tag, catalogId, provider, api, initiator }) {
30
+ /**
31
+ * Book one upstream call. `initiator` is 'user' | 'agent' (§7.1). `free`: an OpenRouter
32
+ * `:free` model, where EVERY call (continuations too) spends one of the day's free requests
33
+ * (openrouter-free.mjs); `account` names the key it spent from, when the bridge knows it.
34
+ */
35
+ export function recordBridgeCall({ tag, catalogId, provider, api, initiator, free = false, account = null }) {
32
36
  const s = slot(tag);
33
37
  if (initiator === 'agent') s.continued += 1; else s.initiated += 1;
34
- bridgeEvents.emit('call', { tag: tag || '', catalogId, provider, api, initiator });
38
+ if (free) s.free += 1;
39
+ bridgeEvents.emit('call', { tag: tag || '', catalogId, provider, api, initiator, free, account });
35
40
  }
36
41
 
37
42
  /** Book one failed upstream call. */
38
- export function recordBridgeError({ tag, catalogId, provider, status, message }) {
43
+ export function recordBridgeError({ tag, catalogId, provider, status, message, account = null }) {
39
44
  slot(tag).errors += 1;
40
- bridgeEvents.emit('failure', { tag: tag || '', catalogId, provider, status, message });
45
+ bridgeEvents.emit('failure', { tag: tag || '', catalogId, provider, status, message, account });
41
46
  }
42
47
 
43
- /** Counters for a tag: {initiated, continued, errors}; zeros when unseen. */
48
+ /** Counters for a tag: {initiated, continued, errors, free}; zeros when unseen. */
44
49
  export function bridgeCallsFor(tag) {
45
50
  const s = calls.get(tag || '');
46
- return s ? { ...s } : { initiated: 0, continued: 0, errors: 0 };
51
+ return s ? { ...s } : { initiated: 0, continued: 0, errors: 0, free: 0 };
47
52
  }
48
53
 
49
- /** Forget a tag's counters (a finished run). */
50
- export function forgetBridgeTag(tag) { calls.delete(tag || ''); }
54
+ // The USD an upstream itself reported for a tag's calls (OpenRouter's
55
+ // usage.cost). Kept apart from the call counters: a priced call is the
56
+ // exception, and the run harness prefers this figure over the CLI's $0.
57
+ const costs = new Map(); // tag -> { costUsd, calls }
58
+
59
+ /** Book the cost one upstream call reported. Ignores anything but a finite, non-negative number. */
60
+ export function recordBridgeCost({ tag, costUsd }) {
61
+ const n = Number(costUsd);
62
+ if (costUsd == null || !Number.isFinite(n) || n < 0) return;
63
+ const k = tag || '';
64
+ let c = costs.get(k);
65
+ if (!c) {
66
+ if (costs.size >= MAX_TAGS) costs.delete(costs.keys().next().value);
67
+ c = { costUsd: 0, calls: 0 };
68
+ costs.set(k, c);
69
+ }
70
+ // Rounded to 1e-9 USD: summing float fractions of a cent would otherwise drift.
71
+ c.costUsd = Math.round((c.costUsd + n) * 1e9) / 1e9;
72
+ c.calls += 1;
73
+ }
74
+
75
+ /** The upstream-reported cost for a tag: {costUsd, calls}, or null when no call reported one. */
76
+ export function bridgeCostFor(tag) {
77
+ const c = costs.get(tag || '');
78
+ return c ? { ...c } : null;
79
+ }
80
+
81
+ /** Forget a tag's counters and cost (a finished run). */
82
+ export function forgetBridgeTag(tag) { calls.delete(tag || ''); costs.delete(tag || ''); }
51
83
 
52
84
  /** Test hook. */
53
- export function _resetBridgeTelemetry() { calls.clear(); }
85
+ export function _resetBridgeTelemetry() { calls.clear(); costs.clear(); }