@worca/app 1.5.0 → 1.6.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +8 -2
  2. package/docker/compose.broker.yml +79 -0
  3. package/docker/compose.isolation.yml +40 -0
  4. package/package.json +2 -1
  5. package/src/broker/config.mjs +200 -0
  6. package/src/broker/copilot.mjs +124 -0
  7. package/src/broker/limits.mjs +88 -0
  8. package/src/broker/main.mjs +129 -0
  9. package/src/broker/scrub.mjs +45 -0
  10. package/src/broker/service.mjs +558 -0
  11. package/src/broker/slots.mjs +180 -0
  12. package/src/broker/store.mjs +171 -0
  13. package/src/broker/tokens.mjs +116 -0
  14. package/src/broker/ui/page.css +54 -0
  15. package/src/broker/ui/page.html +25 -0
  16. package/src/broker/ui/page.mjs +202 -0
  17. package/src/broker/ui-server.mjs +217 -0
  18. package/src/broker/usage.mjs +108 -0
  19. package/src/broker/vault.mjs +37 -0
  20. package/src/cli/models.mjs +46 -14
  21. package/src/cli/render.mjs +21 -0
  22. package/src/cli/runs.mjs +296 -0
  23. package/src/cli/worca-cc.mjs +14 -2
  24. package/src/core/agent-pool.mjs +76 -0
  25. package/src/core/artifacts.mjs +40 -8
  26. package/src/core/ask/events.mjs +11 -0
  27. package/src/core/ask/html-text.mjs +113 -0
  28. package/src/core/ask/limits.mjs +5 -0
  29. package/src/core/ask/mcp-stdio.mjs +74 -20
  30. package/src/core/ask/prompt.mjs +25 -6
  31. package/src/core/ask/spawn.mjs +55 -5
  32. package/src/core/ask/store.mjs +1 -1
  33. package/src/core/ask/tools.mjs +66 -1
  34. package/src/core/ask/turn.mjs +56 -3
  35. package/src/core/ask/web-access.mjs +26 -0
  36. package/src/core/ask/web-deps.mjs +56 -0
  37. package/src/core/ask/web-fetch.mjs +271 -0
  38. package/src/core/ask/web-proposal.mjs +76 -0
  39. package/src/core/auto/classify.mjs +20 -8
  40. package/src/core/auto/runnable.mjs +28 -0
  41. package/src/core/billing.mjs +53 -0
  42. package/src/core/bridge/errors.mjs +77 -5
  43. package/src/core/bridge/openrouter.mjs +59 -0
  44. package/src/core/bridge/provider-ops.mjs +202 -10
  45. package/src/core/bridge/providers/endpoint.mjs +112 -7
  46. package/src/core/bridge/registry.mjs +13 -0
  47. package/src/core/bridge/server.mjs +16 -3
  48. package/src/core/bridge/telemetry.mjs +44 -12
  49. package/src/core/bridge/translate/common.mjs +31 -0
  50. package/src/core/bridge/translate/request.mjs +4 -1
  51. package/src/core/bridge/translate/response.mjs +3 -0
  52. package/src/core/bridge/translate/schema-keywords.mjs +75 -0
  53. package/src/core/bridge/translate/stream.mjs +50 -4
  54. package/src/core/bridge/upstream.mjs +102 -17
  55. package/src/core/broker-boot.mjs +57 -0
  56. package/src/core/broker-client.mjs +206 -0
  57. package/src/core/broker-guard.mjs +112 -0
  58. package/src/core/broker-routing.mjs +138 -0
  59. package/src/core/claude-auth.mjs +29 -0
  60. package/src/core/claude-runner.mjs +200 -9
  61. package/src/core/config.mjs +9 -12
  62. package/src/core/failure-policy.mjs +4 -2
  63. package/src/core/git-info.mjs +49 -5
  64. package/src/core/github-credentials.mjs +44 -1
  65. package/src/core/graph/script-runner.mjs +3 -1
  66. package/src/core/list-prices.mjs +29 -0
  67. package/src/core/mcp-secrets.mjs +80 -0
  68. package/src/core/metrics/sync.mjs +2 -1
  69. package/src/core/model-env.mjs +72 -0
  70. package/src/core/model-test.mjs +17 -5
  71. package/src/core/onboarding.mjs +12 -6
  72. package/src/core/openrouter-free.mjs +159 -0
  73. package/src/core/orchestrator.mjs +100 -12
  74. package/src/core/policy/effective.mjs +18 -1
  75. package/src/core/policy/local.mjs +4 -1
  76. package/src/core/policy/registry.mjs +12 -2
  77. package/src/core/preflight.mjs +116 -0
  78. package/src/core/recoverable-error.mjs +95 -0
  79. package/src/core/recovery-backoff.mjs +84 -0
  80. package/src/core/redact.mjs +25 -0
  81. package/src/core/run-context.mjs +6 -0
  82. package/src/core/run-harness.mjs +112 -30
  83. package/src/core/run-report.mjs +2 -1
  84. package/src/core/settings.mjs +90 -5
  85. package/src/core/title.mjs +7 -2
  86. package/src/core/web-allowlist.mjs +95 -0
  87. package/ui/public/app.js +271 -54
  88. package/ui/public/ask-model.mjs +1 -0
  89. package/ui/public/ask-panel.mjs +160 -23
  90. package/ui/public/bridge-view.mjs +216 -9
  91. package/ui/public/chat-settings-view.mjs +52 -1
  92. package/ui/public/credential-badges.mjs +63 -0
  93. package/ui/public/credentials-view.mjs +57 -0
  94. package/ui/public/index.html +303 -244
  95. package/ui/public/models-view.mjs +19 -1
  96. package/ui/public/openrouter-free-view.mjs +118 -0
  97. package/ui/public/stats-view.mjs +56 -0
  98. package/ui/public/style.css +110 -54
  99. package/ui/public/team-policy-view.mjs +2 -1
  100. package/ui/public/ws-seq.mjs +24 -0
  101. package/ui/server.mjs +319 -23
