@worca/app 1.5.0-rc.3 → 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
@@ -0,0 +1,76 @@
1
+ // src/core/ask/web-proposal.mjs
2
+ // The ONE validator behind mcp__worca__propose_web_access, the event/notice text of the web card,
3
+ // and the per-chat host list the cards record. The model asks to read a host that is not allowed
4
+ // yet; the user answers "for this chat", "always" or declines (docs/guardrails.md "Web access").
5
+ // Pure: the allowed list and the team cap are injected, so the MCP child validates for the model's
6
+ // self-correction and the parent turn re-validates authoritatively and mints the card — the
7
+ // clone-proposal.mjs split. Nothing here allows anything: the card route in ui/server.mjs does,
8
+ // behind the user's click.
9
+ import { checkWebUrl } from './web-fetch.mjs';
10
+ import { ANY_HOST, hostAllowed } from '../web-allowlist.mjs';
11
+
12
+ const str = (v) => (typeof v === 'string' ? v.trim() : '');
13
+ // eslint-disable-next-line no-control-regex
14
+ const BREAKS_RE = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/g;
15
+ const clip = (v, n) => String(v ?? '').replace(BREAKS_RE, ' ').slice(0, n);
16
+
17
+ /**
18
+ * @param {object} r
19
+ * @param {() => string[]} r.allowed this turn's effective allowlist (a card for an allowed host is pointless)
20
+ * @param {() => string[]|null} [r.teamCap] the team allowlist cap, or null
21
+ */
22
+ export function createWebValidator({ allowed, teamCap = () => null }) {
23
+ /** @returns {Promise<{ok:true, card:object}|{ok:false, errors:string[]}>} */
24
+ return async function validateWebProposal(input) {
25
+ const inp = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
26
+ if (!str(inp.url)) return { ok: false, errors: ['url is required'] };
27
+ let url;
28
+ // Every URL rule except the allowlist (https, port, no credentials, no IP, no data in the URL):
29
+ // the card shows the exact URL, and a URL web_fetch would refuse anyway is never proposed.
30
+ try { url = checkWebUrl(str(inp.url), [ANY_HOST]); } catch (err) { return { ok: false, errors: [err.message] }; }
31
+ const host = url.hostname;
32
+ if (hostAllowed(host, allowed())) return { ok: false, errors: [`${host} is already allowed — call web_fetch instead`] };
33
+ const cap = teamCap();
34
+ if (Array.isArray(cap) && !hostAllowed(host, cap)) {
35
+ return { ok: false, errors: [`${host} is outside the team policy's web allowlist for this project — tell the user; a card cannot allow it`] };
36
+ }
37
+ const reason = clip(str(inp.reason), 200);
38
+ return { ok: true, card: {
39
+ type: 'web', kind: 'web', summary: `Read ${host}`, host, url: url.href.slice(0, 500),
40
+ ...(reason ? { reason } : {}),
41
+ change: { host },
42
+ } };
43
+ };
44
+ }
45
+
46
+ /** The hosts this chat's web cards allowed "for this chat" (the cards are the record). */
47
+ export function chatWebHosts(messages) {
48
+ const out = [];
49
+ for (const m of messages || []) {
50
+ for (const b of Array.isArray(m?.blocks) ? m.blocks : []) {
51
+ if (b && b.kind === 'card' && b.state === 'applied' && b.card?.type === 'web' && b.card.result?.scope === 'chat'
52
+ && typeof b.card.host === 'string' && !out.includes(b.card.host)) out.push(b.card.host);
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ const eventText = (s, n) => clip(s, n).replace(/"/g, "'").replace(/\[(\/?)worca context\]/gi, '($1worca context)');
59
+
60
+ /** `[worca event] …` — the synthetic turn's prompt after the user answered a web card. */
61
+ export function webEventPrompt({ cardId, state, card = {}, result = null }) {
62
+ const summary = eventText(card.summary, 200);
63
+ const host = eventText(card.host, 253);
64
+ if (state === 'declined') return `[worca event] web card ${cardId} declined: do not fetch ${host} — answer without it; "${summary}"`;
65
+ if (state === 'failed') return `[worca event] web card ${cardId} failed: ${eventText(result?.error || 'unknown error', 300)}; "${summary}"`;
66
+ const scope = result?.scope === 'always' ? 'is on the allowlist from now on' : 'is allowed for this chat';
67
+ return `[worca event] web card ${cardId} applied: ${host} ${scope} — fetch it now; "${summary}"`;
68
+ }
69
+
70
+ /** The user-row notice above the event turn. */
71
+ export function webNoticeText({ state, card = {}, result = null }) {
72
+ const host = clip(card.host, 253);
73
+ if (state === 'declined') return `Declined — ${clip(card.summary, 160)}`;
74
+ if (state === 'failed') return `Could not allow ${host}: ${clip(result?.error || 'unknown error', 200)}`;
75
+ return result?.scope === 'always' ? `Always allowing ${host}` : `Allowed ${host} for this chat`;
76
+ }
@@ -8,6 +8,7 @@
8
8
  import { runClaude, mockEnabled } from '../claude-runner.mjs';
9
9
  import { resolveModelEnv, resolveModelCost } from '../config.mjs';
10
10
  import { safeParseJson } from '../protocol.mjs';
11
+ import { classifyError } from '../recoverable-error.mjs';
11
12
  import { normalizeShape, ShapeError, cleanText, SHAPE_LIMITS } from '../../shared/graph/assemble.mjs';
12
13
  import { RECIPE_GUIDE, mockShapeFor } from './recipes.mjs';
13
14
 
@@ -27,7 +28,12 @@ export const VOCAB_LIMITS = Object.freeze({ maxAgents: 32, purpose: 300, role: 4
27
28
  export class ClassifierError extends Error {
28
29
  /** `costUsd`/`usage` = what the FAILED attempts already spent (two billed replies
29
30
  * behind CLASSIFIER_FAILED, a partial reply behind a timeout): the caller books it. */
30
- constructor(code, detail, issues = [], { costUsd = 0, usage = null } = {}) {
31
+ /** `errorClass` = the recovery class of the runner failure behind it
32
+ * (recoverable-error.mjs), stamped so classifyError() reads it instead of sniffing
33
+ * the wrapped message: a rate_limit/network cause is retried and, if it outlasts
34
+ * the retries, the run falls back to the default workflow. Every other failure — a
35
+ * timeout, an unusable reply — stays null and keeps the D17 error-pause. */
36
+ constructor(code, detail, issues = [], { costUsd = 0, usage = null, errorClass = null } = {}) {
31
37
  super(`${code === 'CLASSIFIER_TIMEOUT' ? 'the workflow classifier timed out' : 'the workflow classifier failed'}: ${detail}`);
32
38
  this.name = 'ClassifierError';
33
39
  this.code = code;
@@ -35,6 +41,7 @@ export class ClassifierError extends Error {
35
41
  this.issues = issues;
36
42
  this.costUsd = Number.isFinite(Number(costUsd)) ? Number(costUsd) : 0;
37
43
  this.usage = usage;
44
+ this.errorClass = errorClass;
38
45
  }
39
46
  }
40
47
 
@@ -146,7 +153,7 @@ export function shapeForPrompt(shape) {
146
153
  return { ...shape, stages: (Array.isArray(shape.stages) ? shape.stages : []).map((u) => (isObject(u) && Array.isArray(u.parallel) ? { ...u, parallel: u.parallel.map(flatStage) } : flatStage(u))) };
147
154
  }
148
155
 
149
- export function buildClassifierSystemPrompt({ agents = [], models = [], humanInLoop = true, repoLook = false } = {}) {
156
+ export function buildClassifierSystemPrompt({ agents = [], models = [], humanInLoop = true, repoLook = false, requireModel = false } = {}) {
150
157
  const modelLines = models.filter((m) => m && !m.hidden).map((m) => `- ${m.id}${m.label && m.label !== m.id ? ` (${m.label})` : ''}: efforts ${(m.efforts || []).join('/')}`);
151
158
  return [
152
159
  'You design a worca workflow for ONE software task. Reply with exactly one fenced ```json block containing a shape object and nothing else.',
@@ -176,7 +183,10 @@ export function buildClassifierSystemPrompt({ agents = [], models = [], humanInL
176
183
  '',
177
184
  RECIPE_GUIDE,
178
185
  '',
179
- '## Models (use only these ids; omit both "model" and "effort" to run on the default model — an effort without a model is rejected)',
186
+ requireModel
187
+ // Signed out (auto/runnable.mjs): the default model is the CLI's own and cannot run here.
188
+ ? '## Models (use only these ids; every stage MUST name one of these models — this install has no default model; an effort without a model is rejected)'
189
+ : '## Models (use only these ids; omit both "model" and "effort" to run on the default model — an effort without a model is rejected)',
180
190
  ...modelLines,
181
191
  'Tuning guide: planning and review stages deserve the strongest model at high effort; producer stages (checklist, decomposer) the cheapest; the implementer a strong model at medium or high effort; set fanOut only where allowed and only for wide tasks.',
182
192
  'size and signals are shown to the user as chips: keep them short and literal.',
@@ -216,7 +226,7 @@ export function parseShapeReply(text) {
216
226
 
217
227
  /** Catalog check of the per-stage model/effort picks; canonicalises the id casing IN PLACE.
218
228
  * Hidden catalog entries are accepted (a hidden id still resolves), they are just never offered. */
219
- export function checkShapeModels(shape, models) {
229
+ export function checkShapeModels(shape, models, { requireModel = false } = {}) {
220
230
  const byId = new Map((models || []).map((m) => [String(m.id).toLowerCase(), m]));
221
231
  const issues = [];
222
232
  for (const st of flat(shape)) {
@@ -228,6 +238,8 @@ export function checkShapeModels(shape, models) {
228
238
  if (t.effort !== undefined && !(m.efforts || []).includes(t.effort)) issues.push({ code: 'BAD_EFFORT', message: `stage "${st.id}": model ${m.id} has no effort "${t.effort}"`, stageId: st.id });
229
239
  } else if (t.effort !== undefined) {
230
240
  issues.push({ code: 'EFFORT_WITHOUT_MODEL', message: `stage "${st.id}": an effort needs a model`, stageId: st.id });
241
+ } else if (requireModel) {
242
+ issues.push({ code: 'MISSING_MODEL', message: `stage "${st.id}": needs a model — this install has no default model (Claude Code is not signed in)`, stageId: st.id });
231
243
  }
232
244
  }
233
245
  return issues;
@@ -247,7 +259,7 @@ export function withCardsSignal(shape, n) {
247
259
  */
248
260
  export async function classifyTask(input, deps = {}) {
249
261
  const {
250
- taskText = '', extras = [], fingerprint = '', models = [], humanInLoop = true, feedback = [], priorShape = null, registry = {}, domain = null,
262
+ taskText = '', extras = [], fingerprint = '', models = [], humanInLoop = true, feedback = [], priorShape = null, registry = {}, domain = null, requireModel = false,
251
263
  model, modelEnv, cwd = process.cwd(), bin, mock = false, signal, envScrub, envAllowlist, maxAttempts = 2, repoLook = false, timeoutMs,
252
264
  } = input || {};
253
265
  const timeout = Number.isFinite(timeoutMs) ? timeoutMs : (repoLook ? REPO_LOOK_TIMEOUT_MS : CLASSIFIER_TIMEOUT_MS);
@@ -259,7 +271,7 @@ export async function classifyTask(input, deps = {}) {
259
271
  return { shape: withCardsSignal(normalizeShape(mockShapeFor(taskText, { humanInLoop })), agents.length), warnings: [], attempts: 0, costUsd: 0, usage, raw: '', model: model || null };
260
272
  }
261
273
  const known = new Set(agents.map((a) => a.key));
262
- const systemPrompt = buildClassifierSystemPrompt({ agents, models, humanInLoop, repoLook });
274
+ const systemPrompt = buildClassifierSystemPrompt({ agents, models, humanInLoop, repoLook, requireModel });
263
275
  const nudge = repoLook ? ' Do not spend more tool calls: reply with the shape now.' : '';
264
276
  let fb = [...feedback];
265
277
  let prior = priorShape;
@@ -323,7 +335,7 @@ export async function classifyTask(input, deps = {}) {
323
335
  fb = [...fb, `Your previous attempt ran out of turns before replying with a shape.${nudge || ' Reply with the shape now.'}`];
324
336
  continue;
325
337
  }
326
- throw new ClassifierError('CLASSIFIER_FAILED', err?.message || String(err), [], { costUsd, usage });
338
+ throw new ClassifierError('CLASSIFIER_FAILED', err?.message || String(err), [], { costUsd, usage, errorClass: classifyError(err) });
327
339
  } finally {
328
340
  clearTimeout(timer);
329
341
  if (signal) signal.removeEventListener?.('abort', onOuterAbort);
@@ -338,7 +350,7 @@ export async function classifyTask(input, deps = {}) {
338
350
  try { shape = normalizeShape(raw); } catch (e) { if (e instanceof ShapeError) issues = e.issues; else throw e; }
339
351
  if (shape) {
340
352
  for (const st of flat(shape)) if (!known.has(st.agent)) issues.push({ code: 'UNKNOWN_AGENT', message: `stage "${st.id}": unknown agent "${st.agent}"`, stageId: st.id });
341
- issues.push(...checkShapeModels(shape, models));
353
+ issues.push(...checkShapeModels(shape, models, { requireModel }));
342
354
  }
343
355
  }
344
356
  if (!issues.length) return { shape: withCardsSignal(shape, agents.length), warnings, attempts: attempt, costUsd, usage, raw: text, model: model || null };
@@ -0,0 +1,28 @@
1
+ // src/core/auto/runnable.mjs
2
+ // The models Auto may design with: the ones this install can actually RUN. A bridged model
3
+ // whose provider is not set up never is. With Claude Code signed out (a hosted worca that
4
+ // reaches models only through OpenRouter or a gateway), a first-party id is not either — and
5
+ // neither is "the default model" a stage without one falls back to, since that is the CLI's
6
+ // own. So signed out, only endpoint-routed / bridged models are offered and every stage must
7
+ // name one. An unknown sign-in state (mock, a probe that could not run) narrows nothing.
8
+
9
+ /**
10
+ * @param {Array<{id:string, bridged?:string, needsSignIn?:boolean}>} models listModels() output
11
+ * @param {{auth:'signed-in'|'signed-out'|'unknown', routed:(id:string)=>boolean}} o
12
+ * @returns {{models:Array, requireModel:boolean, note:(string|null)}}
13
+ */
14
+ export function autoModelsFor(models, { auth, routed }) {
15
+ const ready = (Array.isArray(models) ? models : []).filter((m) => m && !m.needsSignIn);
16
+ if (auth !== 'signed-out') return { models: ready, requireModel: false, note: null };
17
+ const usable = ready.filter((m) => routed(m.id));
18
+ if (!usable.length) {
19
+ return {
20
+ models: ready, requireModel: false,
21
+ note: "Claude Code isn't signed in and no endpoint or provider model is set up — the run's first-party models will fail; sign in, or add a model under Settings › Providers.",
22
+ };
23
+ }
24
+ return {
25
+ models: usable, requireModel: true,
26
+ note: `Claude Code isn't signed in — Auto designs with the ${usable.length} model${usable.length === 1 ? '' : 's'} routed through an endpoint or provider, and names one on every stage.`,
27
+ };
28
+ }
@@ -0,0 +1,53 @@
1
+ // src/core/billing.mjs
2
+ // Who pays for a spawn (plans/credential-broker-design.html §5.5): the person
3
+ // whose action caused it. The server sets the acting person once per request
4
+ // (ui/server.mjs) and around a run's own loop (run-harness.mjs); every claude spawn
5
+ // started inside that async context is billed to them, without threading a
6
+ // parameter through every call site. An explicit `billTo` on a spawn still wins.
7
+ import { AsyncLocalStorage } from 'node:async_hooks';
8
+
9
+ const als = new AsyncLocalStorage();
10
+
11
+ /** A usable bill-to: a lower-cased email, 'local', or null. */
12
+ export function normalizeBillTo(v) {
13
+ const s = typeof v === 'string' ? v.trim().toLowerCase() : '';
14
+ if (!s) return null;
15
+ if (s === 'local') return 'local';
16
+ return /^[^\s@<>]{1,200}@[^\s@<>]{1,200}$/.test(s) ? s : null;
17
+ }
18
+
19
+ /**
20
+ * Run `fn` with `billTo` as the acting person (an unusable value leaves the context unset).
21
+ * `owner` is whose work it is — whose agent user (agent-pool.mjs) its processes run as.
22
+ * It defaults to the payer; a resumed run pays as the resumer but keeps its starter's
23
+ * agent user, where Claude Code keeps the run's sessions.
24
+ */
25
+ export function withBillTo(billTo, fn, { owner } = {}) {
26
+ const b = normalizeBillTo(billTo);
27
+ return als.run({ billTo: b, owner: owner === undefined ? b : normalizeBillTo(owner) }, fn);
28
+ }
29
+
30
+ /** Enter `billTo` for the rest of the current synchronous execution and its async continuations (Express middleware). */
31
+ export function enterBillTo(billTo) {
32
+ const b = normalizeBillTo(billTo);
33
+ als.enterWith({ billTo: b, owner: b });
34
+ }
35
+
36
+ /** The acting person in this async context, or null. */
37
+ export function currentBillTo() {
38
+ return als.getStore()?.billTo ?? null;
39
+ }
40
+
41
+ /** Whose work this async context is (see withBillTo), or null. */
42
+ export function currentOwner() {
43
+ const s = als.getStore();
44
+ return s ? (s.owner ?? s.billTo ?? null) : null;
45
+ }
46
+
47
+ /**
48
+ * The person a spawn is billed to: an explicit value, else the async context,
49
+ * else WORCA_BROKER_SYSTEM_BILL_TO, else null (the caller refuses in multi mode).
50
+ */
51
+ export function resolveBillTo(explicit, env = process.env) {
52
+ return normalizeBillTo(explicit) || currentBillTo() || normalizeBillTo(env.WORCA_BROKER_SYSTEM_BILL_TO) || null;
53
+ }
@@ -18,16 +18,58 @@ export function anthropicError(status, type, message) {
18
18
  return { status, body: { type: 'error', error: { type, message: String(message || type) } } };
19
19
  }
20
20
 
21
- /** Pull a human message out of an upstream error body (JSON or text). */
21
+ const MESSAGE_MAX = 500;
22
+
23
+ /** A provider's own error text: a JSON body's message, else the text itself. */
24
+ function rawProviderText(raw) {
25
+ if (typeof raw !== 'string' || !raw.trim()) return '';
26
+ try {
27
+ const j = JSON.parse(raw);
28
+ const e = j && (j.error || j);
29
+ if (e && typeof e === 'object' && typeof e.message === 'string') return e.message;
30
+ if (typeof e === 'string') return e;
31
+ } catch { /* plain text */ }
32
+ return raw.trim();
33
+ }
34
+
35
+ /**
36
+ * Pull a human message out of an upstream error body (JSON or text). A router
37
+ * (OpenRouter) answers with a generic `message` ("Provider returned error") and
38
+ * puts the provider's own explanation in `error.metadata.raw` — the line that
39
+ * says what to do (rate-limited upstream: retry, or bring your own key) — so
40
+ * the two are joined. Capped: a provider can return a whole HTML page.
41
+ */
22
42
  export function upstreamMessage(text) {
23
43
  if (!text) return '';
24
44
  try {
25
45
  const j = JSON.parse(text);
26
46
  const e = j && (j.error || j);
27
- if (e && typeof e === 'object') return e.message || e.msg || e.code || JSON.stringify(e);
28
- if (typeof e === 'string') return e;
47
+ if (e && typeof e === 'object') {
48
+ const base = e.message || e.msg || e.code || '';
49
+ const raw = rawProviderText(e.metadata && e.metadata.raw);
50
+ const joined = raw && raw !== base ? (base ? `${base} — ${raw}` : raw) : String(base);
51
+ return (joined || JSON.stringify(e)).slice(0, MESSAGE_MAX);
52
+ }
53
+ if (typeof e === 'string') return e.slice(0, MESSAGE_MAX);
29
54
  } catch { /* not JSON */ }
30
- return String(text).slice(0, 500);
55
+ return String(text).slice(0, MESSAGE_MAX);
56
+ }
57
+
58
+ /** What a router (OpenRouter) says beside an error: the provider that answered, and whose limit it hit. */
59
+ function routerMetadata(text) {
60
+ try {
61
+ const m = JSON.parse(text)?.error?.metadata;
62
+ if (!m || typeof m !== 'object') return {};
63
+ // A rate limit's own headers ride the body too: X-RateLimit-Reset is when it lifts (ms).
64
+ const h = m.headers && typeof m.headers === 'object' ? m.headers : {};
65
+ const resetRaw = Object.entries(h).find(([k]) => k.toLowerCase() === 'x-ratelimit-reset')?.[1];
66
+ const reset = Number(resetRaw);
67
+ return {
68
+ providerName: typeof m.provider_name === 'string' ? m.provider_name : '',
69
+ limitSource: typeof m.limit_source === 'string' ? m.limit_source : '',
70
+ resetAt: Number.isFinite(reset) && reset > 1e12 ? new Date(reset).toISOString() : '',
71
+ };
72
+ } catch { return {}; }
31
73
  }
32
74
 
33
75
  /** The machine-readable code in an upstream error body ('' when there is none). */
@@ -50,6 +92,9 @@ export function unsupportedApiFix(provider) {
50
92
  : 'this model needs a different API: change its API in the model editor';
51
93
  }
52
94
 
95
+ /** A 403 body that is about the credential itself (bad, expired or unscoped key) — still an auth failure. */
96
+ const AUTH_403_RE = /\b(api[ _-]?key|token|credential|unauthori[sz]ed|authenticat|invalid key|expired|revoked|sign(ed)? ?in|log(ged)? ?in)/i;
97
+
53
98
  /** Error codes for a prompt past the model's context window: OpenAI's, and Copilot's own limit check. */
54
99
  const CONTEXT_CODES = new Set(['context_length_exceeded', 'model_max_prompt_tokens_exceeded']);
55
100
 
@@ -84,11 +129,38 @@ export function isFailedResponseOverflow(code, message) {
84
129
  export function mapUpstreamError(status, text, { provider = 'upstream', retryAfter } = {}) {
85
130
  const msg = upstreamMessage(text);
86
131
  const who = provider;
132
+ // The credential broker's own refusals (a missing key, a spent cap, a dead token) keep
133
+ // their 403 and their words: re-wrapped as a 401 the CLI would retry for minutes, and the
134
+ // `worca-broker:` prefix is what run pauses and the key-page hint key on.
135
+ if (/^worca-broker:/.test(msg)) {
136
+ const type = /quota reached|not allowed/.test(msg) ? 'permission_error' : 'authentication_error';
137
+ return anthropicError(status === 429 ? 429 : 403, status === 429 ? 'rate_limit_error' : type, msg);
138
+ }
139
+ // A 403 whose body names something other than the credential is a POLICY
140
+ // refusal (OpenRouter gates some :free models to listed agent apps: "only
141
+ // available on agentic harnesses"). Calling that an auth failure sends the
142
+ // user to re-enter a key that works; the body is the real reason, and it is
143
+ // permanent, so nothing downstream classifies it as retryable. It reaches the
144
+ // CLI as a 400: the CLI reads ANY 403 from its endpoint as a sign-in failure
145
+ // ("Failed to authenticate" / "Not logged in · Please run /login") and buries
146
+ // the reason under it.
147
+ if (status === 403 && /[a-z]{3}/i.test(msg) && !AUTH_403_RE.test(msg)) {
148
+ return anthropicError(400, 'invalid_request_error', `${who}: refused (403) — ${msg}`);
149
+ }
87
150
  if (status === 401 || status === 403) {
88
151
  return anthropicError(401, 'authentication_error', `${who}: authentication failed (${status})${msg ? ` — ${msg}` : ''}`);
89
152
  }
90
153
  if (status === 429) {
91
- const e = anthropicError(429, 'rate_limit_error', `${who}: rate limited (429)${msg ? ` — ${msg}` : ''}`);
154
+ // A router's 429 can come from a pool every one of its users shares
155
+ // (OpenRouter's `:free` models), not from this install's traffic: name the
156
+ // provider behind it and the limit's source, so the run surfaces can say
157
+ // that lowering Max concurrent requests will not help.
158
+ const { providerName, limitSource, resetAt } = routerMetadata(text);
159
+ const via = providerName ? ` via ${providerName}` : '';
160
+ const source = limitSource ? ` [${limitSource}]` : '';
161
+ // When the limit lifts, if the router said: a daily allowance pauses the run until then.
162
+ const reset = resetAt ? ` resets ${resetAt}` : '';
163
+ const e = anthropicError(429, 'rate_limit_error', `${who}: rate limited (429)${via}${msg ? ` — ${msg}` : ''}${source}${reset}`);
92
164
  if (retryAfter) e.headers = { 'retry-after': String(retryAfter) };
93
165
  return e;
94
166
  }
@@ -0,0 +1,59 @@
1
+ // src/core/bridge/openrouter.mjs
2
+ // OpenRouter's dialect of chat completions (docs/models.md › OpenRouter). The
3
+ // wire protocol is OpenAI's, so an OpenRouter model is an ordinary `openai`
4
+ // provider entry; what differs is decided by the base URL alone:
5
+ // - `usage: {include: true}` — the reply's usage then carries the call's USD
6
+ // `cost`, which the bridge books under the run's tag (the CLI prices a
7
+ // bridged id at $0);
8
+ // - `reasoning: {effort}` — OpenRouter's one reasoning knob for every model,
9
+ // where OpenAI's `reasoning_effort` is honoured only by some;
10
+ // - `max_tokens` — OpenRouter's name for the output cap on every model;
11
+ // - the entry's `upstream.openrouter` routing: `provider` preferences and the
12
+ // `models` fallback list, tried when the first model is rate-limited or down;
13
+ // - attribution headers, so worca's traffic is named on the OpenRouter dashboard.
14
+ // Pure; zero imports beyond the model-env leaf.
15
+
16
+ import { isOpenRouterBaseUrl } from '../model-env.mjs';
17
+
18
+ /** Whether a base URL is OpenRouter's (openrouter.ai or a subdomain). */
19
+ export const isOpenRouter = isOpenRouterBaseUrl;
20
+
21
+ /**
22
+ * OpenRouter app attribution (openrouter.ai/docs/app-attribution): every install
23
+ * reports as the one Worca app. HTTP-Referer is what creates the app page; the
24
+ * title rides both spellings (X-Title is the older one); categories come from
25
+ * OpenRouter's fixed list, at most two per request (unknown ones are dropped).
26
+ * Only ever Worca's own identity — never another listed app's referer. No
27
+ * X-OpenRouter-App-Visibility: Worca is listed publicly (OpenRouter's default),
28
+ * and that header only counts on the request that creates the app anyway.
29
+ */
30
+ export const OPENROUTER_HEADERS = Object.freeze({
31
+ 'HTTP-Referer': 'https://worca.dev',
32
+ 'X-Title': 'Worca',
33
+ 'X-OpenRouter-Title': 'Worca',
34
+ 'X-OpenRouter-Categories': 'cloud-agent,cli-agent',
35
+ });
36
+
37
+ /**
38
+ * A translated chat/completions body, adapted for OpenRouter. Never mutates
39
+ * `body`.
40
+ * @param {object} body toChatRequest's output
41
+ * @param {{openrouter?: {models?:string[], provider?:object}}} upstream the entry's upstream settings
42
+ * @returns {object}
43
+ */
44
+ export function adaptOpenRouterChatBody(body, upstream = {}) {
45
+ const out = { ...body };
46
+ if (out.reasoning_effort) {
47
+ out.reasoning = { effort: out.reasoning_effort };
48
+ delete out.reasoning_effort;
49
+ }
50
+ if (out.max_completion_tokens !== undefined) {
51
+ out.max_tokens = out.max_completion_tokens;
52
+ delete out.max_completion_tokens;
53
+ }
54
+ out.usage = { include: true };
55
+ const or = upstream && upstream.openrouter;
56
+ if (or && Array.isArray(or.models) && or.models.length) out.models = [...or.models];
57
+ if (or && or.provider && typeof or.provider === 'object') out.provider = { ...or.provider };
58
+ return out;
59
+ }