@askalf/dario 5.5.85 → 5.5.87
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 +1 -1
- package/dist/anthropic-responses-translate.d.ts +522 -0
- package/dist/anthropic-responses-translate.js +839 -0
- package/dist/codex-backend.d.ts +34 -2
- package/dist/codex-backend.js +155 -20
- package/dist/provider-adapter.d.ts +11 -4
- package/dist/provider-adapter.js +11 -6
- package/dist/proxy.js +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic Messages ⇄ OpenAI Responses translator, ported into dario so that a
|
|
3
|
+
* ChatGPT-subscription (codex) account can serve Anthropic-shape `/v1/messages`
|
|
4
|
+
* requests. It was developed and live-probe-validated outside this repo, then
|
|
5
|
+
* copied here with no change to any translation logic. The only port edits: the
|
|
6
|
+
* Anthropic-side types and the reasoning-effort thresholds it used to import
|
|
7
|
+
* from a sibling module are inlined below, making this file self-contained.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Anthropic Messages ⇄ OpenAI *Responses API* translation.
|
|
11
|
+
*
|
|
12
|
+
* Targets the Responses API (not Chat Completions): the ChatGPT Codex
|
|
13
|
+
* backend speaks Responses, and reasoning models reject tools on
|
|
14
|
+
* chat/completions. This
|
|
15
|
+
* module targets the newer `/v1/responses` shape, which is REQUIRED for
|
|
16
|
+
* the reasoning-model + function-tools combination: gpt-5.6-sol (and the
|
|
17
|
+
* o-series) REJECT function tools together with reasoning on
|
|
18
|
+
* `/chat/completions`, but accept both on `/responses`. That combination
|
|
19
|
+
* is exactly Claude Code's case — CC always sends its 30+ tools and often
|
|
20
|
+
* has thinking enabled — so this is the path dario 5.1 needs to drive a
|
|
21
|
+
* reasoning model as a Claude backend.
|
|
22
|
+
*
|
|
23
|
+
* Pure data transforms — no network, no fs, no timers. A later wiring
|
|
24
|
+
* phase will translate an inbound Messages request with
|
|
25
|
+
* `anthropicToResponsesRequest`, POST it to `{baseUrl}/responses`, and
|
|
26
|
+
* translate the reply back with `responsesToAnthropicResponse`
|
|
27
|
+
* (non-streaming) — streaming is deferred (see the P0.6 stub below).
|
|
28
|
+
*
|
|
29
|
+
* The Responses shape is item-array based and sits closer to Anthropic's
|
|
30
|
+
* block model than chat completions does. Field names CONFIRMED against
|
|
31
|
+
* the OpenAI SDK type sources (openai-python `types/responses/*`); the
|
|
32
|
+
* shapes that differ from the chat-completions translator are called out
|
|
33
|
+
* where they occur:
|
|
34
|
+
*
|
|
35
|
+
* - system → top-level `instructions` (a string), NOT a role message.
|
|
36
|
+
* - messages → `input[]` items. User text/images become one message
|
|
37
|
+
* item with `input_text` / `input_image` parts (image_url is a bare
|
|
38
|
+
* string here, not `{url}`). Assistant text replays as a message item
|
|
39
|
+
* with a plain string. Assistant tool_use blocks become top-level
|
|
40
|
+
* `function_call` items; user tool_result blocks become top-level
|
|
41
|
+
* `function_call_output` items. Anthropic `tool_use_id` ↔ Responses
|
|
42
|
+
* `call_id` threads the two.
|
|
43
|
+
* - tools → FLATTENED function tools `{type,name,description,
|
|
44
|
+
* parameters}` (chat nests these under `.function`).
|
|
45
|
+
* - tool_choice forced form → FLATTENED `{type:'function', name}`
|
|
46
|
+
* (chat uses `{type:'function', function:{name}}`).
|
|
47
|
+
* - thinking → `reasoning:{effort}` (+ `summary:'auto'` so a reasoning
|
|
48
|
+
* summary comes back and can round-trip to a thinking block). Unlike
|
|
49
|
+
* chat completions, reasoning + tools together is ALLOWED here — the
|
|
50
|
+
* whole reason this module exists.
|
|
51
|
+
* - max_tokens → `max_output_tokens`; `store:false` (stateless — dario
|
|
52
|
+
* keeps no server-side conversation).
|
|
53
|
+
*
|
|
54
|
+
* Deliberate lossy edges (documented at the relevant function):
|
|
55
|
+
* - `cache_control` is dropped silently (no Responses analog).
|
|
56
|
+
* - Reasoning models reject sampling params: `temperature`/`top_p` are
|
|
57
|
+
* omitted whenever reasoning is enabled.
|
|
58
|
+
* - assistant `thinking` / `redacted_thinking` blocks are dropped from
|
|
59
|
+
* the OUTBOUND request (no faithful inbound slot; encrypted_content
|
|
60
|
+
* round-tripping is a later concern). Reasoning that the model
|
|
61
|
+
* returns IS surfaced (as thinking blocks) on the response side.
|
|
62
|
+
* - Anthropic server tools (entries without `input_schema`) are skipped.
|
|
63
|
+
* - images inside `tool_result` content are dropped
|
|
64
|
+
* (`function_call_output.output` is a string); sibling text survives.
|
|
65
|
+
*/
|
|
66
|
+
/**
|
|
67
|
+
* Thinking-budget → reasoning_effort thresholds. Claude Code's thinking
|
|
68
|
+
* tiers land at ~4k ("think"), ~10k ("think hard") and 31999
|
|
69
|
+
* ("ultrathink") budget_tokens, so the cut points sit between those
|
|
70
|
+
* tiers:
|
|
71
|
+
*
|
|
72
|
+
* budget_tokens ≤ 4096 → 'low'
|
|
73
|
+
* 4096 < budget_tokens ≤ 16384 → 'medium'
|
|
74
|
+
* budget_tokens > 16384 → 'high'
|
|
75
|
+
*
|
|
76
|
+
* `thinking` absent, disabled, or without a positive budget → no
|
|
77
|
+
* `reasoning_effort` in the output (upstream default applies).
|
|
78
|
+
*/
|
|
79
|
+
export const REASONING_EFFORT_LOW_MAX = 4096;
|
|
80
|
+
export const REASONING_EFFORT_MEDIUM_MAX = 16384;
|
|
81
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
82
|
+
// Small local helpers.
|
|
83
|
+
// Thresholds are IMPORTED so the two translators stay in lock-step.
|
|
84
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
85
|
+
/** thinking → reasoning effort, at the same cut points as the chat path. */
|
|
86
|
+
function thinkingToReasoningEffort(thinking) {
|
|
87
|
+
if (!thinking || thinking.type !== 'enabled')
|
|
88
|
+
return undefined;
|
|
89
|
+
const budget = thinking.budget_tokens;
|
|
90
|
+
if (typeof budget !== 'number' || !Number.isFinite(budget) || budget <= 0) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
if (budget <= REASONING_EFFORT_LOW_MAX)
|
|
94
|
+
return 'low';
|
|
95
|
+
if (budget <= REASONING_EFFORT_MEDIUM_MAX)
|
|
96
|
+
return 'medium';
|
|
97
|
+
return 'high';
|
|
98
|
+
}
|
|
99
|
+
/** Flatten a Messages `system` field (string or text-block array). */
|
|
100
|
+
function flattenSystem(system) {
|
|
101
|
+
if (typeof system === 'string')
|
|
102
|
+
return system;
|
|
103
|
+
if (!Array.isArray(system))
|
|
104
|
+
return '';
|
|
105
|
+
const parts = [];
|
|
106
|
+
for (const block of system) {
|
|
107
|
+
// cache_control dropped silently — no Responses analog.
|
|
108
|
+
if (block && block.type === 'text' && typeof block.text === 'string') {
|
|
109
|
+
parts.push(block.text);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return parts.join('\n\n');
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Stringify a tool_result's content for a `function_call_output.output`,
|
|
116
|
+
* which carries a string. Text blocks join with newlines; images/other
|
|
117
|
+
* non-text blocks are dropped unless there is no text at all, in which
|
|
118
|
+
* case the raw array is JSON-stringified so the data survives.
|
|
119
|
+
*/
|
|
120
|
+
function toolResultContentToString(content) {
|
|
121
|
+
if (content === undefined || content === null)
|
|
122
|
+
return '';
|
|
123
|
+
if (typeof content === 'string')
|
|
124
|
+
return content;
|
|
125
|
+
if (!Array.isArray(content))
|
|
126
|
+
return JSON.stringify(content);
|
|
127
|
+
const texts = [];
|
|
128
|
+
for (const block of content) {
|
|
129
|
+
if (block && block.type === 'text' && typeof block.text === 'string') {
|
|
130
|
+
texts.push(block.text);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (texts.length > 0)
|
|
134
|
+
return texts.join('\n');
|
|
135
|
+
return content.length > 0 ? JSON.stringify(content) : '';
|
|
136
|
+
}
|
|
137
|
+
function imageBlockToResponsesPart(block) {
|
|
138
|
+
const source = block.source;
|
|
139
|
+
if (!source || typeof source !== 'object')
|
|
140
|
+
return null;
|
|
141
|
+
if (source.type === 'base64' && typeof source.data === 'string') {
|
|
142
|
+
const media = typeof source.media_type === 'string' ? source.media_type : 'image/png';
|
|
143
|
+
return { type: 'input_image', image_url: `data:${media};base64,${source.data}` };
|
|
144
|
+
}
|
|
145
|
+
if (source.type === 'url' && typeof source.url === 'string') {
|
|
146
|
+
return { type: 'input_image', image_url: source.url };
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
/** JSON-stringify tool_use input defensively (circular → `{}`). */
|
|
151
|
+
function stringifyToolInput(input) {
|
|
152
|
+
try {
|
|
153
|
+
return JSON.stringify(input ?? {});
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return '{}';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Parse function-call arguments defensively. Upstreams occasionally emit
|
|
161
|
+
* truncated or malformed JSON (notably on truncation); rather than throw
|
|
162
|
+
* mid-response, unparseable arguments degrade to `{}` — the client's own
|
|
163
|
+
* tool validation reports the missing fields.
|
|
164
|
+
*/
|
|
165
|
+
function safeParseArguments(raw) {
|
|
166
|
+
if (typeof raw !== 'string' || raw.trim() === '')
|
|
167
|
+
return {};
|
|
168
|
+
try {
|
|
169
|
+
const parsed = JSON.parse(raw);
|
|
170
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
171
|
+
return parsed;
|
|
172
|
+
}
|
|
173
|
+
return {};
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return {};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function anthropicMessageId(id) {
|
|
180
|
+
if (typeof id === 'string' && id.length > 0) {
|
|
181
|
+
return id.startsWith('msg_') ? id : `msg_${id}`;
|
|
182
|
+
}
|
|
183
|
+
return 'msg_responses_translate';
|
|
184
|
+
}
|
|
185
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
186
|
+
// Request translation: Anthropic → Responses
|
|
187
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
188
|
+
/**
|
|
189
|
+
* Translate one Anthropic user message (block form) into Responses input
|
|
190
|
+
* items. tool_result blocks become top-level `function_call_output`
|
|
191
|
+
* items and are emitted FIRST — they answer the previous assistant
|
|
192
|
+
* turn's `function_call`s and must precede any fresh user content.
|
|
193
|
+
* Remaining text/image blocks collapse into one user message item (a
|
|
194
|
+
* plain string when it is a single text block, else `input_text` /
|
|
195
|
+
* `input_image` parts).
|
|
196
|
+
*/
|
|
197
|
+
function translateUserBlocks(blocks) {
|
|
198
|
+
const out = [];
|
|
199
|
+
const parts = [];
|
|
200
|
+
for (const block of blocks) {
|
|
201
|
+
if (!block || typeof block !== 'object')
|
|
202
|
+
continue;
|
|
203
|
+
if (block.type === 'tool_result') {
|
|
204
|
+
const tr = block;
|
|
205
|
+
out.push({
|
|
206
|
+
type: 'function_call_output',
|
|
207
|
+
call_id: typeof tr.tool_use_id === 'string' ? tr.tool_use_id : '',
|
|
208
|
+
output: toolResultContentToString(tr.content),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
else if (block.type === 'text' && typeof block.text === 'string') {
|
|
212
|
+
parts.push({ type: 'input_text', text: block.text });
|
|
213
|
+
}
|
|
214
|
+
else if (block.type === 'image') {
|
|
215
|
+
const part = imageBlockToResponsesPart(block);
|
|
216
|
+
if (part)
|
|
217
|
+
parts.push(part);
|
|
218
|
+
}
|
|
219
|
+
// Unknown block types (document, search_result, …) are dropped.
|
|
220
|
+
}
|
|
221
|
+
const only = parts.length === 1 ? parts[0] : undefined;
|
|
222
|
+
if (only && only.type === 'input_text') {
|
|
223
|
+
out.push({ role: 'user', content: only.text });
|
|
224
|
+
}
|
|
225
|
+
else if (parts.length > 0) {
|
|
226
|
+
out.push({ role: 'user', content: parts });
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Translate one Anthropic assistant message (block form) into Responses
|
|
232
|
+
* input items: joined text becomes a single assistant message item (plain
|
|
233
|
+
* string); each tool_use becomes a top-level `function_call` item whose
|
|
234
|
+
* `call_id` mirrors the Anthropic tool_use `id`. thinking /
|
|
235
|
+
* redacted_thinking blocks are dropped — no faithful inbound slot.
|
|
236
|
+
*/
|
|
237
|
+
function translateAssistantBlocks(blocks) {
|
|
238
|
+
const out = [];
|
|
239
|
+
const texts = [];
|
|
240
|
+
const calls = [];
|
|
241
|
+
for (const block of blocks) {
|
|
242
|
+
if (!block || typeof block !== 'object')
|
|
243
|
+
continue;
|
|
244
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
245
|
+
texts.push(block.text);
|
|
246
|
+
}
|
|
247
|
+
else if (block.type === 'tool_use') {
|
|
248
|
+
const tu = block;
|
|
249
|
+
calls.push({
|
|
250
|
+
type: 'function_call',
|
|
251
|
+
call_id: typeof tu.id === 'string' ? tu.id : '',
|
|
252
|
+
name: typeof tu.name === 'string' ? tu.name : '',
|
|
253
|
+
arguments: stringifyToolInput(tu.input),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (texts.length > 0)
|
|
258
|
+
out.push({ role: 'assistant', content: texts.join('\n\n') });
|
|
259
|
+
for (const call of calls)
|
|
260
|
+
out.push(call);
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
function translateToolChoice(choice) {
|
|
264
|
+
if (!choice || typeof choice !== 'object')
|
|
265
|
+
return undefined;
|
|
266
|
+
switch (choice.type) {
|
|
267
|
+
case 'auto':
|
|
268
|
+
return 'auto';
|
|
269
|
+
case 'none':
|
|
270
|
+
return 'none';
|
|
271
|
+
case 'any':
|
|
272
|
+
return 'required';
|
|
273
|
+
case 'tool':
|
|
274
|
+
// Responses forced form is FLATTENED: {type:'function', name}.
|
|
275
|
+
return typeof choice.name === 'string'
|
|
276
|
+
? { type: 'function', name: choice.name }
|
|
277
|
+
: 'required';
|
|
278
|
+
default:
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Extra max_output_tokens reserved for reasoning tokens, by effort, so the
|
|
284
|
+
* client's intended visible-output budget survives on a reasoning model
|
|
285
|
+
* (max_output_tokens caps reasoning + output combined on the Responses API).
|
|
286
|
+
*/
|
|
287
|
+
export const REASONING_HEADROOM = { low: 12000, medium: 25000, high: 50000 };
|
|
288
|
+
/** gpt-5.x / o-series output ceiling (tokens). */
|
|
289
|
+
export const RESPONSES_MAX_OUTPUT_CAP = 128000;
|
|
290
|
+
/**
|
|
291
|
+
* Translate an Anthropic Messages request into an OpenAI Responses
|
|
292
|
+
* request body for `{baseUrl}/responses`.
|
|
293
|
+
*
|
|
294
|
+
* Anthropic `input_schema` is already JSON Schema, which is what the
|
|
295
|
+
* Responses `parameters` field expects — it passes through unchanged.
|
|
296
|
+
* Tool entries without an `input_schema` (Anthropic server tools such as
|
|
297
|
+
* `web_search_20250305`) have no function-tool equivalent and are
|
|
298
|
+
* skipped. `cache_control` is dropped wherever it appears.
|
|
299
|
+
*/
|
|
300
|
+
export function anthropicToResponsesRequest(body, targetModel, options = {}) {
|
|
301
|
+
const input = [];
|
|
302
|
+
for (const msg of Array.isArray(body.messages) ? body.messages : []) {
|
|
303
|
+
if (!msg || typeof msg !== 'object')
|
|
304
|
+
continue;
|
|
305
|
+
if (typeof msg.content === 'string') {
|
|
306
|
+
input.push({ role: msg.role === 'assistant' ? 'assistant' : 'user', content: msg.content });
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (!Array.isArray(msg.content))
|
|
310
|
+
continue;
|
|
311
|
+
if (msg.role === 'assistant') {
|
|
312
|
+
input.push(...translateAssistantBlocks(msg.content));
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
input.push(...translateUserBlocks(msg.content));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const out = {
|
|
319
|
+
model: targetModel,
|
|
320
|
+
input,
|
|
321
|
+
store: options.store ?? false,
|
|
322
|
+
};
|
|
323
|
+
const instructions = flattenSystem(body.system);
|
|
324
|
+
if (instructions.length > 0)
|
|
325
|
+
out.instructions = instructions;
|
|
326
|
+
if (Array.isArray(body.tools)) {
|
|
327
|
+
const tools = [];
|
|
328
|
+
for (const tool of body.tools) {
|
|
329
|
+
if (!tool || typeof tool.name !== 'string')
|
|
330
|
+
continue;
|
|
331
|
+
if (!tool.input_schema || typeof tool.input_schema !== 'object')
|
|
332
|
+
continue;
|
|
333
|
+
const fn = {
|
|
334
|
+
type: 'function',
|
|
335
|
+
name: tool.name,
|
|
336
|
+
parameters: tool.input_schema,
|
|
337
|
+
};
|
|
338
|
+
if (typeof tool.description === 'string')
|
|
339
|
+
fn.description = tool.description;
|
|
340
|
+
tools.push(fn);
|
|
341
|
+
}
|
|
342
|
+
if (tools.length > 0)
|
|
343
|
+
out.tools = tools;
|
|
344
|
+
}
|
|
345
|
+
const toolChoice = translateToolChoice(body.tool_choice);
|
|
346
|
+
if (toolChoice !== undefined && out.tools)
|
|
347
|
+
out.tool_choice = toolChoice;
|
|
348
|
+
if (body.tool_choice?.disable_parallel_tool_use === true && out.tools) {
|
|
349
|
+
out.parallel_tool_calls = false;
|
|
350
|
+
}
|
|
351
|
+
const effort = thinkingToReasoningEffort(body.thinking);
|
|
352
|
+
if (effort) {
|
|
353
|
+
out.reasoning = { effort };
|
|
354
|
+
const summary = options.reasoningSummary === undefined ? 'auto' : options.reasoningSummary;
|
|
355
|
+
if (summary !== null)
|
|
356
|
+
out.reasoning.summary = summary;
|
|
357
|
+
}
|
|
358
|
+
if (typeof body.max_tokens === 'number' && body.max_tokens > 0) {
|
|
359
|
+
// CRITICAL: on the Responses API, max_output_tokens caps reasoning +
|
|
360
|
+
// visible output COMBINED, but the client's max_tokens is its intended
|
|
361
|
+
// *visible-output* budget. With reasoning on, the model can spend the
|
|
362
|
+
// whole budget thinking and return status `incomplete` with NO message —
|
|
363
|
+
// the client then renders an empty turn. Reserve reasoning headroom so
|
|
364
|
+
// the client's output budget survives, capped at the model output ceiling.
|
|
365
|
+
const headroom = effort ? REASONING_HEADROOM[effort] : 0;
|
|
366
|
+
out.max_output_tokens = Math.min(RESPONSES_MAX_OUTPUT_CAP, body.max_tokens + headroom);
|
|
367
|
+
}
|
|
368
|
+
// Reasoning models reject sampling params — only forward temperature /
|
|
369
|
+
// top_p when reasoning is OFF. (With reasoning on, dropping them is
|
|
370
|
+
// what keeps the request acceptable at all.)
|
|
371
|
+
if (!effort) {
|
|
372
|
+
if (typeof body.temperature === 'number')
|
|
373
|
+
out.temperature = body.temperature;
|
|
374
|
+
if (typeof body.top_p === 'number')
|
|
375
|
+
out.top_p = body.top_p;
|
|
376
|
+
}
|
|
377
|
+
if (body.stream === true)
|
|
378
|
+
out.stream = true;
|
|
379
|
+
return out;
|
|
380
|
+
}
|
|
381
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
382
|
+
// Response translation: Responses → Anthropic (non-streaming)
|
|
383
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
384
|
+
/** Extract displayable reasoning text: summary_text first, else reasoning_text. */
|
|
385
|
+
function reasoningItemText(item) {
|
|
386
|
+
const parts = [];
|
|
387
|
+
if (Array.isArray(item.summary)) {
|
|
388
|
+
for (const s of item.summary) {
|
|
389
|
+
if (s && typeof s === 'object' && s.type === 'summary_text' && typeof s.text === 'string') {
|
|
390
|
+
parts.push(s.text);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (parts.length === 0 && Array.isArray(item.content)) {
|
|
395
|
+
for (const c of item.content) {
|
|
396
|
+
if (c && typeof c === 'object' && c.type === 'reasoning_text' && typeof c.text === 'string') {
|
|
397
|
+
parts.push(c.text);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return parts.join('\n').trim();
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Derive an Anthropic stop_reason from the Responses status. A tool call
|
|
405
|
+
* anywhere in the output wins → 'tool_use' (Anthropic's rule). Otherwise
|
|
406
|
+
* an `incomplete` status maps to 'max_tokens' (the common truncation
|
|
407
|
+
* case), except a content-filter incompletion, which has no length
|
|
408
|
+
* analog and degrades to 'end_turn'. Everything else is 'end_turn'.
|
|
409
|
+
*/
|
|
410
|
+
function deriveStopReason(resp, sawToolCall) {
|
|
411
|
+
if (sawToolCall)
|
|
412
|
+
return 'tool_use';
|
|
413
|
+
if (resp.status === 'incomplete') {
|
|
414
|
+
return resp.incomplete_details?.reason === 'content_filter' ? 'end_turn' : 'max_tokens';
|
|
415
|
+
}
|
|
416
|
+
return 'end_turn';
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Translate a non-streaming Responses reply into an Anthropic Messages
|
|
420
|
+
* response. `requestModel` is echoed back as `model` so the client sees
|
|
421
|
+
* the model it asked for, not the upstream alias. Output items are walked
|
|
422
|
+
* in order:
|
|
423
|
+
* - message → its `output_text` parts become text blocks; a
|
|
424
|
+
* `refusal` part surfaces as a text block so the client
|
|
425
|
+
* sees why the turn produced no content.
|
|
426
|
+
* - function_call → a tool_use block, id = `call_id` (Anthropic threads
|
|
427
|
+
* the tool_result back on this id), input = parsed
|
|
428
|
+
* `arguments` (bad JSON → `{}`).
|
|
429
|
+
* - reasoning → a thinking block IF it carries summary/reasoning
|
|
430
|
+
* text; an empty reasoning item is dropped.
|
|
431
|
+
* Other item types (web_search_call, code_interpreter_call, …) are
|
|
432
|
+
* dropped. If nothing produced content but `output_text` is present, it
|
|
433
|
+
* is used as a single text block fallback.
|
|
434
|
+
*/
|
|
435
|
+
export function responsesToAnthropicResponse(resp, requestModel) {
|
|
436
|
+
const content = [];
|
|
437
|
+
let sawToolCall = false;
|
|
438
|
+
for (const item of Array.isArray(resp.output) ? resp.output : []) {
|
|
439
|
+
if (!item || typeof item !== 'object')
|
|
440
|
+
continue;
|
|
441
|
+
const type = item.type;
|
|
442
|
+
if (type === 'message') {
|
|
443
|
+
const msg = item;
|
|
444
|
+
for (const part of Array.isArray(msg.content) ? msg.content : []) {
|
|
445
|
+
if (!part || typeof part !== 'object')
|
|
446
|
+
continue;
|
|
447
|
+
const pt = part.type;
|
|
448
|
+
if (pt === 'output_text') {
|
|
449
|
+
const text = part.text;
|
|
450
|
+
if (typeof text === 'string' && text.length > 0)
|
|
451
|
+
content.push({ type: 'text', text });
|
|
452
|
+
}
|
|
453
|
+
else if (pt === 'refusal') {
|
|
454
|
+
const refusal = part.refusal;
|
|
455
|
+
if (typeof refusal === 'string' && refusal.length > 0) {
|
|
456
|
+
content.push({ type: 'text', text: refusal });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
else if (type === 'function_call') {
|
|
462
|
+
const fc = item;
|
|
463
|
+
sawToolCall = true;
|
|
464
|
+
const id = typeof fc.call_id === 'string' && fc.call_id.length > 0
|
|
465
|
+
? fc.call_id
|
|
466
|
+
: typeof fc.id === 'string' && fc.id.length > 0
|
|
467
|
+
? fc.id
|
|
468
|
+
: 'toolu_responses_translate';
|
|
469
|
+
content.push({
|
|
470
|
+
type: 'tool_use',
|
|
471
|
+
id,
|
|
472
|
+
name: typeof fc.name === 'string' ? fc.name : '',
|
|
473
|
+
input: safeParseArguments(fc.arguments),
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
else if (type === 'reasoning') {
|
|
477
|
+
const text = reasoningItemText(item);
|
|
478
|
+
if (text.length > 0)
|
|
479
|
+
content.push({ type: 'thinking', thinking: text });
|
|
480
|
+
}
|
|
481
|
+
// Other item types are dropped.
|
|
482
|
+
}
|
|
483
|
+
if (content.length === 0 && typeof resp.output_text === 'string' && resp.output_text.length > 0) {
|
|
484
|
+
content.push({ type: 'text', text: resp.output_text });
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
id: anthropicMessageId(resp.id),
|
|
488
|
+
type: 'message',
|
|
489
|
+
role: 'assistant',
|
|
490
|
+
model: requestModel,
|
|
491
|
+
content,
|
|
492
|
+
stop_reason: deriveStopReason(resp, sawToolCall),
|
|
493
|
+
stop_sequence: null,
|
|
494
|
+
usage: {
|
|
495
|
+
input_tokens: resp.usage?.input_tokens ?? 0,
|
|
496
|
+
output_tokens: resp.usage?.output_tokens ?? 0,
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
const strOr = (v) => (typeof v === 'string' ? v : '');
|
|
501
|
+
const numOr = (v, fallback) => typeof v === 'number' && Number.isFinite(v) ? v : fallback;
|
|
502
|
+
/**
|
|
503
|
+
* Build a streaming translator from parsed OpenAI Responses events to
|
|
504
|
+
* Anthropic SSE event objects. Same interface + discipline as
|
|
505
|
+
* `openAIStreamToAnthropicSSE`: `push(event)` / `end()`, exactly one
|
|
506
|
+
* content block open at a time, strictly increasing indices, event name
|
|
507
|
+
* === data.type. Callers parse the upstream SSE themselves
|
|
508
|
+
* (`parseResponsesSSEEvent` / `createResponsesSSEParser`), push each
|
|
509
|
+
* event, and serialize the returned events (`formatResponsesAnthropicSSE`
|
|
510
|
+
* produces the wire framing).
|
|
511
|
+
*
|
|
512
|
+
* - message_start fires once, on the first event, with an empty-content
|
|
513
|
+
* envelope and usage 0/0 (usage is known only at response.completed).
|
|
514
|
+
* - A `function_call` item opens a tool_use block on
|
|
515
|
+
* output_item.added; a `reasoning` item opens a thinking block there
|
|
516
|
+
* (eager, so block order matches upstream item order even before the
|
|
517
|
+
* first summary fragment — a reasoning item that streams no text thus
|
|
518
|
+
* yields an empty thinking block bracketed by start/stop, which
|
|
519
|
+
* Anthropic clients tolerate). A `message` item opens its text block
|
|
520
|
+
* lazily on the first output_text.delta.
|
|
521
|
+
* - output_item.done closes that item's block; response.completed (or
|
|
522
|
+
* .incomplete / .failed, or a stream `error`) closes any open block
|
|
523
|
+
* and emits message_delta (stop_reason + usage) + message_stop, then
|
|
524
|
+
* marks the stream ended.
|
|
525
|
+
* - `end()` repeats the closing sequence only if no terminal event was
|
|
526
|
+
* seen (a cut-off stream), and is otherwise idempotent — so callers
|
|
527
|
+
* can always call it safely.
|
|
528
|
+
*
|
|
529
|
+
* stop_reason: a tool call anywhere → 'tool_use'; else an `incomplete`
|
|
530
|
+
* status → 'max_tokens' (content-filter incompletion → 'end_turn'); else
|
|
531
|
+
* 'end_turn' (via the shared deriveStopReason).
|
|
532
|
+
*/
|
|
533
|
+
export function responsesStreamToAnthropicSSE(options = {}) {
|
|
534
|
+
let started = false;
|
|
535
|
+
let ended = false;
|
|
536
|
+
let model = options.requestModel;
|
|
537
|
+
let messageId = 'msg_responses_translate';
|
|
538
|
+
let nextIndex = 0;
|
|
539
|
+
let open = null;
|
|
540
|
+
/** Responses `output_index` → the Anthropic block it maps to. */
|
|
541
|
+
const blockByOutputIndex = new Map();
|
|
542
|
+
let sawToolCall = false;
|
|
543
|
+
let syntheticToolSeq = 0;
|
|
544
|
+
function ensureStarted(event, events) {
|
|
545
|
+
if (started)
|
|
546
|
+
return;
|
|
547
|
+
started = true;
|
|
548
|
+
messageId = anthropicMessageId(event?.response?.id);
|
|
549
|
+
const resolvedModel = model ?? (strOr(event?.response?.model) || 'unknown');
|
|
550
|
+
model = resolvedModel;
|
|
551
|
+
events.push({
|
|
552
|
+
type: 'message_start',
|
|
553
|
+
message: {
|
|
554
|
+
id: messageId,
|
|
555
|
+
type: 'message',
|
|
556
|
+
role: 'assistant',
|
|
557
|
+
model: resolvedModel,
|
|
558
|
+
content: [],
|
|
559
|
+
stop_reason: null,
|
|
560
|
+
stop_sequence: null,
|
|
561
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
function closeOpenBlock(events) {
|
|
566
|
+
if (open) {
|
|
567
|
+
events.push({ type: 'content_block_stop', index: open.index });
|
|
568
|
+
open = null;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
function openToolBlock(item, outputIndex, events) {
|
|
572
|
+
const index = nextIndex++;
|
|
573
|
+
open = { kind: 'tool', index, outputIndex };
|
|
574
|
+
blockByOutputIndex.set(outputIndex, { kind: 'tool', index });
|
|
575
|
+
sawToolCall = true;
|
|
576
|
+
const fc = (item ?? {});
|
|
577
|
+
const id = strOr(fc.call_id) || strOr(fc.id) || `toolu_responses_${syntheticToolSeq++}`;
|
|
578
|
+
events.push({
|
|
579
|
+
type: 'content_block_start',
|
|
580
|
+
index,
|
|
581
|
+
content_block: { type: 'tool_use', id, name: strOr(fc.name), input: {} },
|
|
582
|
+
});
|
|
583
|
+
return index;
|
|
584
|
+
}
|
|
585
|
+
function openThinkingBlock(outputIndex, events) {
|
|
586
|
+
const index = nextIndex++;
|
|
587
|
+
open = { kind: 'thinking', index, outputIndex };
|
|
588
|
+
blockByOutputIndex.set(outputIndex, { kind: 'thinking', index });
|
|
589
|
+
events.push({
|
|
590
|
+
type: 'content_block_start',
|
|
591
|
+
index,
|
|
592
|
+
content_block: { type: 'thinking', thinking: '' },
|
|
593
|
+
});
|
|
594
|
+
return index;
|
|
595
|
+
}
|
|
596
|
+
/** Reuse or lazily open the text block for `outputIndex`. */
|
|
597
|
+
function ensureTextBlock(outputIndex, events) {
|
|
598
|
+
if (open && open.kind === 'text' && open.outputIndex === outputIndex)
|
|
599
|
+
return open.index;
|
|
600
|
+
closeOpenBlock(events);
|
|
601
|
+
const index = nextIndex++;
|
|
602
|
+
open = { kind: 'text', index, outputIndex };
|
|
603
|
+
blockByOutputIndex.set(outputIndex, { kind: 'text', index });
|
|
604
|
+
events.push({ type: 'content_block_start', index, content_block: { type: 'text', text: '' } });
|
|
605
|
+
return index;
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Resolve the block a tool/reasoning delta targets. Normally it is the
|
|
609
|
+
* currently-open block (opened at output_item.added); the map lookup and
|
|
610
|
+
* defensive open cover an upstream that emitted a delta without a
|
|
611
|
+
* preceding item (never observed, but keeps the stream well-formed).
|
|
612
|
+
*/
|
|
613
|
+
function resolveBlock(outputIndex, kind, events) {
|
|
614
|
+
if (open && open.kind === kind && open.outputIndex === outputIndex)
|
|
615
|
+
return open.index;
|
|
616
|
+
const existing = blockByOutputIndex.get(outputIndex);
|
|
617
|
+
if (existing && existing.kind === kind)
|
|
618
|
+
return existing.index;
|
|
619
|
+
return kind === 'tool'
|
|
620
|
+
? openToolBlock(undefined, outputIndex, events)
|
|
621
|
+
: openThinkingBlock(outputIndex, events);
|
|
622
|
+
}
|
|
623
|
+
function finalize(resp, events) {
|
|
624
|
+
closeOpenBlock(events);
|
|
625
|
+
const r = resp ?? {};
|
|
626
|
+
const usageOut = {
|
|
627
|
+
output_tokens: numOr(r.usage?.output_tokens, 0),
|
|
628
|
+
};
|
|
629
|
+
if (typeof r.usage?.input_tokens === 'number')
|
|
630
|
+
usageOut.input_tokens = r.usage.input_tokens;
|
|
631
|
+
events.push({
|
|
632
|
+
type: 'message_delta',
|
|
633
|
+
delta: { stop_reason: deriveStopReason(r, sawToolCall), stop_sequence: null },
|
|
634
|
+
usage: usageOut,
|
|
635
|
+
});
|
|
636
|
+
events.push({ type: 'message_stop' });
|
|
637
|
+
ended = true;
|
|
638
|
+
}
|
|
639
|
+
return {
|
|
640
|
+
push(event) {
|
|
641
|
+
const events = [];
|
|
642
|
+
if (ended || !event || typeof event !== 'object' || typeof event.type !== 'string') {
|
|
643
|
+
return events;
|
|
644
|
+
}
|
|
645
|
+
const type = event.type;
|
|
646
|
+
if (type === 'response.completed' ||
|
|
647
|
+
type === 'response.incomplete' ||
|
|
648
|
+
type === 'response.failed') {
|
|
649
|
+
ensureStarted(event, events);
|
|
650
|
+
finalize(event.response, events);
|
|
651
|
+
return events;
|
|
652
|
+
}
|
|
653
|
+
if (type === 'error') {
|
|
654
|
+
// Upstream stream error — terminate cleanly so the client sees a
|
|
655
|
+
// well-formed end, not a hang. HTTP-level errors are surfaced by
|
|
656
|
+
// the transport layer, not this pure translator.
|
|
657
|
+
ensureStarted(event, events);
|
|
658
|
+
finalize(undefined, events);
|
|
659
|
+
return events;
|
|
660
|
+
}
|
|
661
|
+
ensureStarted(event, events);
|
|
662
|
+
switch (type) {
|
|
663
|
+
case 'response.created':
|
|
664
|
+
case 'response.in_progress':
|
|
665
|
+
break; // message_start already emitted by ensureStarted
|
|
666
|
+
case 'response.output_item.added': {
|
|
667
|
+
const outputIndex = numOr(event.output_index, 0);
|
|
668
|
+
closeOpenBlock(events); // a new item boundary closes the previous block
|
|
669
|
+
const itemType = event.item && typeof event.item === 'object'
|
|
670
|
+
? event.item.type
|
|
671
|
+
: undefined;
|
|
672
|
+
if (itemType === 'function_call')
|
|
673
|
+
openToolBlock(event.item, outputIndex, events);
|
|
674
|
+
else if (itemType === 'reasoning')
|
|
675
|
+
openThinkingBlock(outputIndex, events);
|
|
676
|
+
// message → nothing; the text block opens on the first delta.
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
case 'response.output_text.delta': {
|
|
680
|
+
if (typeof event.delta === 'string' && event.delta.length > 0) {
|
|
681
|
+
const index = ensureTextBlock(numOr(event.output_index, 0), events);
|
|
682
|
+
events.push({
|
|
683
|
+
type: 'content_block_delta',
|
|
684
|
+
index,
|
|
685
|
+
delta: { type: 'text_delta', text: event.delta },
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
case 'response.function_call_arguments.delta': {
|
|
691
|
+
if (typeof event.delta === 'string' && event.delta.length > 0) {
|
|
692
|
+
const index = resolveBlock(numOr(event.output_index, 0), 'tool', events);
|
|
693
|
+
events.push({
|
|
694
|
+
type: 'content_block_delta',
|
|
695
|
+
index,
|
|
696
|
+
delta: { type: 'input_json_delta', partial_json: event.delta },
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
break;
|
|
700
|
+
}
|
|
701
|
+
case 'response.reasoning_summary_text.delta':
|
|
702
|
+
case 'response.reasoning_text.delta': {
|
|
703
|
+
if (typeof event.delta === 'string' && event.delta.length > 0) {
|
|
704
|
+
const index = resolveBlock(numOr(event.output_index, 0), 'thinking', events);
|
|
705
|
+
events.push({
|
|
706
|
+
type: 'content_block_delta',
|
|
707
|
+
index,
|
|
708
|
+
delta: { type: 'thinking_delta', thinking: event.delta },
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
break;
|
|
712
|
+
}
|
|
713
|
+
case 'response.output_item.done': {
|
|
714
|
+
const outputIndex = numOr(event.output_index, 0);
|
|
715
|
+
if (open && open.outputIndex === outputIndex)
|
|
716
|
+
closeOpenBlock(events);
|
|
717
|
+
break;
|
|
718
|
+
}
|
|
719
|
+
default:
|
|
720
|
+
// content_part.added/done, output_text.done,
|
|
721
|
+
// reasoning_summary_part.*, function_call_arguments.done,
|
|
722
|
+
// refusal.*, … — no-ops: the deltas already carried the content
|
|
723
|
+
// and the *.done markers are redundant with output_item.done /
|
|
724
|
+
// response.completed.
|
|
725
|
+
break;
|
|
726
|
+
}
|
|
727
|
+
return events;
|
|
728
|
+
},
|
|
729
|
+
end() {
|
|
730
|
+
if (ended)
|
|
731
|
+
return [];
|
|
732
|
+
const events = [];
|
|
733
|
+
ensureStarted(null, events);
|
|
734
|
+
finalize(undefined, events);
|
|
735
|
+
return events;
|
|
736
|
+
},
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Serialize one emitted Anthropic stream event into SSE wire framing:
|
|
741
|
+
* `event: <type>\ndata: <json>\n\n` — the same `event:`/`data:` pairing
|
|
742
|
+
* dario's streaming path produces. Structural twin of
|
|
743
|
+
* Anthropic SSE formatting, widened to this module's
|
|
744
|
+
* thinking-aware event union.
|
|
745
|
+
*/
|
|
746
|
+
export function formatResponsesAnthropicSSE(event) {
|
|
747
|
+
return `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Parse one Responses SSE record's `event:` and `data:` lines into an
|
|
751
|
+
* event object. Unlike chat completions (data-only), Responses SSE pairs
|
|
752
|
+
* an `event: <type>` line with a `data: <json>` line; the JSON already
|
|
753
|
+
* carries a matching `type`, so `dataLine` alone is usually enough — the
|
|
754
|
+
* `eventLine` is used only to backfill `type` if the JSON somehow omits
|
|
755
|
+
* it. Returns null for keep-alive/comment lines, `data: [DONE]`, and
|
|
756
|
+
* unparseable payloads. Callers that own real SSE framing (multi-line
|
|
757
|
+
* buffering, CRLF, cross-chunk boundaries) should use
|
|
758
|
+
* `createResponsesSSEParser` instead.
|
|
759
|
+
*/
|
|
760
|
+
export function parseResponsesSSEEvent(eventLine, dataLine) {
|
|
761
|
+
if (typeof dataLine !== 'string')
|
|
762
|
+
return null;
|
|
763
|
+
const dtrim = dataLine.endsWith('\r') ? dataLine.slice(0, -1) : dataLine;
|
|
764
|
+
if (!dtrim.startsWith('data:'))
|
|
765
|
+
return null;
|
|
766
|
+
const payload = dtrim.slice(5).trim();
|
|
767
|
+
if (payload === '' || payload === '[DONE]')
|
|
768
|
+
return null;
|
|
769
|
+
let obj;
|
|
770
|
+
try {
|
|
771
|
+
obj = JSON.parse(payload);
|
|
772
|
+
}
|
|
773
|
+
catch {
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj))
|
|
777
|
+
return null;
|
|
778
|
+
const ev = obj;
|
|
779
|
+
if (typeof ev.type !== 'string' && typeof eventLine === 'string') {
|
|
780
|
+
const etrim = eventLine.endsWith('\r') ? eventLine.slice(0, -1) : eventLine;
|
|
781
|
+
if (etrim.startsWith('event:'))
|
|
782
|
+
ev.type = etrim.slice(6).trim();
|
|
783
|
+
}
|
|
784
|
+
return typeof ev.type === 'string' ? ev : null;
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Buffered Responses SSE parser: feed raw stream chunks (which do NOT
|
|
788
|
+
* align to event boundaries), get back the complete events decoded so
|
|
789
|
+
* far. Records are separated by a blank line (`\n\n` or `\r\n\r\n`);
|
|
790
|
+
* multiple `data:` lines in one record join with `\n` per the SSE spec.
|
|
791
|
+
* Call `flush()` at end-of-stream to parse any trailing record that was
|
|
792
|
+
* not blank-line terminated. Pure and offline-testable.
|
|
793
|
+
*/
|
|
794
|
+
export function createResponsesSSEParser() {
|
|
795
|
+
let buffer = '';
|
|
796
|
+
const boundary = /\r?\n\r?\n/;
|
|
797
|
+
function parseRecord(record) {
|
|
798
|
+
let eventLine;
|
|
799
|
+
const dataParts = [];
|
|
800
|
+
for (const raw of record.split('\n')) {
|
|
801
|
+
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
|
802
|
+
if (line.startsWith('event:'))
|
|
803
|
+
eventLine = line;
|
|
804
|
+
else if (line.startsWith('data:'))
|
|
805
|
+
dataParts.push(line.slice(5).replace(/^ /, ''));
|
|
806
|
+
}
|
|
807
|
+
if (dataParts.length === 0)
|
|
808
|
+
return null;
|
|
809
|
+
return parseResponsesSSEEvent(eventLine, `data: ${dataParts.join('\n')}`);
|
|
810
|
+
}
|
|
811
|
+
return {
|
|
812
|
+
push(chunk) {
|
|
813
|
+
const events = [];
|
|
814
|
+
if (typeof chunk !== 'string' || chunk.length === 0)
|
|
815
|
+
return events;
|
|
816
|
+
buffer += chunk;
|
|
817
|
+
let m;
|
|
818
|
+
while ((m = boundary.exec(buffer)) !== null) {
|
|
819
|
+
const record = buffer.slice(0, m.index);
|
|
820
|
+
buffer = buffer.slice(m.index + m[0].length);
|
|
821
|
+
const ev = parseRecord(record);
|
|
822
|
+
if (ev)
|
|
823
|
+
events.push(ev);
|
|
824
|
+
}
|
|
825
|
+
return events;
|
|
826
|
+
},
|
|
827
|
+
flush() {
|
|
828
|
+
const events = [];
|
|
829
|
+
const rest = buffer.trim();
|
|
830
|
+
buffer = '';
|
|
831
|
+
if (rest.length > 0) {
|
|
832
|
+
const ev = parseRecord(rest);
|
|
833
|
+
if (ev)
|
|
834
|
+
events.push(ev);
|
|
835
|
+
}
|
|
836
|
+
return events;
|
|
837
|
+
},
|
|
838
|
+
};
|
|
839
|
+
}
|