@@ -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(); }
@@ -120,6 +120,37 @@ export function mapEffort(requested, capabilities = {}) {
120
120
  return atOrBelow.length ? atOrBelow[atOrBelow.length - 1] : levels[0];
121
121
  }
122
122
 
123
+ /**
124
+ * The signature on a thinking block the CHAT translator emits. Chat
125
+ * completions returns reasoning as plain text with nothing to replay, but a
126
+ * thinking block needs a signature the CLI can carry back; this one tells the
127
+ * request side the block is ours and is dropped quietly on the next turn.
128
+ */
129
+ export const CHAT_REASONING_SIGNATURE = 'worca.rsn.chat.v1';
130
+
131
+ /**
132
+ * The reasoning text in a chat/completions delta or message: OpenRouter's
133
+ * `reasoning` (with `reasoning_details` beside it — the same text, so it is
134
+ * read only when `reasoning` is absent), vLLM / DeepSeek's
135
+ * `reasoning_content`. Encrypted details carry no text and are skipped.
136
+ * @returns {string}
137
+ */
138
+ export function chatReasoningText(m) {
139
+ if (!m || typeof m !== 'object') return '';
140
+ if (typeof m.reasoning === 'string' && m.reasoning) return m.reasoning;
141
+ if (typeof m.reasoning_content === 'string' && m.reasoning_content) return m.reasoning_content;
142
+ if (Array.isArray(m.reasoning_details)) {
143
+ let s = '';
144
+ for (const d of m.reasoning_details) {
145
+ if (!d || typeof d !== 'object') continue;
146
+ if (d.type === 'reasoning.text' && typeof d.text === 'string') s += d.text;
147
+ else if (d.type === 'reasoning.summary' && typeof d.summary === 'string') s += d.summary;
148
+ }
149
+ return s;
150
+ }
151
+ return '';
152
+ }
153
+
123
154
  const MARKER_PREFIX = 'worca.rsn.v1.';
124
155
 
