@askalf/dario 6.2.0 → 6.3.0

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.
@@ -0,0 +1,616 @@
1
+ /**
2
+ * Inbound OpenAI Responses API (v6.3) — `POST /v1/responses` on dario.
3
+ *
4
+ * Codex CLI 0.154 dropped `wire_api = "chat"` (openai/codex discussion 7782):
5
+ * a custom provider must speak the Responses API or it cannot be used at all.
6
+ * The OpenAI Agents SDK and everything newer from OpenAI speak the same shape.
7
+ * This module makes dario a Responses endpoint, so those clients run on a
8
+ * Claude subscription — and on a ChatGPT one, through the codex leg.
9
+ *
10
+ * Shape of the work: the request is translated ONCE at the front door into
11
+ * the Anthropic Messages body every other dario path already understands
12
+ * (`responsesRequestToAnthropic`), the request then runs as an ordinary
13
+ * Anthropic-shape request — pool, template, codex leg, mid-stream
14
+ * continuation, all of it — and every byte written back to the client passes
15
+ * through `ResponsesOut`, which turns Anthropic SSE (or a buffered Anthropic
16
+ * message, or an Anthropic error body) into the Responses wire shape. Nothing
17
+ * downstream of the front door knows the client is a Responses client.
18
+ *
19
+ * The reverse direction — an Anthropic-shape request served by the codex
20
+ * backend — has lived in anthropic-responses-translate.ts since 5.5.87. The
21
+ * two translators share types and nothing else on purpose: each direction is
22
+ * read against the wire captures that motivated it.
23
+ *
24
+ * What is dropped, and said so once per process at verbose: hosted tool types
25
+ * the pool cannot run (`web_search`, `file_search`, `mcp`, …), `reasoning`
26
+ * items on the way in (the encrypted content is OpenAI's, and the pool does
27
+ * not need them back), `text.format`, `previous_response_id` (dario is
28
+ * stateless; a 400, not a silent ignore).
29
+ */
30
+ import { SseFrameSplitter } from './midstream.js';
31
+ export class ResponsesRequestError extends Error {
32
+ param;
33
+ constructor(message, param) {
34
+ super(message);
35
+ this.param = param;
36
+ }
37
+ }
38
+ const DEFAULT_MAX_OUTPUT_TOKENS = 32000;
39
+ const EFFORTS = {
40
+ none: 'low', minimal: 'low', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max',
41
+ };
42
+ function textPart(text) { return { type: 'text', text }; }
43
+ function imagePart(url, warnings) {
44
+ const m = /^data:([^;,]+);base64,(.+)$/s.exec(url);
45
+ if (m)
46
+ return { type: 'image', source: { type: 'base64', media_type: m[1], data: m[2] } };
47
+ if (/^https?:\/\//.test(url))
48
+ return { type: 'image', source: { type: 'url', url } };
49
+ warnings.push(`input_image with an unsupported image_url dropped (${url.slice(0, 24)}…)`);
50
+ return null;
51
+ }
52
+ /** Content of a Responses message → Anthropic content blocks. */
53
+ function contentParts(content, warnings) {
54
+ if (typeof content === 'string')
55
+ return content.length > 0 ? [textPart(content)] : [];
56
+ if (!Array.isArray(content))
57
+ return [];
58
+ const out = [];
59
+ for (const p of content) {
60
+ switch (p?.type) {
61
+ case 'input_text':
62
+ case 'output_text':
63
+ case 'text':
64
+ if (typeof p.text === 'string' && p.text.length > 0)
65
+ out.push(textPart(p.text));
66
+ break;
67
+ case 'refusal':
68
+ if (typeof p.refusal === 'string')
69
+ out.push(textPart(p.refusal));
70
+ break;
71
+ case 'input_image': {
72
+ const url = typeof p.image_url === 'string' ? p.image_url : p.image_url?.url;
73
+ if (typeof url === 'string') {
74
+ const img = imagePart(url, warnings);
75
+ if (img)
76
+ out.push(img);
77
+ }
78
+ else
79
+ warnings.push('input_image without image_url dropped (file_id images are not supported)');
80
+ break;
81
+ }
82
+ default:
83
+ warnings.push(`content part ${String(p?.type)} dropped`);
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+ /** A tool's output as a tool_result `content` — string when it is only text. */
89
+ function toolOutput(output, warnings) {
90
+ if (typeof output === 'string')
91
+ return output;
92
+ if (Array.isArray(output)) {
93
+ const parts = contentParts(output, warnings);
94
+ return parts.every((p) => p.type === 'text') ? parts.map((p) => p.text).join('') : parts;
95
+ }
96
+ return output === undefined || output === null ? '' : JSON.stringify(output);
97
+ }
98
+ function parseArguments(args, name, warnings) {
99
+ if (typeof args !== 'string' || args.trim() === '')
100
+ return {};
101
+ try {
102
+ const v = JSON.parse(args);
103
+ if (v !== null && typeof v === 'object' && !Array.isArray(v))
104
+ return v;
105
+ warnings.push(`function_call ${name}: arguments is not a JSON object, sent as {}`);
106
+ }
107
+ catch {
108
+ warnings.push(`function_call ${name}: arguments is not valid JSON, sent as {}`);
109
+ }
110
+ return {};
111
+ }
112
+ /** Function tools, with `namespace` groups flattened; hosted tool types dropped. */
113
+ function translateTools(tools, warnings) {
114
+ if (!Array.isArray(tools))
115
+ return [];
116
+ const out = [];
117
+ const add = (t) => {
118
+ switch (t?.type) {
119
+ case 'function': {
120
+ if (typeof t.name !== 'string' || t.name.length === 0) {
121
+ warnings.push('function tool without a name dropped');
122
+ break;
123
+ }
124
+ const tool = { name: t.name, input_schema: (t.parameters && typeof t.parameters === 'object') ? t.parameters : { type: 'object', properties: {} } };
125
+ if (typeof t.description === 'string')
126
+ tool.description = t.description;
127
+ out.push(tool);
128
+ break;
129
+ }
130
+ case 'namespace':
131
+ for (const inner of (Array.isArray(t.tools) ? t.tools : []))
132
+ add(inner);
133
+ break;
134
+ case 'custom':
135
+ warnings.push(`custom tool ${String(t.name)} dropped (freeform tools have no Anthropic equivalent)`);
136
+ break;
137
+ default:
138
+ warnings.push(`${String(t?.type)} tool dropped (hosted tools do not run on the Claude pool)`);
139
+ }
140
+ };
141
+ for (const t of tools)
142
+ add(t);
143
+ return out;
144
+ }
145
+ function translateToolChoice(choice, parallel) {
146
+ let out;
147
+ if (choice === undefined || choice === 'auto')
148
+ out = { type: 'auto' };
149
+ else if (choice === 'required')
150
+ out = { type: 'any' };
151
+ else if (choice === 'none')
152
+ out = { type: 'none' };
153
+ else if (choice && typeof choice === 'object') {
154
+ const c = choice;
155
+ if (c.type === 'function' && typeof c.name === 'string')
156
+ out = { type: 'tool', name: c.name };
157
+ else
158
+ out = { type: 'auto' }; // allowed_tools and hosted-tool choices: let the model decide
159
+ }
160
+ if (parallel === false && out && out.type !== 'none')
161
+ out.disable_parallel_tool_use = true;
162
+ // Anthropic's default is auto; only send the field when it says something.
163
+ if (out && out.type === 'auto' && !out.disable_parallel_tool_use)
164
+ return undefined;
165
+ return out;
166
+ }
167
+ /**
168
+ * The Responses request as the Anthropic Messages body the rest of dario
169
+ * serves. Throws ResponsesRequestError for shapes that cannot be served
170
+ * honestly (no model, no input, `previous_response_id`).
171
+ */
172
+ export function responsesRequestToAnthropic(req) {
173
+ const warnings = [];
174
+ const model = typeof req.model === 'string' ? req.model.trim() : '';
175
+ if (!model)
176
+ throw new ResponsesRequestError('model is required', 'model');
177
+ const unsupported = [];
178
+ if (req.previous_response_id !== undefined && req.previous_response_id !== null)
179
+ unsupported.push('previous_response_id');
180
+ const systemParts = [];
181
+ if (typeof req.instructions === 'string' && req.instructions.length > 0)
182
+ systemParts.push(req.instructions);
183
+ const messages = [];
184
+ // Tools declared inside the input (`additional_tools` items — what Codex
185
+ // CLI sends for models it has metadata for) join the top-level list.
186
+ const extraTools = [];
187
+ const push = (role, parts) => {
188
+ if (parts.length === 0)
189
+ return;
190
+ const last = messages[messages.length - 1];
191
+ if (last && last.role === role)
192
+ last.content.push(...parts);
193
+ else
194
+ messages.push({ role, content: parts });
195
+ };
196
+ const input = req.input;
197
+ if (typeof input === 'string') {
198
+ push('user', [textPart(input)]);
199
+ }
200
+ else if (Array.isArray(input)) {
201
+ for (const raw of input) {
202
+ const type = typeof raw?.type === 'string' ? raw.type : (raw?.role ? 'message' : '');
203
+ switch (type) {
204
+ case 'message': {
205
+ const role = String(raw.role ?? 'user');
206
+ if (role === 'system' || role === 'developer') {
207
+ const text = contentParts(raw.content, warnings).filter((p) => p.type === 'text').map((p) => p.text).join('\n');
208
+ if (text)
209
+ systemParts.push(text);
210
+ }
211
+ else {
212
+ push(role === 'assistant' ? 'assistant' : 'user', contentParts(raw.content, warnings));
213
+ }
214
+ break;
215
+ }
216
+ case 'function_call': {
217
+ const name = String(raw.name ?? '');
218
+ const callId = String(raw.call_id ?? raw.id ?? '');
219
+ if (!name || !callId) {
220
+ warnings.push('function_call without name/call_id dropped');
221
+ break;
222
+ }
223
+ push('assistant', [{ type: 'tool_use', id: callId, name, input: parseArguments(raw.arguments, name, warnings) }]);
224
+ break;
225
+ }
226
+ case 'function_call_output': {
227
+ const callId = String(raw.call_id ?? '');
228
+ if (!callId) {
229
+ warnings.push('function_call_output without call_id dropped');
230
+ break;
231
+ }
232
+ push('user', [{ type: 'tool_result', tool_use_id: callId, content: toolOutput(raw.output, warnings) }]);
233
+ break;
234
+ }
235
+ case 'reasoning':
236
+ break; // OpenAI's encrypted reasoning; the pool has its own
237
+ case 'additional_tools':
238
+ if (Array.isArray(raw.tools))
239
+ extraTools.push(...raw.tools);
240
+ break;
241
+ default:
242
+ warnings.push(`input item ${type || '(untyped)'} dropped`);
243
+ }
244
+ }
245
+ }
246
+ else {
247
+ throw new ResponsesRequestError('input must be a string or an array of items', 'input');
248
+ }
249
+ if (messages.length === 0)
250
+ throw new ResponsesRequestError('input carries no user or assistant content', 'input');
251
+ if (messages[0].role !== 'user')
252
+ messages.unshift({ role: 'user', content: [textPart('(continue)')] });
253
+ const body = { model, messages };
254
+ if (systemParts.length > 0)
255
+ body.system = systemParts.join('\n\n');
256
+ const maxOut = typeof req.max_output_tokens === 'number' && req.max_output_tokens > 0 ? Math.floor(req.max_output_tokens) : DEFAULT_MAX_OUTPUT_TOKENS;
257
+ body.max_tokens = maxOut;
258
+ if (req.stream === true)
259
+ body.stream = true;
260
+ if (typeof req.temperature === 'number')
261
+ body.temperature = req.temperature;
262
+ if (typeof req.top_p === 'number')
263
+ body.top_p = req.top_p;
264
+ const tools = translateTools([...(Array.isArray(req.tools) ? req.tools : []), ...extraTools], warnings);
265
+ if (tools.length > 0)
266
+ body.tools = tools;
267
+ const choice = translateToolChoice(req.tool_choice, req.parallel_tool_calls);
268
+ if (choice && tools.length > 0)
269
+ body.tool_choice = choice;
270
+ const effort = req.reasoning?.effort;
271
+ if (typeof effort === 'string' && EFFORTS[effort]) {
272
+ // dario's own per-request effort spelling (model:high), parsed on the
273
+ // Claude path and the codex path alike — see parseEffortSuffix.
274
+ body.model = `${model}:${EFFORTS[effort]}`;
275
+ }
276
+ const text = req.text;
277
+ if (text?.format && text.format.type && text.format.type !== 'text')
278
+ warnings.push(`text.format ${text.format.type} dropped (structured output is not translated on this route)`);
279
+ return { body, warnings, unsupported };
280
+ }
281
+ /** The 400 the Claude pool answers for a Responses feature it cannot serve. */
282
+ export function unsupportedOnClaudeError(field) {
283
+ const why = field === 'previous_response_id'
284
+ ? 'previous_response_id cannot be served from the Claude pool: dario is stateless there — send the full input each turn (store: false), or use a ChatGPT-subscription model, which passes the request through to a backend that keeps state'
285
+ : `${field} cannot be served from the Claude pool`;
286
+ return { error: { message: why, type: 'invalid_request_error', param: field, code: null } };
287
+ }
288
+ function responsesUsage(u) {
289
+ const cached = u?.cache_read_input_tokens ?? 0;
290
+ const input = (u?.input_tokens ?? 0) + cached + (u?.cache_creation_input_tokens ?? 0);
291
+ const output = u?.output_tokens ?? 0;
292
+ return {
293
+ input_tokens: input,
294
+ input_tokens_details: { cached_tokens: cached },
295
+ output_tokens: output,
296
+ output_tokens_details: { reasoning_tokens: 0 },
297
+ total_tokens: input + output,
298
+ };
299
+ }
300
+ function statusFor(stopReason) {
301
+ if (stopReason === 'max_tokens')
302
+ return { status: 'incomplete', incomplete_details: { reason: 'max_output_tokens' } };
303
+ return { status: 'completed', incomplete_details: null };
304
+ }
305
+ /** Anthropic message id `msg_01AB…` → a Responses response id. */
306
+ function responseIdFrom(messageId) {
307
+ const raw = typeof messageId === 'string' && messageId.length > 0 ? messageId.replace(/^msg_/, '') : Math.random().toString(36).slice(2);
308
+ return `resp_${raw}`;
309
+ }
310
+ function baseResponse(id, createdAt, model) {
311
+ return {
312
+ id, object: 'response', created_at: createdAt, status: 'in_progress', error: null, incomplete_details: null,
313
+ model, output: [], parallel_tool_calls: true, tool_choice: 'auto', tools: [], store: false, usage: null,
314
+ };
315
+ }
316
+ /** A buffered Anthropic message → a Responses response object. */
317
+ export function anthropicMessageToResponses(msg, createdAt = Math.floor(Date.now() / 1000)) {
318
+ const id = responseIdFrom(msg.id);
319
+ const model = typeof msg.model === 'string' ? msg.model : '';
320
+ const output = [];
321
+ let n = 0;
322
+ for (const block of (Array.isArray(msg.content) ? msg.content : [])) {
323
+ n++;
324
+ switch (block.type) {
325
+ case 'text':
326
+ output.push({ id: `msg_${id.slice(5)}_${n}`, type: 'message', status: 'completed', role: 'assistant', content: [{ type: 'output_text', text: String(block.text ?? ''), annotations: [] }] });
327
+ break;
328
+ case 'tool_use':
329
+ output.push({ id: `fc_${id.slice(5)}_${n}`, type: 'function_call', status: 'completed', call_id: String(block.id ?? ''), name: String(block.name ?? ''), arguments: JSON.stringify(block.input ?? {}) });
330
+ break;
331
+ case 'thinking': {
332
+ const t = typeof block.thinking === 'string' ? block.thinking : '';
333
+ output.push({ id: `rs_${id.slice(5)}_${n}`, type: 'reasoning', summary: t ? [{ type: 'summary_text', text: t }] : [] });
334
+ break;
335
+ }
336
+ default: break; // redacted_thinking, server tool blocks: nothing a Responses client can use
337
+ }
338
+ }
339
+ const st = statusFor(msg.stop_reason);
340
+ return { ...baseResponse(id, createdAt, model), status: st.status, incomplete_details: st.incomplete_details, output, usage: responsesUsage(msg.usage) };
341
+ }
342
+ /** An Anthropic error body → the OpenAI error envelope. */
343
+ export function anthropicErrorToResponses(body) {
344
+ // Anthropic: { type: 'error', error: { type, message } }. dario's own
345
+ // pre-upstream errors: { error: 'Proxy error', message: '…' }.
346
+ const e = body.error;
347
+ if (typeof e === 'string')
348
+ return { error: { message: typeof body.message === 'string' ? body.message : e, type: 'api_error', code: null, param: null } };
349
+ const err = (e ?? {});
350
+ return { error: { message: err.message ?? 'upstream error', type: err.type ?? 'api_error', code: err.type ?? null, param: null } };
351
+ }
352
+ /**
353
+ * Anthropic SSE → Responses SSE, incrementally. One instance per response.
354
+ * Comments (`: dario continuation …`) ride through untouched; `ping` is
355
+ * dropped; `error` becomes `response.failed` + an `error` event.
356
+ */
357
+ export class ResponsesOutStream {
358
+ seq = 0;
359
+ id = '';
360
+ createdAt = Math.floor(Date.now() / 1000);
361
+ model;
362
+ started = false;
363
+ output = [];
364
+ open = new Map();
365
+ usage = {};
366
+ stopReason = null;
367
+ done = false;
368
+ splitter = new SseFrameSplitter();
369
+ constructor(requestModel) { this.model = requestModel; }
370
+ ev(type, payload) {
371
+ return `event: ${type}\ndata: ${JSON.stringify({ type, sequence_number: this.seq++, ...payload })}\n\n`;
372
+ }
373
+ snapshot(status) {
374
+ return { ...baseResponse(this.id, this.createdAt, this.model), status, output: this.output.map((o) => ({ ...o })) };
375
+ }
376
+ feed(chunk) {
377
+ let out = '';
378
+ for (const f of this.splitter.feed(chunk))
379
+ out += this.frame(f);
380
+ return out;
381
+ }
382
+ /** Whatever is still buffered (a partial frame) — nothing a Responses client can use. */
383
+ end() { this.splitter.flush(); return ''; }
384
+ get finished() { return this.done; }
385
+ frame(f) {
386
+ if (f.comment)
387
+ return f.raw;
388
+ const d = f.data;
389
+ if (!d)
390
+ return '';
391
+ switch (d.type) {
392
+ case 'ping': return '';
393
+ case 'message_start': {
394
+ const m = (d.message ?? {});
395
+ this.id = responseIdFrom(m.id);
396
+ if (typeof m.model === 'string' && m.model)
397
+ this.model = m.model;
398
+ this.usage = { ...m.usage };
399
+ this.started = true;
400
+ return this.ev('response.created', { response: this.snapshot('in_progress') }) + this.ev('response.in_progress', { response: this.snapshot('in_progress') });
401
+ }
402
+ case 'content_block_start': return this.blockStart(d);
403
+ case 'content_block_delta': return this.blockDelta(d);
404
+ case 'content_block_stop': return this.blockStop(d);
405
+ case 'message_delta': {
406
+ const delta = d.delta;
407
+ if (delta?.stop_reason !== undefined)
408
+ this.stopReason = delta.stop_reason;
409
+ const u = d.usage;
410
+ if (u)
411
+ this.usage = { ...this.usage, ...u };
412
+ return '';
413
+ }
414
+ case 'message_stop': {
415
+ if (!this.started)
416
+ return '';
417
+ this.done = true;
418
+ const st = statusFor(this.stopReason);
419
+ const response = { ...this.snapshot(st.status), incomplete_details: st.incomplete_details, usage: responsesUsage(this.usage) };
420
+ return this.ev('response.completed', { response });
421
+ }
422
+ case 'error': {
423
+ const err = (d.error ?? {});
424
+ this.done = true;
425
+ if (!this.started) {
426
+ this.id = responseIdFrom(undefined);
427
+ this.started = true;
428
+ }
429
+ const response = { ...this.snapshot('failed'), error: { code: err.type ?? 'server_error', message: err.message ?? 'upstream error' } };
430
+ return this.ev('response.failed', { response }) + this.ev('error', { code: err.type ?? 'server_error', message: err.message ?? 'upstream error', param: null });
431
+ }
432
+ default: return '';
433
+ }
434
+ }
435
+ blockStart(d) {
436
+ const idx = typeof d.index === 'number' ? d.index : this.output.length;
437
+ const cb = (d.content_block ?? {});
438
+ const n = this.output.length + 1;
439
+ const outputIndex = this.output.length;
440
+ switch (cb.type) {
441
+ case 'text': {
442
+ const item = { id: `msg_${this.id.slice(5)}_${n}`, type: 'message', status: 'in_progress', role: 'assistant', content: [] };
443
+ this.output.push(item);
444
+ this.open.set(idx, { item, kind: 'message', text: typeof cb.text === 'string' ? cb.text : '', summaryOpened: false });
445
+ return this.ev('response.output_item.added', { output_index: outputIndex, item: { ...item } })
446
+ + this.ev('response.content_part.added', { item_id: item.id, output_index: outputIndex, content_index: 0, part: { type: 'output_text', text: '', annotations: [] } });
447
+ }
448
+ case 'tool_use': {
449
+ const item = { id: `fc_${this.id.slice(5)}_${n}`, type: 'function_call', status: 'in_progress', call_id: String(cb.id ?? ''), name: String(cb.name ?? ''), arguments: '' };
450
+ this.output.push(item);
451
+ this.open.set(idx, { item, kind: 'function_call', text: '', summaryOpened: false });
452
+ return this.ev('response.output_item.added', { output_index: outputIndex, item: { ...item } });
453
+ }
454
+ case 'thinking': {
455
+ const item = { id: `rs_${this.id.slice(5)}_${n}`, type: 'reasoning', summary: [] };
456
+ this.output.push(item);
457
+ this.open.set(idx, { item, kind: 'reasoning', text: '', summaryOpened: false });
458
+ return this.ev('response.output_item.added', { output_index: outputIndex, item: { ...item } });
459
+ }
460
+ default:
461
+ return ''; // redacted_thinking, server tools: not surfaced
462
+ }
463
+ }
464
+ blockDelta(d) {
465
+ const idx = typeof d.index === 'number' ? d.index : -1;
466
+ const o = this.open.get(idx);
467
+ if (!o)
468
+ return '';
469
+ const delta = (d.delta ?? {});
470
+ const outputIndex = this.output.indexOf(o.item);
471
+ if (o.kind === 'message' && delta.type === 'text_delta' && typeof delta.text === 'string') {
472
+ o.text += delta.text;
473
+ return this.ev('response.output_text.delta', { item_id: o.item.id, output_index: outputIndex, content_index: 0, delta: delta.text });
474
+ }
475
+ if (o.kind === 'function_call' && delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
476
+ o.text += delta.partial_json;
477
+ return this.ev('response.function_call_arguments.delta', { item_id: o.item.id, output_index: outputIndex, delta: delta.partial_json });
478
+ }
479
+ if (o.kind === 'reasoning' && delta.type === 'thinking_delta' && typeof delta.thinking === 'string' && delta.thinking.length > 0) {
480
+ let out = '';
481
+ if (!o.summaryOpened) {
482
+ o.summaryOpened = true;
483
+ out += this.ev('response.reasoning_summary_part.added', { item_id: o.item.id, output_index: outputIndex, summary_index: 0, part: { type: 'summary_text', text: '' } });
484
+ }
485
+ o.text += delta.thinking;
486
+ return out + this.ev('response.reasoning_summary_text.delta', { item_id: o.item.id, output_index: outputIndex, summary_index: 0, delta: delta.thinking });
487
+ }
488
+ return '';
489
+ }
490
+ blockStop(d) {
491
+ const idx = typeof d.index === 'number' ? d.index : -1;
492
+ const o = this.open.get(idx);
493
+ if (!o)
494
+ return '';
495
+ this.open.delete(idx);
496
+ const outputIndex = this.output.indexOf(o.item);
497
+ switch (o.kind) {
498
+ case 'message': {
499
+ const part = { type: 'output_text', text: o.text, annotations: [] };
500
+ o.item.status = 'completed';
501
+ o.item.content = [part];
502
+ return this.ev('response.output_text.done', { item_id: o.item.id, output_index: outputIndex, content_index: 0, text: o.text })
503
+ + this.ev('response.content_part.done', { item_id: o.item.id, output_index: outputIndex, content_index: 0, part })
504
+ + this.ev('response.output_item.done', { output_index: outputIndex, item: { ...o.item } });
505
+ }
506
+ case 'function_call': {
507
+ const args = o.text.trim() === '' ? '{}' : o.text;
508
+ o.item.arguments = args;
509
+ o.item.status = 'completed';
510
+ return this.ev('response.function_call_arguments.done', { item_id: o.item.id, output_index: outputIndex, arguments: args })
511
+ + this.ev('response.output_item.done', { output_index: outputIndex, item: { ...o.item } });
512
+ }
513
+ case 'reasoning': {
514
+ let out = '';
515
+ if (o.summaryOpened) {
516
+ out += this.ev('response.reasoning_summary_text.done', { item_id: o.item.id, output_index: outputIndex, summary_index: 0, text: o.text })
517
+ + this.ev('response.reasoning_summary_part.done', { item_id: o.item.id, output_index: outputIndex, summary_index: 0, part: { type: 'summary_text', text: o.text } });
518
+ o.item.summary = [{ type: 'summary_text', text: o.text }];
519
+ }
520
+ return out + this.ev('response.output_item.done', { output_index: outputIndex, item: { ...o.item } });
521
+ }
522
+ }
523
+ }
524
+ }
525
+ // ---------------------------------------------------------------------------
526
+ // The write boundary
527
+ // ---------------------------------------------------------------------------
528
+ /**
529
+ * Everything dario writes to a Responses client passes through here. The
530
+ * first bytes decide the mode: SSE frames are translated as they arrive; a
531
+ * JSON body (a buffered message, or an error) is held and translated at end().
532
+ */
533
+ export class ResponsesOut {
534
+ mode = 'undecided';
535
+ json = '';
536
+ stream;
537
+ decoder = new TextDecoder();
538
+ constructor(requestModel) { this.stream = new ResponsesOutStream(requestModel); }
539
+ write(chunk) {
540
+ const text = typeof chunk === 'string' ? chunk : this.decoder.decode(chunk, { stream: true });
541
+ if (this.mode === 'undecided') {
542
+ const head = text.trimStart();
543
+ if (head.length === 0)
544
+ return '';
545
+ this.mode = head.startsWith('event:') || head.startsWith('data:') || head.startsWith(':') ? 'sse' : 'json';
546
+ }
547
+ if (this.mode === 'sse')
548
+ return this.stream.feed(text);
549
+ this.json += text;
550
+ return '';
551
+ }
552
+ end() {
553
+ if (this.mode === 'sse')
554
+ return this.stream.end();
555
+ if (this.mode === 'json') {
556
+ this.json += this.decoder.decode();
557
+ try {
558
+ const parsed = JSON.parse(this.json);
559
+ if (parsed.type === 'message' && Array.isArray(parsed.content))
560
+ return JSON.stringify(anthropicMessageToResponses(parsed));
561
+ // Already the OpenAI envelope (dario answered this route in the
562
+ // client's shape itself): leave it alone.
563
+ if (parsed.type !== 'error' && parsed.error && typeof parsed.error === 'object' && 'param' in parsed.error)
564
+ return this.json;
565
+ if (parsed.type === 'error' || parsed.error)
566
+ return JSON.stringify(anthropicErrorToResponses(parsed));
567
+ return this.json;
568
+ }
569
+ catch {
570
+ return this.json;
571
+ }
572
+ }
573
+ return '';
574
+ }
575
+ }
576
+ /**
577
+ * The ServerResponse a Responses client is served through: every write is
578
+ * translated, everything else reaches the real response untouched (headers,
579
+ * events, `writableEnded`, `destroyed`). Bound methods, so `res.on('close')`
580
+ * and friends keep working on the real object.
581
+ */
582
+ export function wrapResponsesClient(res, out) {
583
+ const target = res;
584
+ return new Proxy(res, {
585
+ get(_t, prop) {
586
+ if (prop === 'write') {
587
+ return (chunk, ...rest) => {
588
+ const translated = out.write(chunk);
589
+ const cb = rest.find((r) => typeof r === 'function');
590
+ if (translated.length === 0) {
591
+ cb?.();
592
+ return true;
593
+ }
594
+ return target.write(translated, ...(typeof rest[0] === 'string' ? rest : rest.filter((r) => typeof r === 'function')));
595
+ };
596
+ }
597
+ if (prop === 'end') {
598
+ return (chunk, ...rest) => {
599
+ if (chunk !== undefined && chunk !== null && typeof chunk !== 'function') {
600
+ const t = out.write(chunk);
601
+ if (t.length > 0)
602
+ target.write(t);
603
+ }
604
+ const tail = out.end();
605
+ if (tail.length > 0)
606
+ target.write(tail);
607
+ return target.end(...(typeof chunk === 'function' ? [chunk] : rest.filter((r) => typeof r === 'function')));
608
+ };
609
+ }
610
+ // Getters run with `this` = the real response, never the proxy: Node's
611
+ // internals read symbol-keyed state off `this`.
612
+ const v = Reflect.get(target, prop, target);
613
+ return typeof v === 'function' ? v.bind(target) : v;
614
+ },
615
+ });
616
+ }
@@ -1,5 +1,11 @@
1
1
  # Drift monitor
2
2
 
3
+ > **The feed:** every change the watcher has ever observed, as a page with
4
+ > RSS and JSON Feed — <https://askalf.github.io/dario/drift-feed/>. Rebuilt
5
+ > from git history on every template change (`scripts/drift-feed.mjs`,
6
+ > `.github/workflows/drift-feed.yml`); a "nothing changed on the wire" line is
7
+ > a Claude Code release the watcher checked and found identical.
8
+
3
9
  Dario's bundled CC template (`src/cc-template-data.json`) is the wire-shape
4
10
  fallback the proxy uses when it can't fingerprint a live CC install. For that
5
11
  fallback to be honest, the bundle has to keep up with what real CC is actually