@worca/app 1.5.0 → 1.6.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
@@ -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')
|
|
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
|
-
|
|
196
|
-
|
|
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: {
|
|
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
|
|
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
|
|
201
|
-
//
|
|
202
|
-
//
|
|
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
|
-
|
|
205
|
-
|
|
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
|
+
}
|