125
156
  /**
@@ -12,7 +12,7 @@
12
12
 
13
13
  import {
14
14
  SERVER_TOOL_RE, budgetToReasoningEffort, textOf, imageUrl, flattenToolResult,
15
- referencedToolNames, requestedEffort, mapEffort,
15
+ referencedToolNames, requestedEffort, mapEffort, CHAT_REASONING_SIGNATURE,
16
16
  } from './common.mjs';
17
17
 
18
18
  export { SERVER_TOOL_RE, budgetToReasoningEffort };
@@ -99,6 +99,9 @@ export function toChatRequest(body, { upstreamModel, capabilities = {} } = {}) {
99
99
  type: 'function',
100
100
  function: { name: String(b.name || ''), arguments: JSON.stringify(b.input ?? {}) },
101
101
  });
102
+ } else if (b.type === 'thinking' && b.signature === CHAT_REASONING_SIGNATURE) {
103
+ // Our own reasoning echoed back: chat completions takes no reasoning
104
+ // input, and this is the expected case, not a degradation to report.
102
105
  } else if (b.type === 'thinking' || b.type === 'redacted_thinking') warn('thinking');
103
106
  else warn(b.type);
104
107
  }
@@ -3,6 +3,7 @@
3
3
  // local count_tokens estimate (model-bridge-design.md §5.4 buffered path, §5.9).
4
4
 
5
5
  import { mapStopReason, mapUsage, newMessageId, newToolUseId, parsesAsJson, truncatedToolNote } from './stream.mjs';
6
+ import { CHAT_REASONING_SIGNATURE, chatReasoningText } from './common.mjs';
6
7
 
7
8
  /**
8
9
  * @param {object} completion the upstream JSON body
@@ -12,6 +13,8 @@ export function toMessagesResponse(completion, { model } = {}) {
12
13
  const choice = completion && Array.isArray(completion.choices) ? completion.choices[0] : null;
13
14
  const msg = (choice && choice.message) || {};
14
15
  const content = [];
16
+ const reasoning = chatReasoningText(msg);
17
+ if (reasoning) content.push({ type: 'thinking', thinking: reasoning, signature: CHAT_REASONING_SIGNATURE });
15
18
  if (typeof msg.content === 'string' && msg.content) content.push({ type: 'text', text: msg.content });
16
19
  const calls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
17
20
  const finish = choice ? choice.finish_reason : null;
@@ -0,0 +1,75 @@
1
+ // src/core/bridge/translate/schema-keywords.mjs
2
+ // Some OpenAI-compatible upstreams compile tool parameter schemas into a decoding
3
+ // grammar that knows only part of JSON Schema, and refuse the whole request over
4
+ // one validation keyword (OpenRouter's ModelRun on qwen3.8-27b:free: `tool
5
+ // "ListAgents" parameter schema: parameter "channel": unsupported schema keyword
6
+ // "maxLength"`). The CLI's own tools carry such keywords and worca cannot edit
7
+ // them, so the bridge learns the keyword from the refusal, drops it from every
8
+ // tool schema, and retries. Validation keywords only narrow what the model may
9
+ // send; the CLI still validates every tool call against the full schema.
10
+
11
+ /** The keyword an upstream names when it refuses a tool schema; null otherwise. */
12
+ const UNSUPPORTED_KEYWORD_RE = /unsupported (?:json )?schema keyword[:\s]+["'`]?([A-Za-z$][\w$-]*)/i;
13
+ export function unsupportedSchemaKeyword(message) {
14
+ const m = UNSUPPORTED_KEYWORD_RE.exec(String(message ?? ''));
15
+ return m ? m[1] : null;
16
+ }
17
+
18
+ // Where sub-schemas live. A `properties` / `$defs` map holds schemas under
19
+ // arbitrary names, so a property NAMED like a keyword is never dropped.
20
+ const SCHEMA_MAPS = ['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas'];
21
+ const SCHEMA_ONE = ['items', 'additionalProperties', 'additionalItems', 'contains', 'not', 'if', 'then', 'else', 'propertyNames', 'unevaluatedProperties', 'unevaluatedItems'];
22
+ const SCHEMA_LIST = ['anyOf', 'oneOf', 'allOf', 'prefixItems'];
23
+
24
+ /** A copy of `schema` without the `drop` keywords, at every depth. */
25
+ export function dropSchemaKeywords(schema, drop) {
26
+ if (Array.isArray(schema)) return schema.map((s) => dropSchemaKeywords(s, drop));
27
+ if (!schema || typeof schema !== 'object') return schema;
28
+ const out = {};
29
+ for (const [k, v] of Object.entries(schema)) {
30
+ if (drop.has(k)) continue;
31
+ if (SCHEMA_MAPS.includes(k) && v && typeof v === 'object' && !Array.isArray(v)) {
32
+ out[k] = Object.fromEntries(Object.entries(v).map(([name, s]) => [name, dropSchemaKeywords(s, drop)]));
33
+ } else if (SCHEMA_ONE.includes(k) || SCHEMA_LIST.includes(k)) {
34
+ out[k] = dropSchemaKeywords(v, drop);
35
+ } else {
36
+ out[k] = v;
37
+ }
38
+ }
39
+ return out;
40
+ }
41
+
42
+ // A refusal about one tool's schema that no keyword drop can fix (the CLI's
43
+ // Workflow tool takes `args` as any JSON value: "more than one JSON reading of
44
+ // the same emitted value"). The bridge leaves that tool out for the model.
45
+ const REFUSED_TOOL_RE = /\btool ["'`]([^"'`]+)["'`] parameter schema\b/i;
46
+ /** The tool a grammar refusal names; null otherwise. */
47
+ export function refusedToolName(message) {
48
+ const m = REFUSED_TOOL_RE.exec(String(message ?? ''));
49
+ return m ? m[1] : null;
50
+ }
51
+
52
+ /** A translated request body without the `names` tools (chat and Responses shapes). Never mutates `body`. */
53
+ export function withoutTools(body, names) {
54
+ if (!body || !Array.isArray(body.tools) || !names || !names.size) return body;
55
+ const toolName = (t) => (t && t.function ? t.function.name : t && t.name);
56
+ return { ...body, tools: body.tools.filter((t) => !names.has(toolName(t))) };
57
+ }
58
+
59
+ /**
60
+ * A translated request body with `drop` removed from every tool's parameter
61
+ * schema: chat/completions (`tools[].function.parameters`) and the Responses API
62
+ * (`tools[].parameters`). Never mutates `body`; a body without tools, or an
63
+ * empty `drop`, comes back as is.
64
+ */
65
+ export function withToolSchemaKeywordsDropped(body, drop) {
66
+ if (!body || !Array.isArray(body.tools) || !drop || !drop.size) return body;
67
+ const tools = body.tools.map((t) => {
68
+ if (t && t.function && t.function.parameters) {
69
+ return { ...t, function: { ...t.function, parameters: dropSchemaKeywords(t.function.parameters, drop) } };
70
+ }
71
+ if (t && t.parameters) return { ...t, parameters: dropSchemaKeywords(t.parameters, drop) };
72
+ return t;
73
+ });
74
+ return { ...body, tools };
75
+ }
@@ -12,6 +12,7 @@
12
12
  // cost of a tool block appearing a moment later than its first delta.
13
13
 
14
14
  import { randomBytes } from 'node:crypto';
15
+ import { CHAT_REASONING_SIGNATURE, chatReasoningText } from './common.mjs';
15
16
 
16
17
  /** finish_reason -> stop_reason (§5.5). */
17
18
  export function mapStopReason(finish, { emitted = false } = {}) {
@@ -25,6 +26,9 @@ export function mapStopReason(finish, { emitted = false } = {}) {
25
26
  }
26
27
  }
27
28
 
29
+ /** The overloaded_error a turn with no visible output becomes (ChatStreamTranslator#finish). */
30
+ export const EMPTY_TURN_MESSAGE = 'upstream returned no output (no text or tool call) — an upstream glitch; retrying';
31
+
28
32
  /** chat/completions usage -> Anthropic usage (§5.8). */
29
33
  export function mapUsage(usage) {
30
34
  const u = usage && typeof usage === 'object' ? usage : {};
@@ -60,12 +64,15 @@ export class ChatStreamTranslator {
60
64
  this.started = false;
61
65
  this.nextIndex = 0;
62
66
  this.textIndex = null; // open text block index, or null
67
+ this.thinkIndex = null; // open thinking block index, or null
63
68
  this.tools = new Map(); // tool_calls index -> {id, name, args, order}
64
69
  this.toolOrder = [];
65
70
  this.flushedTools = 0; // how many of toolOrder have been emitted
66
71
  this.finishReason = null;
67
72
  this.usage = null;
73
+ this.costUsd = null; // the upstream's own USD cost (OpenRouter usage.cost), when reported
68
74
  this.emittedAny = false;
75
+ this.sawText = false; // any visible text (thinking is not visible output)
69
76
  this.contentFilter = false;
70
77
  }
71
78
 
@@ -92,6 +99,17 @@ export class ChatStreamTranslator {
92
99
  return [{ event: 'content_block_stop', data: { type: 'content_block_stop', index: i } }];
93
100
  }
94
101
 
102
+ /** Close the open thinking block, signing it as ours (common.mjs#CHAT_REASONING_SIGNATURE). */
103
+ _closeThinking() {
104
+ if (this.thinkIndex === null) return [];
105
+ const index = this.thinkIndex;
106
+ this.thinkIndex = null;
107
+ return [
108
+ { event: 'content_block_delta', data: { type: 'content_block_delta', index, delta: { type: 'signature_delta', signature: CHAT_REASONING_SIGNATURE } } },
109
+ { event: 'content_block_stop', data: { type: 'content_block_stop', index } },
110
+ ];
111
+ }
112
+
95
113
  /** Emit every buffered tool call not yet emitted (in first-seen order). */
96
114
  _flushTools(upTo = this.toolOrder.length) {
97
115
  const out = [];
@@ -119,12 +137,31 @@ export class ChatStreamTranslator {
119
137
  out.push({ event: 'error', data: { type: 'error', error: { type: 'api_error', message: `upstream stream error: ${msg}` } } });
120
138
  return out;
121
139
  }
122
- if (chunk.usage && typeof chunk.usage === 'object') this.usage = chunk.usage;
140
+ if (chunk.usage && typeof chunk.usage === 'object') {
141
+ this.usage = chunk.usage;
142
+ const cost = Number(chunk.usage.cost);
143
+ if (chunk.usage.cost != null && Number.isFinite(cost) && cost >= 0) this.costUsd = cost;
144
+ }
123
145
  const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : null;
124
146
  if (!choice) return out;
125
147
  const delta = choice.delta || {};
126
148
 
149
+ // Reasoning streams first on a reasoning model; one live thinking block per
150
+ // contiguous run, closed before any text or tool block starts.
151
+ const reasoning = chatReasoningText(delta);
152
+ if (reasoning) {
153
+ if (this.thinkIndex === null) {
154
+ out.push(...this._closeText());
155
+ if (this.toolOrder.length > this.flushedTools) out.push(...this._flushTools());
156
+ this.thinkIndex = this.nextIndex++;
157
+ out.push({ event: 'content_block_start', data: { type: 'content_block_start', index: this.thinkIndex, content_block: { type: 'thinking', thinking: '', signature: '' } } });
158
+ }
159
+ out.push({ event: 'content_block_delta', data: { type: 'content_block_delta', index: this.thinkIndex, delta: { type: 'thinking_delta', thinking: reasoning } } });
160
+ this.emittedAny = true;
161
+ }
162
+
127
163
  if (typeof delta.content === 'string' && delta.content.length) {
164
+ out.push(...this._closeThinking());
128
165
  // Text after tool calls: the tools are complete — flush them first.
129
166
  if (this.toolOrder.length > this.flushedTools) { out.push(...this._closeText(), ...this._flushTools()); }
130
167
  if (this.textIndex === null) {
@@ -133,6 +170,7 @@ export class ChatStreamTranslator {
133
170
  }
134
171
  out.push({ event: 'content_block_delta', data: { type: 'content_block_delta', index: this.textIndex, delta: { type: 'text_delta', text: delta.content } } });
135
172
  this.emittedAny = true;
173
+ this.sawText = true;
136
174
  }
137
175
 
138
176
  if (Array.isArray(delta.tool_calls)) {
@@ -142,7 +180,7 @@ export class ChatStreamTranslator {
142
180
  let t = this.tools.get(key);
143
181
  if (!t) {
144
182
  // A new tool call: text before it is complete; earlier tool calls are complete.
145
- out.push(...this._closeText());
183
+ out.push(...this._closeThinking(), ...this._closeText());
146
184
  out.push(...this._flushTools());
147
185
  t = { id: tc.id || newToolUseId(), name: '', args: '' };
148
186
  this.tools.set(key, t);
@@ -164,6 +202,7 @@ export class ChatStreamTranslator {
164
202
  /** End of stream: close blocks, emit message_delta + message_stop. */
165
203
  finish() {
166
204
  const out = this._start();
205
+ out.push(...this._closeThinking());
167
206
  // Cut off mid tool call (finish_reason `length` — the output cap, or on a
168
207
  // small local model the context window filling up): the last call's
169
208
  // arguments are unterminated JSON. Forwarded, the CLI rejects it as an
@@ -192,8 +231,15 @@ export class ChatStreamTranslator {
192
231
  out.push(...this._closeText());
193
232
  const hadTools = this.toolOrder.length > 0;
194
233
  let stop = mapStopReason(this.finishReason, { emitted: this.emittedAny });
195
- if (stop === null) {
196
- out.push({ event: 'error', data: { type: 'error', error: { type: 'api_error', message: 'upstream stream ended without content or finish_reason' } } });
234
+ // No text and no tool call — nothing at all, or reasoning only — on a turn
235
+ // that ended normally or not at all: an upstream glitch (OpenRouter's free
236
+ // Nvidia endpoint does it intermittently). Forwarded as an empty turn the CLI
237
+ // nudges "no visible output" and exits with no cause; as overloaded_error it
238
+ // retries with backoff, and a run that still fails names the reason. A length
239
+ // cut and a content filter keep their stop reasons: those are the model's.
240
+ const visible = hadTools || this.sawText;
241
+ if (stop === null || (!visible && (this.finishReason === 'stop' || this.finishReason == null))) {
242
+ out.push({ event: 'error', data: { type: 'error', error: { type: 'overloaded_error', message: EMPTY_TURN_MESSAGE } } });
197
243
  return out;
198
244
  }
199
245
  if (hadTools && stop === 'end_turn') stop = 'tool_use';
@@ -12,13 +12,29 @@ import { ResponsesStreamTranslator, toMessagesResponseFromResponses } from './tr
12
12
  import { mapUpstreamError, mapNetworkError, bridgeErrors, anthropicError, isFailedResponseOverflow, PAYLOAD_CEILING_BYTES } from './errors.mjs';
13
13
  import { copilotToken, invalidateCopilotToken, copilotApiHost, copilotHeaders, bodyHasImage, requestInitiator } from './providers/copilot.mjs';
14
14
  import { upstreamSettings, providerReadiness } from './registry.mjs';
15
+ import { brokerEnabled, slotBaseUrl } from '../broker-client.mjs';
16
+ import { routeBridgedUpstream } from '../broker-routing.mjs';
15
17
  import { KeyedSemaphore } from './semaphore.mjs';
16
- import { recordBridgeCall, recordBridgeError } from './telemetry.mjs';
18
+ import { recordBridgeCall, recordBridgeError, recordBridgeCost } from './telemetry.mjs';
19
+ import { isOpenRouter, adaptOpenRouterChatBody, OPENROUTER_HEADERS } from './openrouter.mjs';
20
+ import { isOpenRouterFree, keyAccount } from '../openrouter-free.mjs';
21
+ import { unsupportedSchemaKeyword, withToolSchemaKeywordsDropped, refusedToolName, withoutTools } from './translate/schema-keywords.mjs';
17
22
 
18
23
  export const semaphore = new KeyedSemaphore();
19
24
  const PING_INTERVAL_MS = 15_000;
20
25
  const warned = new Set();
21
26
 
27
+ // What an upstream model's tool grammar refused (translate/schema-keywords.mjs):
28
+ // schema keywords to drop, and whole tools no drop can fix. Learned per provider
29
+ // + base URL + upstream model for the life of the process, so only the first
30
+ // request after a boot pays each refusal round trip.
31
+ const schemaDrops = new Map(); // key -> { keywords:Set<string>, tools:Set<string> }
32
+ const MAX_SCHEMA_RETRIES = 6;
33
+ export function _resetSchemaKeywordDrops() { schemaDrops.clear(); }
34
+ function applySchemaDrops(body, d) {
35
+ return d ? withoutTools(withToolSchemaKeywordsDropped(body, d.keywords), d.tools) : body;
36
+ }
37
+
22
38
  /** Once-per-process warning (dropped fields, queue notices). */
23
39
  function warnOnce(key, line, log) {
24
40
  if (warned.has(key)) return;
@@ -32,6 +48,19 @@ export function _resetBridgeWarnings() { warned.clear(); }
32
48
  * @returns {Promise<{url:string, headers:object, provider:string, retryAuth?:() => Promise<object>}>}
33
49
  */
34
50
  async function prepareUpstream(us, body, { fetch: f, requestHeaders }) {
51
+ // Credential broker: the request goes to <broker>/p/<slot>… with the spawn's token; the
52
+ // broker adds the person's key (or runs the Copilot exchange). us.baseUrl keeps the real
53
+ // provider URL: dialect decisions (OpenRouter's body and headers) still key on it.
54
+ if (us.brokerToken && us.provider === 'copilot') {
55
+ const initiator = requestInitiator(body);
56
+ const path = us.api === 'anthropic' ? '/v1/messages' : us.api === 'openai-responses' ? '/responses' : '/chat/completions';
57
+ const headers = { ...copilotHeaders(us.brokerToken, { vision: bodyHasImage(body), initiator }), ...us.headers };
58
+ if (us.api === 'anthropic') {
59
+ headers['anthropic-version'] = requestHeaders['anthropic-version'] || '2023-06-01';
60
+ if (requestHeaders['anthropic-beta']) headers['anthropic-beta'] = requestHeaders['anthropic-beta'];
61
+ }
62
+ return { url: `${us.brokerBase}${path}`, headers, provider: 'copilot', initiator };
63
+ }
35
64
  if (us.provider === 'copilot') {
36
65
  const initiator = requestInitiator(body);
37
66
  const vision = bodyHasImage(body);
@@ -51,11 +80,11 @@ async function prepareUpstream(us, body, { fetch: f, requestHeaders }) {
51
80
  }
52
81
  const initiator = requestInitiator(body);
53
82
  if (us.api === 'anthropic') {
54
- const base = (us.baseUrl || 'https://api.anthropic.com').replace(/\/+$/, '');
83
+ const base = (us.brokerBase || us.baseUrl || 'https://api.anthropic.com').replace(/\/+$/, '');
55
84
  const url = /\/v1$/.test(base) ? `${base}/messages` : `${base}/v1/messages`;
56
85
  const headers = {
57
86
  'content-type': 'application/json',
58
- 'x-api-key': us.apiKey,
87
+ 'x-api-key': us.brokerToken || us.apiKey,
59
88
  'anthropic-version': requestHeaders['anthropic-version'] || '2023-06-01',
60
89
  ...(requestHeaders['anthropic-beta'] ? { 'anthropic-beta': requestHeaders['anthropic-beta'] } : {}),
61
90
  ...us.headers,
@@ -63,9 +92,15 @@ async function prepareUpstream(us, body, { fetch: f, requestHeaders }) {
63
92
  return { url, headers, provider: us.provider, initiator };
64
93
  }
65
94
  const base = (us.baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '');
95
+ const key = us.brokerToken || us.apiKey;
66
96
  return {
67
- url: `${base}/${us.api === 'openai-responses' ? 'responses' : 'chat/completions'}`,
68
- headers: { 'content-type': 'application/json', ...(us.apiKey ? { authorization: `Bearer ${us.apiKey}` } : {}), ...us.headers },
97
+ url: `${(us.brokerBase || base).replace(/\/+$/, '')}/${us.api === 'openai-responses' ? 'responses' : 'chat/completions'}`,
98
+ headers: {
99
+ 'content-type': 'application/json',
100
+ ...(key ? { authorization: `Bearer ${key}` } : {}),
101
+ ...(isOpenRouter(base) ? OPENROUTER_HEADERS : {}), // an entry's own headers still win
102
+ ...us.headers,
103
+ },
69
104
  provider: us.provider,
70
105
  initiator,
71
106
  };
@@ -83,7 +118,7 @@ async function prepareUpstream(us, body, { fetch: f, requestHeaders }) {
83
118
  * @param {(line:string)=>void} [args.log]
84
119
  * @param {object} reply { status(code, headers), write(chunk), end(), json(status, obj, headers?) }
85
120
  */
86
- export async function handleMessages({ entry, body, requestHeaders = {}, tag = '', signal, fetch: f = globalThis.fetch, log }, reply) {
121
+ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '', signal, fetch: f = globalThis.fetch, log, brokerToken = null }, reply) {
87
122
  const upstream = entry.upstream;
88
123
  const ready = providerReadiness(upstream);
89
124
  if (!ready.ok) {
@@ -92,6 +127,31 @@ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '
92
127
  return reply.json(e.status, e.body);
93
128
  }
94
129
  const us = upstreamSettings(upstream);
130
+ // Credential broker: route through the model's slot with the spawn's token.
131
+ if (brokerEnabled()) {
132
+ const route = routeBridgedUpstream(upstream);
133
+ if (route.error) {
134
+ const e = anthropicError(403, 'permission_error', `worca-broker: ${route.error}`);
135
+ recordBridgeError({ tag, catalogId: entry.id, provider: us.provider, status: e.status, message: e.body.error.message });
136
+ return reply.json(e.status, e.body);
137
+ }
138
+ if (!route.keyless) {
139
+ if (!brokerToken) {
140
+ const e = anthropicError(403, 'authentication_error', 'worca-broker: this spawn has no broker token');
141
+ recordBridgeError({ tag, catalogId: entry.id, provider: us.provider, status: e.status, message: e.body.error.message });
142
+ return reply.json(e.status, e.body);
143
+ }
144
+ us.brokerToken = brokerToken;
145
+ us.brokerBase = `${slotBaseUrl(route.slot)}${route.prefix || ''}`;
146
+ us.apiKey = '';
147
+ us.githubToken = null;
148
+ }
149
+ }
150
+
151
+ // An OpenRouter `:free` model: every call spends one of the day's free requests of the
152
+ // key it goes out with (openrouter-free.mjs). Through the broker that key is unknown here.
153
+ const free = isOpenRouterFree(us);
154
+ const account = free && !us.brokerToken ? keyAccount(us.apiKey) : null;
95
155
 
96
156
  // Body → upstream body.
97
157
  let outBody;
@@ -111,9 +171,11 @@ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '
111
171
  : `${w} has no ${responses ? 'Responses API' : 'chat/completions'} equivalent — dropped`;
112
172
  warnOnce(`${entry.id}:${w}`, `[worca] bridge: model ${JSON.stringify(entry.id)}: ${line}`, log);
113
173
  }
114
- outBody = t.body;
174
+ outBody = !responses && isOpenRouter(us.baseUrl) ? adaptOpenRouterChatBody(t.body, us) : t.body;
115
175
  }
116
- const payload = JSON.stringify(outBody);
176
+ const dropKey = `${us.provider}|${us.baseUrl || ''}|${us.model}`;
177
+ if (us.api !== 'anthropic') outBody = applySchemaDrops(outBody, schemaDrops.get(dropKey));
178
+ let payload = JSON.stringify(outBody);
117
179
  if (Buffer.byteLength(payload) > PAYLOAD_CEILING_BYTES) {
118
180
  const e = bridgeErrors.tooLarge();
119
181
  return reply.json(e.status, e.body);
@@ -142,7 +204,7 @@ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '
142
204
  recordBridgeError({ tag, catalogId: entry.id, provider: us.provider, status: e.status, message: e.body.error.message });
143
205
  return reply.json(e.status, e.body);
144
206
  }
145
- recordBridgeCall({ tag, catalogId: entry.id, provider: us.provider, api: us.api, initiator: prep.initiator });
207
+ recordBridgeCall({ tag, catalogId: entry.id, provider: us.provider, api: us.api, initiator: prep.initiator, free, account });
146
208
 
147
209
  const doFetch = (p) => f(p.url, { method: 'POST', headers: p.headers, body: payload, signal });
148
210
  let res;
@@ -152,6 +214,28 @@ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '
152
214
  const again = await prep.retryAuth();
153
215
  res = await doFetch(again);
154
216
  }
217
+ // A tool schema the upstream's grammar cannot take: drop the keyword it
218
+ // names from every tool schema — or, when no keyword is named, leave that
219
+ // one tool out — remember it for this model, and retry. Once per new
220
+ // keyword / tool, so a refusal that survives the fix is answered, not looped.
221
+ for (let i = 0; i < MAX_SCHEMA_RETRIES && res.status === 400 && us.api !== 'anthropic' && Array.isArray(outBody.tools) && outBody.tools.length; i++) {
222
+ const text = await res.clone().text().catch(() => '');
223
+ const msg = mapUpstreamError(400, text, { provider: us.provider }).body.error.message;
224
+ const kw = unsupportedSchemaKeyword(msg);
225
+ const tool = kw ? null : refusedToolName(msg);
226
+ const d = schemaDrops.get(dropKey) || { keywords: new Set(), tools: new Set() };
227
+ if (kw && !d.keywords.has(kw)) {
228
+ d.keywords.add(kw);
229
+ warnOnce(`schema-kw:${dropKey}:${kw}`, `[worca] bridge: ${us.provider} model ${JSON.stringify(us.model)} refuses the tool-schema keyword "${kw}" — dropping it from tool schemas`, log);
230
+ } else if (tool && !d.tools.has(tool)) {
231
+ d.tools.add(tool);
232
+ warnOnce(`schema-tool:${dropKey}:${tool}`, `[worca] bridge: ${us.provider} model ${JSON.stringify(us.model)} cannot take the schema of tool "${tool}" — leaving it out of this model's requests`, log);
233
+ } else break;
234
+ schemaDrops.set(dropKey, d);
235
+ outBody = applySchemaDrops(outBody, d);
236
+ payload = JSON.stringify(outBody);
237
+ res = await doFetch(prep);
238
+ }
155
239
  } catch (err) {
156
240
  if (signal && signal.aborted) return reply.end();
157
241
  const e = mapNetworkError(err, { provider: us.provider });
@@ -162,7 +246,7 @@ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '
162
246
  if (!res.ok) {
163
247
  const text = await res.text().catch(() => '');
164
248
  const e = mapUpstreamError(res.status, text, { provider: us.provider, retryAfter: res.headers.get('retry-after') });
165
- recordBridgeError({ tag, catalogId: entry.id, provider: us.provider, status: res.status, message: e.body.error.message });
249
+ recordBridgeError({ tag, catalogId: entry.id, provider: us.provider, status: res.status, message: e.body.error.message, account });
166
250
  if (log) log(`[worca] bridge: ${us.provider} answered ${res.status} for ${JSON.stringify(entry.id)}: ${e.body.error.message}`);
167
251
  return reply.json(e.status, e.body, e.headers);
168
252
  }
@@ -188,6 +272,7 @@ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '
188
272
  recordBridgeError({ tag, catalogId: entry.id, provider: us.provider, status: 200, message: e.body.error.message });
189
273
  return reply.json(e.status, e.body);
190
274
  }
