@worca/app 1.4.0 → 1.5.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.
- package/README.md +36 -1
- package/docker/.env.example +54 -0
- package/docker/compose.clonein.yml +24 -0
- package/docker/compose.dev.yml +24 -0
- package/docker/compose.egress.yml +47 -0
- package/docker/compose.ssh.yml +17 -0
- package/docker/compose.teams.yml +25 -0
- package/docker/compose.yml +57 -0
- package/package.json +4 -1
- package/src/cli/container.mjs +227 -0
- package/src/cli/models.mjs +5 -4
- package/src/cli/schedule.mjs +23 -10
- package/src/cli/worca-cc.mjs +62 -4
- package/src/core/agent-user.mjs +92 -0
- package/src/core/artifacts.mjs +41 -9
- package/src/core/ask/clone-deps.mjs +25 -0
- package/src/core/ask/clone-proposal.mjs +79 -0
- package/src/core/ask/events.mjs +10 -0
- package/src/core/ask/mcp-stdio.mjs +4 -2
- package/src/core/ask/model-proposal.mjs +14 -3
- package/src/core/ask/prompt.mjs +27 -6
- package/src/core/ask/schedule-deps.mjs +16 -11
- package/src/core/ask/spawn.mjs +8 -3
- package/src/core/ask/store.mjs +40 -10
- package/src/core/ask/tool-deps.mjs +36 -1
- package/src/core/ask/tools.mjs +69 -8
- package/src/core/ask/turn.mjs +31 -1
- package/src/core/bridge/errors.mjs +50 -2
- package/src/core/bridge/provider-ops.mjs +20 -7
- package/src/core/bridge/providers/copilot.mjs +51 -5
- package/src/core/bridge/translate/common.mjs +144 -0
- package/src/core/bridge/translate/request.mjs +16 -73
- package/src/core/bridge/translate/responses-request.mjs +185 -0
- package/src/core/bridge/translate/responses-stream.mjs +327 -0
- package/src/core/bridge/upstream.mjs +37 -10
- package/src/core/cf-access.mjs +90 -0
- package/src/core/chat/command-router.mjs +17 -12
- package/src/core/claude-runner.mjs +26 -3
- package/src/core/clone-project.mjs +139 -0
- package/src/core/config.mjs +16 -9
- package/src/core/db.mjs +60 -5
- package/src/core/deployment.mjs +33 -0
- package/src/core/diff-comments.mjs +17 -8
- package/src/core/git-info.mjs +27 -7
- package/src/core/github-app.mjs +107 -0
- package/src/core/github-credentials.mjs +93 -0
- package/src/core/graph/executor.mjs +22 -4
- package/src/core/graph/script-runner.mjs +24 -3
- package/src/core/guardrails.mjs +1 -0
- package/src/core/identity.mjs +83 -0
- package/src/core/metrics/pr-events-workflow.yml +127 -0
- package/src/core/metrics/prs.mjs +383 -0
- package/src/core/metrics/read.mjs +39 -29
- package/src/core/metrics/record.mjs +30 -2
- package/src/core/metrics/sync.mjs +35 -4
- package/src/core/model-env.mjs +47 -7
- package/src/core/model-test.mjs +29 -5
- package/src/core/notifications.mjs +36 -14
- package/src/core/orchestrator.mjs +10 -4
- package/src/core/phases.mjs +2 -0
- package/src/core/pipeline-delete.mjs +4 -3
- package/src/core/plugin-repo.mjs +7 -3
- package/src/core/policy/gate.mjs +9 -5
- package/src/core/policy/state.mjs +2 -2
- package/src/core/policy/sync.mjs +5 -4
- package/src/core/remote-access.mjs +138 -0
- package/src/core/run-harness.mjs +71 -9
- package/src/core/scheduler.mjs +63 -33
- package/src/shared/team-metrics/timeline.mjs +330 -0
- package/ui/public/app.js +591 -14
- package/ui/public/ask-panel.mjs +65 -3
- package/ui/public/bridge-view.mjs +72 -14
- package/ui/public/index.html +42 -9
- package/ui/public/schedules-view.mjs +21 -2
- package/ui/public/session-guard.mjs +77 -0
- package/ui/public/style.css +171 -1
- package/ui/public/team-metrics-timeline.mjs +480 -0
- package/ui/public/team-metrics-view.mjs +10 -2
- package/ui/public/team-policy-view.mjs +6 -1
- package/ui/server.mjs +454 -104
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// src/core/bridge/translate/responses-stream.mjs
|
|
2
|
+
// OpenAI Responses API -> Anthropic Messages (2026-09-23-bridge-openai-
|
|
3
|
+
// responses-design.md §9): the SSE stream state machine and the buffered
|
|
4
|
+
// (non-streaming) response. Pure; the same push()/finish() contract as
|
|
5
|
+
// ChatStreamTranslator, fed by the same SseDataParser (a Responses stream has
|
|
6
|
+
// no [DONE] sentinel — it ends when the connection closes).
|
|
7
|
+
//
|
|
8
|
+
// Events are correlated by `output_index`, never `item_id`: Copilot rotates
|
|
9
|
+
// item_id on every event. Anthropic content blocks are strictly sequential:
|
|
10
|
+
// reasoning summary text and output text stream live; a function call is
|
|
11
|
+
// emitted whole when its item is done (the done item carries the complete
|
|
12
|
+
// arguments). A reasoning item becomes a `thinking` block whose signature is
|
|
13
|
+
// the reasoning marker (common.mjs) — or a `redacted_thinking` block when the
|
|
14
|
+
// model produced no summary text — so the CLI echoes it back next turn and
|
|
15
|
+
// responses-request.mjs replays it as a `reasoning` input item.
|
|
16
|
+
|
|
17
|
+
import { newMessageId, newToolUseId, parsesAsJson, truncatedToolNote } from './stream.mjs';
|
|
18
|
+
import { encodeReasoningMarker } from './common.mjs';
|
|
19
|
+
import { isFailedResponseOverflow, PROMPT_TOO_LONG } from '../errors.mjs';
|
|
20
|
+
|
|
21
|
+
/** Responses usage -> Anthropic usage. */
|
|
22
|
+
export function mapResponsesUsage(usage) {
|
|
23
|
+
const u = usage && typeof usage === 'object' ? usage : {};
|
|
24
|
+
const input = Number(u.input_tokens) || 0;
|
|
25
|
+
const cached = Number(u.input_tokens_details && u.input_tokens_details.cached_tokens) || 0;
|
|
26
|
+
return {
|
|
27
|
+
input_tokens: Math.max(0, input - cached),
|
|
28
|
+
output_tokens: Number(u.output_tokens) || 0,
|
|
29
|
+
cache_read_input_tokens: cached,
|
|
30
|
+
cache_creation_input_tokens: 0,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const errorEvent = (message) => ({ event: 'error', data: { type: 'error', error: { type: 'api_error', message } } });
|
|
35
|
+
|
|
36
|
+
export class ResponsesStreamTranslator {
|
|
37
|
+
/**
|
|
38
|
+
* @param {{model:string, upstreamModel:string}} opts the catalog id the CLI
|
|
39
|
+
* expects back, and the upstream id the reasoning marker names
|
|
40
|
+
*/
|
|
41
|
+
constructor({ model, upstreamModel } = {}) {
|
|
42
|
+
this.model = model || 'bridge';
|
|
43
|
+
this.upstreamModel = upstreamModel || '';
|
|
44
|
+
this.id = newMessageId();
|
|
45
|
+
this.started = false;
|
|
46
|
+
this.nextIndex = 0;
|
|
47
|
+
this.open = null; // the open live block: { outputIndex, kind: 'text'|'thinking', index }
|
|
48
|
+
this.slots = new Map(); // output_index -> { type, callId, name, args, done, thinking, newPart }
|
|
49
|
+
this.toolsEmitted = 0;
|
|
50
|
+
this.emittedAny = false;
|
|
51
|
+
this.terminal = null; // 'completed' | 'incomplete' | 'failed'
|
|
52
|
+
this.incompleteReason = null;
|
|
53
|
+
this.usage = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_start() {
|
|
57
|
+
if (this.started) return [];
|
|
58
|
+
this.started = true;
|
|
59
|
+
return [{
|
|
60
|
+
event: 'message_start',
|
|
61
|
+
data: {
|
|
62
|
+
type: 'message_start',
|
|
63
|
+
message: {
|
|
64
|
+
id: this.id, type: 'message', role: 'assistant', model: this.model, content: [],
|
|
65
|
+
stop_reason: null, stop_sequence: null,
|
|
66
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
}];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_slot(i) {
|
|
73
|
+
let s = this.slots.get(i);
|
|
74
|
+
if (!s) {
|
|
75
|
+
s = { type: null, callId: '', name: '', args: '', done: false, thinking: false, newPart: false };
|
|
76
|
+
this.slots.set(i, s);
|
|
77
|
+
}
|
|
78
|
+
return s;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_close() {
|
|
82
|
+
if (!this.open) return [];
|
|
83
|
+
const { index } = this.open;
|
|
84
|
+
this.open = null;
|
|
85
|
+
return [{ event: 'content_block_stop', data: { type: 'content_block_stop', index } }];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_isOpen(outputIndex, kind) {
|
|
89
|
+
return !!this.open && this.open.outputIndex === outputIndex && this.open.kind === kind;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Open a live (streamed) block for `outputIndex`, closing whatever is open. */
|
|
93
|
+
_openLive(outputIndex, kind, contentBlock) {
|
|
94
|
+
const out = this._close();
|
|
95
|
+
const index = this.nextIndex++;
|
|
96
|
+
this.open = { outputIndex, kind, index };
|
|
97
|
+
out.push({ event: 'content_block_start', data: { type: 'content_block_start', index, content_block: contentBlock } });
|
|
98
|
+
this.emittedAny = true;
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_delta(delta) {
|
|
103
|
+
return { event: 'content_block_delta', data: { type: 'content_block_delta', index: this.open.index, delta } };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A complete block emitted at once: a tool call, a redacted reasoning item, a note. */
|
|
107
|
+
_whole(contentBlock, deltas = []) {
|
|
108
|
+
const out = this._close();
|
|
109
|
+
const index = this.nextIndex++;
|
|
110
|
+
out.push({ event: 'content_block_start', data: { type: 'content_block_start', index, content_block: contentBlock } });
|
|
111
|
+
for (const d of deltas) out.push({ event: 'content_block_delta', data: { type: 'content_block_delta', index, delta: d } });
|
|
112
|
+
out.push({ event: 'content_block_stop', data: { type: 'content_block_stop', index } });
|
|
113
|
+
this.emittedAny = true;
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A finished function call: the tool_use block — or, when it was cut off
|
|
119
|
+
* mid-arguments, a note saying so (forwarded, the CLI would reject the
|
|
120
|
+
* unterminated JSON and the model would retry the same oversized call).
|
|
121
|
+
*/
|
|
122
|
+
_finishCall(s, { callId, name, args, incomplete }) {
|
|
123
|
+
s.done = true;
|
|
124
|
+
if (incomplete || !parsesAsJson(args)) {
|
|
125
|
+
return this._whole({ type: 'text', text: '' }, [{ type: 'text_delta', text: truncatedToolNote(name || 'tool') }]);
|
|
126
|
+
}
|
|
127
|
+
this.toolsEmitted += 1;
|
|
128
|
+
return this._whole(
|
|
129
|
+
{ type: 'tool_use', id: callId || newToolUseId(), name, input: {} },
|
|
130
|
+
[{ type: 'input_json_delta', partial_json: args || '{}' }],
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Feed one parsed stream event. Returns the SSE events to write.
|
|
136
|
+
* @param {object} ev
|
|
137
|
+
*/
|
|
138
|
+
push(ev) {
|
|
139
|
+
const out = this._start();
|
|
140
|
+
if (!ev || typeof ev !== 'object') return out;
|
|
141
|
+
if (this.terminal === 'failed') return out; // the error event is the stream's last word
|
|
142
|
+
const i = Number.isInteger(ev.output_index) ? ev.output_index : null;
|
|
143
|
+
// A data line with no type but an `error` (the chat-style mid-stream error) is a failure too.
|
|
144
|
+
const type = ev.type || (ev.error ? 'error' : undefined);
|
|
145
|
+
switch (type) {
|
|
146
|
+
case 'response.output_item.added': {
|
|
147
|
+
if (i === null || !ev.item) break;
|
|
148
|
+
const s = this._slot(i);
|
|
149
|
+
s.type = ev.item.type;
|
|
150
|
+
if (s.type === 'function_call') {
|
|
151
|
+
s.callId = typeof ev.item.call_id === 'string' ? ev.item.call_id : '';
|
|
152
|
+
s.name = typeof ev.item.name === 'string' ? ev.item.name : '';
|
|
153
|
+
if (typeof ev.item.arguments === 'string') s.args = ev.item.arguments;
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case 'response.reasoning_summary_part.added': {
|
|
158
|
+
if (i !== null && this._slot(i).thinking) this._slot(i).newPart = true;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case 'response.reasoning_summary_text.delta': {
|
|
162
|
+
if (i === null || typeof ev.delta !== 'string' || !ev.delta) break;
|
|
163
|
+
const s = this._slot(i);
|
|
164
|
+
if (!this._isOpen(i, 'thinking')) {
|
|
165
|
+
out.push(...this._openLive(i, 'thinking', { type: 'thinking', thinking: '', signature: '' }));
|
|
166
|
+
s.thinking = true;
|
|
167
|
+
s.newPart = false;
|
|
168
|
+
}
|
|
169
|
+
if (s.newPart) {
|
|
170
|
+
out.push(this._delta({ type: 'thinking_delta', thinking: '\n\n' }));
|
|
171
|
+
s.newPart = false;
|
|
172
|
+
}
|
|
173
|
+
out.push(this._delta({ type: 'thinking_delta', thinking: ev.delta }));
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
case 'response.output_text.delta':
|
|
177
|
+
case 'response.refusal.delta': {
|
|
178
|
+
if (i === null || typeof ev.delta !== 'string' || !ev.delta) break;
|
|
179
|
+
if (!this._isOpen(i, 'text')) out.push(...this._openLive(i, 'text', { type: 'text', text: '' }));
|
|
180
|
+
out.push(this._delta({ type: 'text_delta', text: ev.delta }));
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
case 'response.function_call_arguments.delta': {
|
|
184
|
+
if (i !== null && typeof ev.delta === 'string') this._slot(i).args += ev.delta;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case 'response.output_item.done': {
|
|
188
|
+
if (i === null || !ev.item) break;
|
|
189
|
+
const s = this._slot(i);
|
|
190
|
+
const item = ev.item;
|
|
191
|
+
if (item.type === 'reasoning') {
|
|
192
|
+
s.done = true;
|
|
193
|
+
const enc = typeof item.encrypted_content === 'string' ? item.encrypted_content : '';
|
|
194
|
+
const marker = enc ? encodeReasoningMarker(this.upstreamModel, enc) : '';
|
|
195
|
+
if (this._isOpen(i, 'thinking')) {
|
|
196
|
+
if (marker) out.push(this._delta({ type: 'signature_delta', signature: marker }));
|
|
197
|
+
out.push(...this._close());
|
|
198
|
+
} else if (marker) {
|
|
199
|
+
// No summary text — or its thinking block was already closed by an
|
|
200
|
+
// interleaved item: a redacted block carries the reasoning on.
|
|
201
|
+
out.push(...this._whole({ type: 'redacted_thinking', data: marker }));
|
|
202
|
+
}
|
|
203
|
+
} else if (item.type === 'message') {
|
|
204
|
+
s.done = true;
|
|
205
|
+
if (this.open && this.open.outputIndex === i) out.push(...this._close());
|
|
206
|
+
} else if (item.type === 'function_call') {
|
|
207
|
+
out.push(...this._finishCall(s, {
|
|
208
|
+
callId: typeof item.call_id === 'string' && item.call_id ? item.call_id : s.callId,
|
|
209
|
+
name: typeof item.name === 'string' && item.name ? item.name : s.name,
|
|
210
|
+
// The done item's arguments are authoritative — unless it came back empty
|
|
211
|
+
// after arguments streamed in (then the streamed ones are the call).
|
|
212
|
+
args: typeof item.arguments === 'string' && item.arguments.trim() ? item.arguments : s.args,
|
|
213
|
+
incomplete: item.status === 'incomplete',
|
|
214
|
+
}));
|
|
215
|
+
}
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
case 'response.completed':
|
|
219
|
+
case 'response.incomplete': {
|
|
220
|
+
this.terminal = ev.type === 'response.completed' ? 'completed' : 'incomplete';
|
|
221
|
+
const r = ev.response && typeof ev.response === 'object' ? ev.response : {};
|
|
222
|
+
if (r.usage) this.usage = r.usage;
|
|
223
|
+
if (r.incomplete_details && typeof r.incomplete_details.reason === 'string') this.incompleteReason = r.incomplete_details.reason;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
case 'response.failed':
|
|
227
|
+
case 'error': {
|
|
228
|
+
this.terminal = 'failed';
|
|
229
|
+
const e = type === 'error'
|
|
230
|
+
? (ev.error && typeof ev.error === 'object' ? ev.error : typeof ev.error === 'string' ? { message: ev.error } : ev)
|
|
231
|
+
: ((ev.response && ev.response.error) || {});
|
|
232
|
+
const msg = (e && (e.message || e.code)) || 'the upstream reported a failure';
|
|
233
|
+
// A context overflow is said in the words the CLI compacts on, as for an HTTP 400 — and
|
|
234
|
+
// only an overflow: a rate limit or a timeout keeps its own words (errors.mjs).
|
|
235
|
+
out.push(...this._close(), isFailedResponseOverflow(e && e.code, e && e.message)
|
|
236
|
+
? { event: 'error', data: { type: 'error', error: { type: 'invalid_request_error', message: PROMPT_TOO_LONG } } }
|
|
237
|
+
: errorEvent(`upstream stream error: ${msg}`));
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
default:
|
|
241
|
+
break; // created, in_progress, content_part.*, *.done text events, copilot_usage lines …
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** End of stream: close blocks, settle unfinished calls, emit message_delta + message_stop. */
|
|
247
|
+
finish() {
|
|
248
|
+
const out = this._start();
|
|
249
|
+
if (this.terminal === 'failed') return out; // the error event is already out
|
|
250
|
+
out.push(...this._close());
|
|
251
|
+
// Function calls whose item never finished (a cut stream, an incomplete response).
|
|
252
|
+
for (const [, s] of [...this.slots.entries()].sort((a, b) => a[0] - b[0])) {
|
|
253
|
+
if (s.type !== 'function_call' || s.done) continue;
|
|
254
|
+
out.push(...this._finishCall(s, { callId: s.callId, name: s.name, args: s.args, incomplete: !s.args.trim() }));
|
|
255
|
+
}
|
|
256
|
+
let stop;
|
|
257
|
+
if (this.terminal === 'incomplete') stop = this.incompleteReason === 'max_output_tokens' ? 'max_tokens' : 'end_turn';
|
|
258
|
+
else if (this.terminal === 'completed') stop = 'end_turn';
|
|
259
|
+
else stop = this.emittedAny ? 'end_turn' : null;
|
|
260
|
+
if (stop === null) {
|
|
261
|
+
out.push(errorEvent('upstream stream ended without content or a terminal event'));
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
if (this.toolsEmitted && stop === 'end_turn') stop = 'tool_use';
|
|
265
|
+
out.push({ event: 'message_delta', data: { type: 'message_delta', delta: { stop_reason: stop, stop_sequence: null }, usage: mapResponsesUsage(this.usage) } });
|
|
266
|
+
out.push({ event: 'message_stop', data: { type: 'message_stop' } });
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* A buffered (non-streaming) Responses API body -> Anthropic message object.
|
|
273
|
+
* @param {object} resp the upstream JSON body
|
|
274
|
+
* @param {{model:string, upstreamModel:string}} opts
|
|
275
|
+
*/
|
|
276
|
+
export function toMessagesResponseFromResponses(resp, { model, upstreamModel } = {}) {
|
|
277
|
+
const r = resp && typeof resp === 'object' ? resp : {};
|
|
278
|
+
const content = [];
|
|
279
|
+
let tools = 0;
|
|
280
|
+
for (const item of Array.isArray(r.output) ? r.output : []) {
|
|
281
|
+
if (!item || typeof item !== 'object') continue;
|
|
282
|
+
if (item.type === 'reasoning') {
|
|
283
|
+
const text = (Array.isArray(item.summary) ? item.summary : [])
|
|
284
|
+
.map((p) => (p && typeof p.text === 'string' ? p.text : ''))
|
|
285
|
+
.filter(Boolean)
|
|
286
|
+
.join('\n\n');
|
|
287
|
+
const marker = typeof item.encrypted_content === 'string' && item.encrypted_content
|
|
288
|
+
? encodeReasoningMarker(upstreamModel || '', item.encrypted_content) : '';
|
|
289
|
+
if (text) content.push({ type: 'thinking', thinking: text, signature: marker });
|
|
290
|
+
else if (marker) content.push({ type: 'redacted_thinking', data: marker });
|
|
291
|
+
} else if (item.type === 'message') {
|
|
292
|
+
const text = (Array.isArray(item.content) ? item.content : [])
|
|
293
|
+
.map((c) => {
|
|
294
|
+
if (!c || typeof c !== 'object') return '';
|
|
295
|
+
if (c.type === 'output_text' && typeof c.text === 'string') return c.text;
|
|
296
|
+
if (c.type === 'refusal' && typeof c.refusal === 'string') return c.refusal;
|
|
297
|
+
return '';
|
|
298
|
+
})
|
|
299
|
+
.join('');
|
|
300
|
+
if (text) content.push({ type: 'text', text });
|
|
301
|
+
} else if (item.type === 'function_call') {
|
|
302
|
+
const name = String(item.name || '');
|
|
303
|
+
const args = typeof item.arguments === 'string' ? item.arguments : '';
|
|
304
|
+
if (item.status === 'incomplete' || !parsesAsJson(args)) {
|
|
305
|
+
content.push({ type: 'text', text: truncatedToolNote(name || 'tool') });
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
let input = {};
|
|
309
|
+
if (args.trim()) { try { input = JSON.parse(args); } catch { input = { _raw: args }; } }
|
|
310
|
+
content.push({ type: 'tool_use', id: item.call_id || newToolUseId(), name, input });
|
|
311
|
+
tools += 1;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
let stop = r.status === 'incomplete' && r.incomplete_details && r.incomplete_details.reason === 'max_output_tokens'
|
|
315
|
+
? 'max_tokens' : 'end_turn';
|
|
316
|
+
if (tools && stop === 'end_turn') stop = 'tool_use';
|
|
317
|
+
return {
|
|
318
|
+
id: newMessageId(),
|
|
319
|
+
type: 'message',
|
|
320
|
+
role: 'assistant',
|
|
321
|
+
model: model || 'bridge',
|
|
322
|
+
content,
|
|
323
|
+
stop_reason: stop,
|
|
324
|
+
stop_sequence: null,
|
|
325
|
+
usage: mapResponsesUsage(r.usage),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
import { toChatRequest } from './translate/request.mjs';
|
|
8
8
|
import { ChatStreamTranslator, SseDataParser, serializeSse } from './translate/stream.mjs';
|
|
9
9
|
import { toMessagesResponse } from './translate/response.mjs';
|
|
10
|
-
import {
|
|
10
|
+
import { toResponsesRequest } from './translate/responses-request.mjs';
|
|
11
|
+
import { ResponsesStreamTranslator, toMessagesResponseFromResponses } from './translate/responses-stream.mjs';
|
|
12
|
+
import { mapUpstreamError, mapNetworkError, bridgeErrors, anthropicError, isFailedResponseOverflow, PAYLOAD_CEILING_BYTES } from './errors.mjs';
|
|
11
13
|
import { copilotToken, invalidateCopilotToken, copilotApiHost, copilotHeaders, bodyHasImage, requestInitiator } from './providers/copilot.mjs';
|
|
12
14
|
import { upstreamSettings, providerReadiness } from './registry.mjs';
|
|
13
15
|
import { KeyedSemaphore } from './semaphore.mjs';
|
|
@@ -36,7 +38,7 @@ async function prepareUpstream(us, body, { fetch: f, requestHeaders }) {
|
|
|
36
38
|
const build = async (force) => {
|
|
37
39
|
const { token, apiHost } = await copilotToken(us.githubToken, { fetch: f, force });
|
|
38
40
|
const host = apiHost || copilotApiHost(us.accountType);
|
|
39
|
-
const path = us.api === 'anthropic' ? '/v1/messages' : '/chat/completions';
|
|
41
|
+
const path = us.api === 'anthropic' ? '/v1/messages' : us.api === 'openai-responses' ? '/responses' : '/chat/completions';
|
|
40
42
|
const headers = { ...copilotHeaders(token, { vision, initiator }), ...us.headers };
|
|
41
43
|
if (us.api === 'anthropic') {
|
|
42
44
|
headers['anthropic-version'] = requestHeaders['anthropic-version'] || '2023-06-01';
|
|
@@ -62,7 +64,7 @@ async function prepareUpstream(us, body, { fetch: f, requestHeaders }) {
|
|
|
62
64
|
}
|
|
63
65
|
const base = (us.baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '');
|
|
64
66
|
return {
|
|
65
|
-
url: `${base}
|
|
67
|
+
url: `${base}/${us.api === 'openai-responses' ? 'responses' : 'chat/completions'}`,
|
|
66
68
|
headers: { 'content-type': 'application/json', ...(us.apiKey ? { authorization: `Bearer ${us.apiKey}` } : {}), ...us.headers },
|
|
67
69
|
provider: us.provider,
|
|
68
70
|
initiator,
|
|
@@ -96,7 +98,8 @@ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '
|
|
|
96
98
|
if (us.api === 'anthropic') {
|
|
97
99
|
outBody = { ...body, model: us.model };
|
|
98
100
|
} else {
|
|
99
|
-
const
|
|
101
|
+
const responses = us.api === 'openai-responses';
|
|
102
|
+
const t = (responses ? toResponsesRequest : toChatRequest)(body, { upstreamModel: us.model, capabilities: us.capabilities });
|
|
100
103
|
if (t.error) {
|
|
101
104
|
const e = bridgeErrors.unsupported(t.error.message);
|
|
102
105
|
recordBridgeError({ tag, catalogId: entry.id, provider: us.provider, status: e.status, message: e.body.error.message });
|
|
@@ -105,7 +108,7 @@ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '
|
|
|
105
108
|
for (const w of t.warnings) {
|
|
106
109
|
const line = w === 'max_tokens clamped'
|
|
107
110
|
? 'max_tokens clamped to the model\'s output limit'
|
|
108
|
-
: `${w} has no chat/completions equivalent — dropped`;
|
|
111
|
+
: `${w} has no ${responses ? 'Responses API' : 'chat/completions'} equivalent — dropped`;
|
|
109
112
|
warnOnce(`${entry.id}:${w}`, `[worca] bridge: model ${JSON.stringify(entry.id)}: ${line}`, log);
|
|
110
113
|
}
|
|
111
114
|
outBody = t.body;
|
|
@@ -176,11 +179,35 @@ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '
|
|
|
176
179
|
|
|
177
180
|
if (!streaming) {
|
|
178
181
|
const j = await res.json();
|
|
179
|
-
|
|
182
|
+
if (us.api === 'openai-responses' && j && j.status === 'failed') {
|
|
183
|
+
// A buffered Responses body reports its failure with HTTP 200: answer it as the error it is.
|
|
184
|
+
const fe = j.error && typeof j.error === 'object' ? j.error : {};
|
|
185
|
+
const e = isFailedResponseOverflow(fe.code, fe.message)
|
|
186
|
+
? bridgeErrors.tooLarge()
|
|
187
|
+
: anthropicError(502, 'api_error', `${us.provider}: upstream error — ${fe.message || fe.code || 'the response failed'}`);
|
|
188
|
+
recordBridgeError({ tag, catalogId: entry.id, provider: us.provider, status: 200, message: e.body.error.message });
|
|
189
|
+
return reply.json(e.status, e.body);
|
|
190
|
+
}
|
|
191
|
+
return reply.json(200, us.api === 'openai-responses'
|
|
192
|
+
? toMessagesResponseFromResponses(j, { model: entry.id, upstreamModel: us.model })
|
|
193
|
+
: toMessagesResponse(j, { model: entry.id }));
|
|
180
194
|
}
|
|
181
195
|
|
|
182
196
|
reply.status(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
|
|
183
|
-
const translator =
|
|
197
|
+
const translator = us.api === 'openai-responses'
|
|
198
|
+
? new ResponsesStreamTranslator({ model: entry.id, upstreamModel: us.model })
|
|
199
|
+
: 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.
|
|
203
|
+
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
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return events;
|
|
210
|
+
};
|
|
184
211
|
const parser = new SseDataParser();
|
|
185
212
|
const decoder = new TextDecoder();
|
|
186
213
|
let ping = setInterval(() => reply.write('event: ping\ndata: {"type":"ping"}\n\n'), PING_INTERVAL_MS);
|
|
@@ -191,14 +218,14 @@ export async function handleMessages({ entry, body, requestHeaders = {}, tag = '
|
|
|
191
218
|
for await (const chunk of res.body) {
|
|
192
219
|
const text = decoder.decode(chunk, { stream: true });
|
|
193
220
|
for (const obj of parser.feed(text)) {
|
|
194
|
-
const events = translator.push(obj);
|
|
221
|
+
const events = booked(translator.push(obj));
|
|
195
222
|
if (events.length) { reply.write(serializeSse(events)); bump(); }
|
|
196
223
|
}
|
|
197
224
|
if (parser.done) break;
|
|
198
225
|
}
|
|
199
|
-
for (const obj of parser.end()) reply.write(serializeSse(translator.push(obj)));
|
|
226
|
+
for (const obj of parser.end()) reply.write(serializeSse(booked(translator.push(obj))));
|
|
200
227
|
}
|
|
201
|
-
reply.write(serializeSse(translator.finish()));
|
|
228
|
+
reply.write(serializeSse(booked(translator.finish())));
|
|
202
229
|
} finally {
|
|
203
230
|
clearInterval(ping);
|
|
204
231
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Cloudflare Access token verification (docs/remote-access.md). Access signs a
|
|
2
|
+
// short-lived RS256 JWT and sends it as `Cf-Access-Jwt-Assertion` on every
|
|
3
|
+
// request it lets through, WebSocket upgrades included. The team's public keys
|
|
4
|
+
// come from https://<team>/cdn-cgi/access/certs: cached 10 min, refetched on an
|
|
5
|
+
// unknown `kid` (key rotation) at most once per 30 s. node:crypto only.
|
|
6
|
+
import { createPublicKey, verify } from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
const KEYS_TTL_MS = 600_000;
|
|
9
|
+
const REFETCH_MIN_MS = 30_000;
|
|
10
|
+
const CLOCK_SKEW_S = 30;
|
|
11
|
+
|
|
12
|
+
/** "acme.cloudflareaccess.com" (a scheme or trailing slash is tolerated) -> "acme.cloudflareaccess.com". */
|
|
13
|
+
export function normalizeTeamDomain(value) {
|
|
14
|
+
return String(value || '').trim().replace(/^https?:\/\//i, '').replace(/\/+$/, '').toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @returns {(token: string) => Promise<{ email: string|null, sub: string|null } | null>}
|
|
19
|
+
* resolves to the identity for a valid token, null for any invalid one; rejects
|
|
20
|
+
* only when the keys cannot be loaded (the caller answers 503, not 401).
|
|
21
|
+
*/
|
|
22
|
+
export function createAccessVerifier({ teamDomain, aud, fetchImpl = fetch, now = () => Date.now() }) {
|
|
23
|
+
const domain = normalizeTeamDomain(teamDomain);
|
|
24
|
+
if (!domain) throw new Error('createAccessVerifier: teamDomain is required');
|
|
25
|
+
if (!aud) throw new Error('createAccessVerifier: aud is required');
|
|
26
|
+
const issuer = `https://${domain}`;
|
|
27
|
+
const certsUrl = `${issuer}/cdn-cgi/access/certs`;
|
|
28
|
+
let keys = new Map();
|
|
29
|
+
let fetchedAt = 0;
|
|
30
|
+
let inflight = null;
|
|
31
|
+
|
|
32
|
+
async function fetchKeys() {
|
|
33
|
+
const res = await fetchImpl(certsUrl);
|
|
34
|
+
if (!res.ok) throw new Error(`Access certs: HTTP ${res.status}`);
|
|
35
|
+
const body = await res.json();
|
|
36
|
+
const next = new Map();
|
|
37
|
+
for (const k of Array.isArray(body?.keys) ? body.keys : []) {
|
|
38
|
+
if (typeof k?.kid !== 'string' || k.kty !== 'RSA') continue;
|
|
39
|
+
try { next.set(k.kid, createPublicKey({ key: k, format: 'jwk' })); } catch { /* skip a malformed key */ }
|
|
40
|
+
}
|
|
41
|
+
if (!next.size) throw new Error('Access certs: no usable RSA keys');
|
|
42
|
+
keys = next;
|
|
43
|
+
fetchedAt = now();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function loadKeys(force) {
|
|
47
|
+
const age = now() - fetchedAt;
|
|
48
|
+
if (keys.size && age < (force ? REFETCH_MIN_MS : KEYS_TTL_MS)) return;
|
|
49
|
+
// One fetch at a time: a burst of requests after expiry shares it.
|
|
50
|
+
inflight ||= fetchKeys().finally(() => { inflight = null; });
|
|
51
|
+
try {
|
|
52
|
+
await inflight;
|
|
53
|
+
} catch (err) {
|
|
54
|
+
// Stale keys beat none: keep serving with them if the refresh fails.
|
|
55
|
+
if (!keys.size) throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const decode = (seg) => JSON.parse(Buffer.from(seg, 'base64url').toString('utf8'));
|
|
60
|
+
|
|
61
|
+
return async function verifyAccessJwt(token) {
|
|
62
|
+
const parts = typeof token === 'string' ? token.split('.') : [];
|
|
63
|
+
if (parts.length !== 3 || parts.some((p) => !p)) return null;
|
|
64
|
+
let header, payload;
|
|
65
|
+
try { header = decode(parts[0]); payload = decode(parts[1]); } catch { return null; }
|
|
66
|
+
if (!header || header.alg !== 'RS256' || typeof header.kid !== 'string') return null;
|
|
67
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
68
|
+
|
|
69
|
+
await loadKeys(false);
|
|
70
|
+
if (!keys.has(header.kid)) await loadKeys(true);
|
|
71
|
+
const key = keys.get(header.kid);
|
|
72
|
+
if (!key) return null;
|
|
73
|
+
|
|
74
|
+
let ok = false;
|
|
75
|
+
try {
|
|
76
|
+
ok = verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(parts[2], 'base64url'));
|
|
77
|
+
} catch { ok = false; }
|
|
78
|
+
if (!ok) return null;
|
|
79
|
+
|
|
80
|
+
const t = Math.floor(now() / 1000);
|
|
81
|
+
const auds = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
82
|
+
if (payload.iss !== issuer || !auds.includes(aud)) return null;
|
|
83
|
+
if (typeof payload.exp !== 'number' || payload.exp < t - CLOCK_SKEW_S) return null;
|
|
84
|
+
if (typeof payload.nbf === 'number' && payload.nbf > t + CLOCK_SKEW_S) return null;
|
|
85
|
+
return {
|
|
86
|
+
email: typeof payload.email === 'string' ? payload.email : null,
|
|
87
|
+
sub: typeof payload.sub === 'string' ? payload.sub : null,
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -16,6 +16,7 @@ import { createAllowlistGuard, parseIdList } from './allowlist.mjs';
|
|
|
16
16
|
import { runRef, fmtUsd, fmtMs } from './renderers.mjs';
|
|
17
17
|
import { promptFields, parseAnswerLine } from '../../shared/forms/project.mjs';
|
|
18
18
|
import { giveUpOption, describePauseReason, pauseConsequences } from '../failure-policy.mjs';
|
|
19
|
+
import { chatActor } from '../identity.mjs';
|
|
19
20
|
|
|
20
21
|
const md = (value) => ({ kind: 'markdown', value });
|
|
21
22
|
const reply = (text, severity = 'info') => ({ title: null, body: [md(text)], severity });
|
|
@@ -227,21 +228,21 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
|
|
|
227
228
|
return reply(`\`${runRef(t.run.runId)}\` cost so far: ${fmtUsd(state?.totalCostUsd) || '$0.00'}`);
|
|
228
229
|
},
|
|
229
230
|
|
|
230
|
-
pause: async ({ chatKey, args }) => {
|
|
231
|
+
pause: async ({ chatKey, args, actor }) => {
|
|
231
232
|
const t = resolveTarget(args[0], scopedRuns(chatKey), [], { wantLive: true });
|
|
232
233
|
if (t.error) return t.error;
|
|
233
|
-
await actions.pause(t.run.runId);
|
|
234
|
+
await actions.pause(t.run.runId, actor);
|
|
234
235
|
return reply(`⏸ Pausing \`${runRef(t.run.runId)}\` — resume from the UI or \`/resume ${runRef(t.run.runId)}\`.`, 'warning');
|
|
235
236
|
},
|
|
236
237
|
|
|
237
|
-
stop: async ({ chatKey, args }) => {
|
|
238
|
+
stop: async ({ chatKey, args, actor }) => {
|
|
238
239
|
const t = resolveTarget(args[0], scopedRuns(chatKey), [], { wantLive: true });
|
|
239
240
|
if (t.error) return t.error;
|
|
240
|
-
await actions.stop(t.run.runId);
|
|
241
|
+
await actions.stop(t.run.runId, actor);
|
|
241
242
|
return reply(`⏹ Stopping \`${runRef(t.run.runId)}\` (${String(t.run.title || '').slice(0, 50)}).`, 'warning');
|
|
242
243
|
},
|
|
243
244
|
|
|
244
|
-
resume: async ({ args }) => {
|
|
245
|
+
resume: async ({ args, actor }) => {
|
|
245
246
|
// Resolve against PAUSED/INTERRUPTED history rows (resume works across
|
|
246
247
|
// restarts); a live match means it's already running.
|
|
247
248
|
const rows = (await actions.history({ limit: 50 })).filter((r) => r.status === 'paused' || r.status === 'interrupted');
|
|
@@ -254,7 +255,7 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
|
|
|
254
255
|
if (args[0] || rows.length !== 1) return t.error;
|
|
255
256
|
t = { row: rows[0] }; // exactly one paused row, bare /resume: take it
|
|
256
257
|
}
|
|
257
|
-
const out = await actions.resume(t.row.id);
|
|
258
|
+
const out = await actions.resume(t.row.id, actor);
|
|
258
259
|
if (out?.ok) return reply(`▶️ Resuming \`${runRef(t.row.id)}\` — ${String(t.row.title || '').slice(0, 50)}`);
|
|
259
260
|
return reply(`Could not resume \`${runRef(t.row.id)}\`: ${out?.error || 'unknown error'}`, 'error');
|
|
260
261
|
},
|
|
@@ -263,7 +264,7 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
|
|
|
263
264
|
retry: async (env) => answerDecision(env, 'retry'),
|
|
264
265
|
abort: async (env) => answerDecision(env, 'abort'),
|
|
265
266
|
|
|
266
|
-
answer: async ({ chatKey, args }) => {
|
|
267
|
+
answer: async ({ chatKey, args, actor }) => {
|
|
267
268
|
const t = resolveTarget(args[0] && args[0].startsWith('*') ? args[0] : '', scopedRuns(chatKey), [], { wantLive: true });
|
|
268
269
|
if (t.error) return t.error;
|
|
269
270
|
const pq = actions.pendingQuestion(t.run.runId);
|
|
@@ -295,7 +296,7 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
|
|
|
295
296
|
'', `Reply: \`/answer ${formRef} ${example}\``].join('\n'), 'warning');
|
|
296
297
|
}
|
|
297
298
|
try {
|
|
298
|
-
await actions.answer(t.run.runId, pq.id, { values: parsed.values });
|
|
299
|
+
await actions.answer(t.run.runId, pq.id, { values: parsed.values }, actor);
|
|
299
300
|
} catch (err) {
|
|
300
301
|
if (!err || err.code !== 'INVALID_ANSWER') throw err; // the handler's catch owns everything else
|
|
301
302
|
return reply([`\`${formRef}\` — that answer was rejected:`,
|
|
@@ -348,7 +349,7 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
|
|
|
348
349
|
return reply(`Q${i + 1} takes an option number (1–${opts.length}), not text.`, 'warning');
|
|
349
350
|
}
|
|
350
351
|
}
|
|
351
|
-
await actions.answer(t.run.runId, pq.id, { answers });
|
|
352
|
+
await actions.answer(t.run.runId, pq.id, { answers }, actor);
|
|
352
353
|
return reply(`✅ Answered ${questions.length} question${questions.length === 1 ? '' : 's'} on \`${ref}\`.`, 'success');
|
|
353
354
|
},
|
|
354
355
|
|
|
@@ -367,7 +368,7 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
|
|
|
367
368
|
},
|
|
368
369
|
};
|
|
369
370
|
|
|
370
|
-
async function answerDecision({ chatKey, args }, verb) {
|
|
371
|
+
async function answerDecision({ chatKey, args, actor }, verb) {
|
|
371
372
|
const t = resolveTarget(args[0], scopedRuns(chatKey), [], { wantLive: true });
|
|
372
373
|
if (t.error) return t.error;
|
|
373
374
|
const pq = actions.pendingQuestion(t.run.runId);
|
|
@@ -384,7 +385,7 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
|
|
|
384
385
|
} else {
|
|
385
386
|
return reply(`\`${ref}\` is waiting on ${pq.kind} — use \`/answer ${ref} <n>\`.`, 'warning');
|
|
386
387
|
}
|
|
387
|
-
await actions.answer(t.run.runId, pq.id, payload);
|
|
388
|
+
await actions.answer(t.run.runId, pq.id, payload, actor);
|
|
388
389
|
const what = pq.kind === 'gate'
|
|
389
390
|
? (payload.decision === 'continue' ? 'approved — continuing' : 'sent back for another cycle')
|
|
390
391
|
: (payload.decision === 'retry' ? 'retrying' : payload.decision === 'abort' ? 'aborting the run' : 'pausing the run');
|
|
@@ -407,8 +408,12 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
|
|
|
407
408
|
const handler = Object.hasOwn(handlers, parsed.command) ? handlers[parsed.command] : null;
|
|
408
409
|
const chatKey = `${platform}:${msg.chatId}`;
|
|
409
410
|
if (!handler) return reply(`Unknown command \`/${parsed.command}\` — \`/help\` lists commands.`, 'warning');
|
|
411
|
+
// Who sent it, as attribution TEXT ("ada via Slack"): the platform's display name or
|
|
412
|
+
// user id, never a sign-in identity. Actions that change a run record it.
|
|
413
|
+
const meta = msg.meta && typeof msg.meta === 'object' ? msg.meta : {};
|
|
414
|
+
const actor = chatActor({ platform, userName: meta.username || meta.name || meta.userName || null, userId: msg.userId });
|
|
410
415
|
try {
|
|
411
|
-
return await handler({ chatKey, platform, plugin, channelId, msg, args: parsed.args });
|
|
416
|
+
return await handler({ chatKey, platform, plugin, channelId, msg, args: parsed.args, actor });
|
|
412
417
|
} catch (err) {
|
|
413
418
|
logger('error', `chat command /${parsed.command} failed: ${err?.message || err}`);
|
|
414
419
|
return reply(`Command failed: ${String(err?.message || err).slice(0, 200)}`, 'error');
|