@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.
- package/README.md +7 -1
- package/docker/compose.broker.yml +79 -0
- package/docker/compose.isolation.yml +40 -0
- package/package.json +2 -1
- package/src/broker/config.mjs +200 -0
- package/src/broker/copilot.mjs +124 -0
- package/src/broker/limits.mjs +88 -0
- package/src/broker/main.mjs +129 -0
- package/src/broker/scrub.mjs +45 -0
- package/src/broker/service.mjs +558 -0
- package/src/broker/slots.mjs +180 -0
- package/src/broker/store.mjs +171 -0
- package/src/broker/tokens.mjs +116 -0
- package/src/broker/ui/page.css +54 -0
- package/src/broker/ui/page.html +25 -0
- package/src/broker/ui/page.mjs +202 -0
- package/src/broker/ui-server.mjs +217 -0
- package/src/broker/usage.mjs +108 -0
- package/src/broker/vault.mjs +37 -0
- package/src/cli/models.mjs +46 -14
- package/src/cli/render.mjs +21 -0
- package/src/cli/runs.mjs +296 -0
- package/src/cli/worca-cc.mjs +14 -2
- package/src/core/agent-pool.mjs +76 -0
- package/src/core/artifacts.mjs +40 -8
- package/src/core/ask/events.mjs +11 -0
- package/src/core/ask/html-text.mjs +113 -0
- package/src/core/ask/limits.mjs +5 -0
- package/src/core/ask/mcp-stdio.mjs +74 -20
- package/src/core/ask/prompt.mjs +25 -6
- package/src/core/ask/spawn.mjs +55 -5
- package/src/core/ask/store.mjs +1 -1
- package/src/core/ask/tools.mjs +66 -1
- package/src/core/ask/turn.mjs +52 -3
- package/src/core/ask/web-access.mjs +26 -0
- package/src/core/ask/web-deps.mjs +56 -0
- package/src/core/ask/web-fetch.mjs +271 -0
- package/src/core/ask/web-proposal.mjs +76 -0
- package/src/core/auto/classify.mjs +20 -8
- package/src/core/auto/runnable.mjs +28 -0
- package/src/core/billing.mjs +53 -0
- package/src/core/bridge/errors.mjs +77 -5
- package/src/core/bridge/openrouter.mjs +59 -0
- package/src/core/bridge/provider-ops.mjs +165 -9
- package/src/core/bridge/providers/endpoint.mjs +112 -7
- package/src/core/bridge/registry.mjs +13 -0
- package/src/core/bridge/server.mjs +16 -3
- package/src/core/bridge/telemetry.mjs +44 -12
- package/src/core/bridge/translate/common.mjs +31 -0
- package/src/core/bridge/translate/request.mjs +4 -1
- package/src/core/bridge/translate/response.mjs +3 -0
- package/src/core/bridge/translate/schema-keywords.mjs +75 -0
- package/src/core/bridge/translate/stream.mjs +50 -4
- package/src/core/bridge/upstream.mjs +102 -17
- package/src/core/broker-boot.mjs +57 -0
- package/src/core/broker-client.mjs +206 -0
- package/src/core/broker-guard.mjs +112 -0
- package/src/core/broker-routing.mjs +138 -0
- package/src/core/claude-auth.mjs +29 -0
- package/src/core/claude-runner.mjs +200 -9
- package/src/core/config.mjs +9 -12
- package/src/core/failure-policy.mjs +4 -2
- package/src/core/git-info.mjs +49 -5
- package/src/core/github-credentials.mjs +44 -1
- package/src/core/graph/script-runner.mjs +3 -1
- package/src/core/list-prices.mjs +29 -0
- package/src/core/mcp-secrets.mjs +80 -0
- package/src/core/metrics/sync.mjs +2 -1
- package/src/core/model-env.mjs +72 -0
- package/src/core/model-test.mjs +17 -5
- package/src/core/onboarding.mjs +12 -6
- package/src/core/openrouter-free.mjs +159 -0
- package/src/core/orchestrator.mjs +100 -12
- package/src/core/policy/effective.mjs +18 -1
- package/src/core/policy/local.mjs +4 -1
- package/src/core/policy/registry.mjs +12 -2
- package/src/core/preflight.mjs +116 -0
- package/src/core/recoverable-error.mjs +95 -0
- package/src/core/recovery-backoff.mjs +84 -0
- package/src/core/redact.mjs +25 -0
- package/src/core/run-context.mjs +6 -0
- package/src/core/run-harness.mjs +112 -30
- package/src/core/run-report.mjs +2 -1
- package/src/core/settings.mjs +90 -5
- package/src/core/title.mjs +7 -2
- package/src/core/web-allowlist.mjs +95 -0
- package/ui/public/app.js +244 -43
- package/ui/public/ask-model.mjs +1 -0
- package/ui/public/ask-panel.mjs +160 -23
- package/ui/public/bridge-view.mjs +175 -6
- package/ui/public/chat-settings-view.mjs +52 -1
- package/ui/public/credential-badges.mjs +63 -0
- package/ui/public/credentials-view.mjs +57 -0
- package/ui/public/index.html +33 -6
- package/ui/public/models-view.mjs +19 -1
- package/ui/public/openrouter-free-view.mjs +118 -0
- package/ui/public/stats-view.mjs +56 -0
- package/ui/public/style.css +99 -50
- package/ui/public/team-policy-view.mjs +2 -1
- package/ui/public/ws-seq.mjs +24 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
|
28
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|