275
+ if (us.api === 'openai-chat') recordBridgeCost({ tag, costUsd: j && j.usage ? j.usage.cost : undefined });
191
276
  return reply.json(200, us.api === 'openai-responses'
192
277
  ? toMessagesResponseFromResponses(j, { model: entry.id, upstreamModel: us.model })
193
278
  : toMessagesResponse(j, { model: entry.id }));
@@ -197,14 +282,13 @@ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '
197
282
  const translator = us.api === 'openai-responses'
198
283
  ? new ResponsesStreamTranslator({ model: entry.id, upstreamModel: us.model })
199
284
  : new ChatStreamTranslator({ model: entry.id });
200
- // A Responses stream can fail mid-flight (response.failed / error): book it
201
- // like an upstream refusal, so the Test button can name the reason. The
202
- // chat stream's events pass through untouched.
285
+ // A stream can fail mid-flight (a Responses response.failed / error, a chat
286
+ // stream cut short or ending with no output): book it like an upstream
287
+ // refusal, so the Test button — and a run whose CLI exits without an API
288
+ // Error line (claude-runner's bridge-failure fallback) — can name the reason.
203
289
  const booked = (events) => {
204
- if (us.api === 'openai-responses') {
205
- for (const e of events) {
206
- if (e.event === 'error') recordBridgeError({ tag, catalogId: entry.id, provider: us.provider, status: 200, message: e.data.error.message });
207
- }
290
+ for (const e of events) {
291
+ if (e.event === 'error') recordBridgeError({ tag, catalogId: entry.id, provider: us.provider, status: 200, message: e.data.error.message });
208
292
  }
209
293
  return events;
210
294
  };
@@ -226,6 +310,7 @@ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '
226
310
  for (const obj of parser.end()) reply.write(serializeSse(booked(translator.push(obj))));
227
311
  }
