@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.
- package/README.md +23 -3
- package/dist/codex-backend.d.ts +14 -0
- package/dist/codex-backend.js +142 -0
- package/dist/doctor-core.d.ts +15 -0
- package/dist/doctor-core.js +26 -0
- package/dist/midstream.d.ts +28 -0
- package/dist/midstream.js +56 -0
- package/dist/proxy.js +103 -16
- package/dist/responses-inbound.d.ts +108 -0
- package/dist/responses-inbound.js +616 -0
- package/docs/drift-monitor.md +6 -0
- package/docs/integrations/codex-cli.md +94 -0
- package/docs/midstream-continuation.md +26 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/docs/drift-monitor.md
CHANGED
|
@@ -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
|