228
312
  reply.write(serializeSse(booked(translator.finish())));
313
+ if (translator.costUsd != null) recordBridgeCost({ tag, costUsd: translator.costUsd });
229
314
  } finally {
230
315
  clearInterval(ping);
231
316
  }
@@ -0,0 +1,57 @@
1
+ // src/core/broker-boot.mjs
2
+ // The server's boot check for the credential broker (plans/credential-broker-design.html
3
+ // §6.7): configuration, the credential guard (K1), then the broker itself. Returns the
4
+ // problems instead of exiting, so ui/server.mjs prints them and exits 78 in one place.
5
+ import { brokerConfig, connectBroker } from './broker-client.mjs';
6
+ import { findLocalCredentials, credentialFiles, guardMessage } from './broker-guard.mjs';
7
+ import { listGlobalModels, readSettings } from './settings.mjs';
8
+ import { listPluginModels } from './plugin-models.mjs';
9
+
10
+ /**
11
+ * @param {{env?:object, shared?:boolean, log?:Function, waitMs?:number}} o
12
+ * shared: several people sign in (Cloudflare Access or a trusted identity header)
13
+ * @returns {Promise<{on:boolean, fatal:string[], warnings:string[], info?:object}>}
14
+ */
15
+ export async function checkBrokerAtBoot({ env = process.env, shared = false, log = console.log, waitMs = 60_000 } = {}) {
16
+ const c = brokerConfig(env);
17
+ if (!c) {
18
+ const warnings = [];
19
+ const fatal = [];
20
+ if (shared) {
21
+ const msg = 'several people sign in to this worca, but the credential broker is off: everyone shares the model key in worca\'s environment, and agents can read it. See docs/credential-broker.md.';
22
+ if (/^(1|true|yes|on)$/i.test(String(env.WORCA_BROKER_REQUIRED || ''))) fatal.push(`${msg} (WORCA_BROKER_REQUIRED is set)`);
23
+ else warnings.push(msg);
24
+ }
25
+ return { on: false, fatal, warnings };
26
+ }
27
+ if (c.error) return { on: true, fatal: [c.error], warnings: [] };
28
+
29
+ // The broker first: its slots say which remote endpoints are reachable through it.
30
+ let info;
31
+ try {
32
+ info = await connectBroker({ waitMs, log });
33
+ } catch (err) {
34
+ return { on: true, fatal: [`${err.message}. Is the broker running at ${c.url}, and does WORCA_BROKER_SECRET match on both sides?`], warnings: [] };
35
+ }
36
+
37
+ let models = [];
38
+ let providers = {};
39
+ try { models = [...listGlobalModels(), ...listPluginModels()]; } catch { /* unreadable settings: nothing to check */ }
40
+ try { providers = readSettings().providers || {}; } catch { /* same */ }
41
+ const findings = findLocalCredentials({
42
+ env,
43
+ files: credentialFiles([env.HOME, env.WORCA_AGENT_HOME]),
44
+ models,
45
+ providers,
46
+ brokerUrl: c.url,
47
+ slotOrigins: (info.slots || []).filter((s) => s.auth !== 'copilot' && s.auth !== 'github-user').map((s) => s.upstream),
48
+ });
49
+ if (findings.length) return { on: true, fatal: [guardMessage(findings, info.publicUrl)], warnings: [] };
50
+ const warnings = [];
51
+ const asPerson = String(env.WORCA_GH_AS_PERSON || '').trim().toLowerCase();
52
+ if (asPerson && !['prefer', 'required', 'off', '0'].includes(asPerson)) warnings.push(`WORCA_GH_AS_PERSON must be prefer or required (got ${JSON.stringify(asPerson)}); pushes use worca's own GitHub credential`);
53
+ if ((asPerson === 'prefer' || asPerson === 'required') && !(info.slots || []).some((s) => s.auth === 'github-user')) {
54
+ warnings.push(`WORCA_GH_AS_PERSON=${asPerson}, but the broker has no GitHub slot (set WORCA_BROKER_GITHUB_CLIENT_ID on it)${asPerson === 'required' ? ': every push will fail' : ''}`);
55
+ }
56
+ return { on: true, fatal: [], warnings, info };
57
+ }