clauderipple 0.9.3 → 0.9.5
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/CHANGELOG.md +41 -0
- package/dist/app/package.json +1 -1
- package/dist/mod/hooks/i18n.ts +36 -0
- package/dist/mod/hooks/register.tsx +8 -4
- package/dist/mod/hooks/session.ts +2 -2
- package/dist/mod/hooks/ui/alerts.tsx +10 -0
- package/dist/mod/hooks/ui/art.ts +64 -1
- package/dist/mod/hooks/ui/band.tsx +70 -42
- package/dist/mod/hooks/ui/kit.tsx +34 -2
- package/dist/mod/hooks/ui/view.ts +5 -0
- package/dist/mod/tests/register.test.tsx +31 -3
- package/dist/mod/tests/ui.test.tsx +108 -18
- package/dist/mod/types/index.d.ts +1 -1
- package/dist/router/src/admin.js +65 -6
- package/dist/router/src/agents.js +5 -0
- package/dist/router/src/catalog.js +18 -8
- package/dist/router/src/index.js +13 -4
- package/dist/router/src/presets.js +3 -0
- package/dist/router/src/providers/chatgpt/index.js +49 -11
- package/dist/router/src/providers/chatgpt/translate.js +147 -7
- package/dist/router/src/providers/openai/index.js +29 -5
- package/dist/router/src/providers/openai/translate.js +247 -25
- package/dist/router/src/providers/retry.js +15 -0
- package/dist/router/src/reasoning.js +157 -0
- package/dist/router/src/thinking.js +20 -9
- package/dist/router/src/version.js +1 -1
- package/docs/ARCHITECTURE.md +56 -4
- package/package.json +3 -2
|
@@ -5,6 +5,14 @@ import crypto from "node:crypto";
|
|
|
5
5
|
import { clampEffort } from "../../compat.js";
|
|
6
6
|
import { conversationKey, estimateTokens, normalizeSchema, serverToolNames, systemWithDroppedToolNote, toolNameForResponses } from "../chatgpt/translate.js";
|
|
7
7
|
import { identityPrefix, instructionsSuffix } from "../../identity.js";
|
|
8
|
+
import { encodeEnvelope, envelopeFor, replayableReasoning } from "../../reasoning.js";
|
|
9
|
+
/** Counts a replay outcome, creating the tally the adapter reports from when it is the first. */
|
|
10
|
+
function bump(opts, kind, by = 1) {
|
|
11
|
+
if (!opts)
|
|
12
|
+
return;
|
|
13
|
+
const carried = opts.carried ?? (opts.carried = { replayed: 0, undecodable: 0 });
|
|
14
|
+
carried[kind] += by;
|
|
15
|
+
}
|
|
8
16
|
function textOf(content) {
|
|
9
17
|
if (typeof content === "string")
|
|
10
18
|
return content;
|
|
@@ -104,6 +112,14 @@ export function toChatMessages(req, opts) {
|
|
|
104
112
|
if (role === "assistant") {
|
|
105
113
|
const text = [];
|
|
106
114
|
const calls = [];
|
|
115
|
+
// The reasoning this turn came back with, rejoined into one string and sent as
|
|
116
|
+
// `reasoning_content` on the same assistant message — the shape DeepSeek's own example builds
|
|
117
|
+
// by appending the whole response message, and the only shape its API documents. The field
|
|
118
|
+
// name is the one the vendor sent it under, carried in the envelope: a model heard on
|
|
119
|
+
// `reasoning` is answered on `reasoning` (reasoning.ts).
|
|
120
|
+
const reasoning = [];
|
|
121
|
+
let field = "reasoning_content";
|
|
122
|
+
let carriedHere = 0;
|
|
107
123
|
for (const block of message.content) {
|
|
108
124
|
if (block.type === "text")
|
|
109
125
|
text.push(String(block.text ?? ""));
|
|
@@ -112,9 +128,31 @@ export function toChatMessages(req, opts) {
|
|
|
112
128
|
knownCalls.add(call.id);
|
|
113
129
|
calls.push({ id: call.id, type: "function", function: { name: toolNameForResponses(call.name), arguments: typeof call.input === "string" ? call.input : JSON.stringify(call.input ?? {}) } });
|
|
114
130
|
}
|
|
131
|
+
const envelope = envelopeFor(block, "chat", opts?.env, (reason) => {
|
|
132
|
+
bump(opts, "undecodable");
|
|
133
|
+
if (opts?.replay !== false)
|
|
134
|
+
opts?.log?.(`dropped a reasoning carrier: ${reason}`);
|
|
135
|
+
});
|
|
136
|
+
if (!envelope || opts?.replay === false)
|
|
137
|
+
continue;
|
|
138
|
+
const held = block.thinking;
|
|
139
|
+
if (typeof held === "string" && held.length > 0)
|
|
140
|
+
reasoning.push(held);
|
|
141
|
+
field = envelope.f === "reasoning" ? "reasoning" : "reasoning_content";
|
|
142
|
+
carriedHere++;
|
|
143
|
+
}
|
|
144
|
+
if (carriedHere > 0)
|
|
145
|
+
bump(opts, "replayed", carriedHere);
|
|
146
|
+
// A turn whose only content was reasoning is still a turn the model had: it goes out with the
|
|
147
|
+
// reasoning alone rather than vanishing, which is what dropping thinking did before.
|
|
148
|
+
if (text.some(Boolean) || calls.length || reasoning.length) {
|
|
149
|
+
messages.push({
|
|
150
|
+
role: "assistant",
|
|
151
|
+
content: text.join("\n") || null,
|
|
152
|
+
...(calls.length ? { tool_calls: calls } : {}),
|
|
153
|
+
...(reasoning.length ? (field === "reasoning" ? { reasoning: reasoning.join("\n\n") } : { reasoning_content: reasoning.join("\n\n") }) : {}),
|
|
154
|
+
});
|
|
115
155
|
}
|
|
116
|
-
if (text.some(Boolean) || calls.length)
|
|
117
|
-
messages.push({ role: "assistant", content: text.join("\n") || null, ...(calls.length ? { tool_calls: calls } : {}) });
|
|
118
156
|
continue;
|
|
119
157
|
}
|
|
120
158
|
let parts = [];
|
|
@@ -158,9 +196,16 @@ export function toChatMessages(req, opts) {
|
|
|
158
196
|
return messages;
|
|
159
197
|
}
|
|
160
198
|
/** Translate to the stateless OpenAI Responses input grammar. */
|
|
161
|
-
export function toResponsesInput(req) {
|
|
199
|
+
export function toResponsesInput(req, opts) {
|
|
162
200
|
const input = [];
|
|
163
201
|
const knownCalls = new Set();
|
|
202
|
+
let lastCallId;
|
|
203
|
+
// A translation with no options is a translation with nothing to say about reasoning: there is no
|
|
204
|
+
// provider to name in an envelope and no model to check it against, so nothing is carried either
|
|
205
|
+
// way. `opts` is only consulted when it is there.
|
|
206
|
+
const carried = (count) => {
|
|
207
|
+
bump(opts, count);
|
|
208
|
+
};
|
|
164
209
|
for (const message of req.messages) {
|
|
165
210
|
const role = message.role === "assistant" ? "assistant" : "user";
|
|
166
211
|
if (typeof message.content === "string") {
|
|
@@ -177,6 +222,27 @@ export function toResponsesInput(req) {
|
|
|
177
222
|
parts = [];
|
|
178
223
|
};
|
|
179
224
|
for (const block of message.content) {
|
|
225
|
+
// A reasoning item this conversation came back with, in the position it was replayed from —
|
|
226
|
+
// beside the function calls of that turn, and never for the other wire's carrier (reasoning.ts).
|
|
227
|
+
const envelope = envelopeFor(block, "responses", opts?.env, (reason) => {
|
|
228
|
+
carried("undecodable");
|
|
229
|
+
if (opts?.replay !== false)
|
|
230
|
+
opts?.log?.(`dropped a reasoning carrier: ${reason}`);
|
|
231
|
+
});
|
|
232
|
+
if (envelope) {
|
|
233
|
+
if (opts?.replay !== false) {
|
|
234
|
+
const item = replayableReasoning(envelope.r);
|
|
235
|
+
if (item) {
|
|
236
|
+
flush();
|
|
237
|
+
input.push(item);
|
|
238
|
+
carried("replayed");
|
|
239
|
+
opts?.onArrival?.(item, lastCallId);
|
|
240
|
+
}
|
|
241
|
+
else
|
|
242
|
+
carried("undecodable");
|
|
243
|
+
}
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
180
246
|
if (block.type === "text") {
|
|
181
247
|
const text = String(block.text ?? "");
|
|
182
248
|
if (text)
|
|
@@ -191,6 +257,7 @@ export function toResponsesInput(req) {
|
|
|
191
257
|
flush();
|
|
192
258
|
const call = block;
|
|
193
259
|
knownCalls.add(call.id);
|
|
260
|
+
lastCallId = call.id;
|
|
194
261
|
input.push({ type: "function_call", call_id: call.id, name: toolNameForResponses(call.name), arguments: typeof call.input === "string" ? call.input : JSON.stringify(call.input ?? {}) });
|
|
195
262
|
}
|
|
196
263
|
else if (block.type === "tool_result") {
|
|
@@ -243,11 +310,18 @@ export function toOpenAiRequest(req, opts) {
|
|
|
243
310
|
return out;
|
|
244
311
|
}
|
|
245
312
|
const responseTools = tools.map(({ function: fn }) => ({ type: "function", ...fn }));
|
|
313
|
+
const input = toResponsesInput(req, opts);
|
|
246
314
|
const out = {
|
|
247
315
|
model: opts.model,
|
|
248
|
-
input
|
|
316
|
+
input,
|
|
249
317
|
stream: true,
|
|
250
318
|
};
|
|
319
|
+
// A vendor marked `sealedReasoning` is asked for its reasoning on every turn, the way OpenCode's own
|
|
320
|
+
// client asks: asking only once a conversation already carried some would never start one on a
|
|
321
|
+
// vendor that seals nothing unasked. Anyone else is asked only when the conversation is already
|
|
322
|
+
// carrying its reasoning, so a request for a vendor never measured stays as it was.
|
|
323
|
+
if (opts.replay !== false && (opts.sealedReasoning || input.some((item) => item.type === "reasoning")))
|
|
324
|
+
out.include = ["reasoning.encrypted_content"];
|
|
251
325
|
const instructions = systemWithIdentity(systemWithDroppedToolNote(req), opts);
|
|
252
326
|
if (instructions)
|
|
253
327
|
out.instructions = instructions;
|
|
@@ -268,7 +342,7 @@ export function toOpenAiRequest(req, opts) {
|
|
|
268
342
|
if (typeof req.temperature === "number")
|
|
269
343
|
out.temperature = req.temperature;
|
|
270
344
|
if (effort)
|
|
271
|
-
out.reasoning = { effort };
|
|
345
|
+
out.reasoning = opts.sealedReasoning && opts.replay !== false ? { effort, summary: "auto" } : { effort };
|
|
272
346
|
return out;
|
|
273
347
|
}
|
|
274
348
|
export { conversationKey, estimateTokens };
|
|
@@ -277,26 +351,43 @@ export class OpenAiStreamMapper {
|
|
|
277
351
|
started = false;
|
|
278
352
|
finished = false;
|
|
279
353
|
blockIndex = -1;
|
|
354
|
+
/** `block` is the content entry this block writes into; `signature` seals a Responses item. */
|
|
280
355
|
open = null;
|
|
281
356
|
sawTool = false;
|
|
282
357
|
finishReason;
|
|
283
358
|
responseItems = [];
|
|
284
359
|
responseItemsById = new Map();
|
|
360
|
+
/** Reasoning items of a Responses stream, by item id, until the item that carries one is done. */
|
|
361
|
+
reasoningItems = new Map();
|
|
362
|
+
/** The item id of the most recent function_call, which is what a following reasoning describes. */
|
|
363
|
+
lastCallId;
|
|
364
|
+
/** The field a Chat vendor's reasoning arrived under; the same one it is replayed under. */
|
|
365
|
+
chatReasoningField = "reasoning_content";
|
|
285
366
|
messageId = `msg_${crypto.randomBytes(12).toString("hex")}`;
|
|
286
367
|
servedTier;
|
|
287
368
|
content = [];
|
|
288
369
|
usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
|
|
289
370
|
stopReason = "end_turn";
|
|
371
|
+
/** How many reasoning items this response handed over whole. The `reasoning=` figure in the log. */
|
|
372
|
+
reasoningCount = 0;
|
|
290
373
|
model;
|
|
291
374
|
startInput;
|
|
292
375
|
/** Mangled tool name → the name Claude Code knows, from `toolNameRestoreMap`. */
|
|
293
376
|
toolNames;
|
|
377
|
+
/** What goes in each envelope: the provider and model this stream is going to (reasoning.ts). */
|
|
378
|
+
env;
|
|
379
|
+
/**
|
|
380
|
+
* The wire this stream is on. It is fixed for the mapper's life — the adapter knows it before the
|
|
381
|
+
* first event. Decides what a closing block does: on Chat the block itself is the carrier.
|
|
382
|
+
*/
|
|
383
|
+
wire = "chat";
|
|
294
384
|
/** Applied to every error message before it is stored or sent. */
|
|
295
385
|
redactError = (message) => message;
|
|
296
|
-
constructor(model, startInput = 0, toolNames = new Map()) {
|
|
386
|
+
constructor(model, startInput = 0, toolNames = new Map(), env = { p: "", m: "" }) {
|
|
297
387
|
this.model = model;
|
|
298
388
|
this.startInput = startInput;
|
|
299
389
|
this.toolNames = toolNames;
|
|
390
|
+
this.env = { p: env.p, m: env.m || model };
|
|
300
391
|
}
|
|
301
392
|
get isFinished() { return this.finished; }
|
|
302
393
|
/**
|
|
@@ -315,17 +406,40 @@ export class OpenAiStreamMapper {
|
|
|
315
406
|
this.started = true;
|
|
316
407
|
return [{ event: "message_start", data: { type: "message_start", message: { id: this.messageId, type: "message", role: "assistant", model: this.model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: this.startInput, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } } } }];
|
|
317
408
|
}
|
|
409
|
+
/**
|
|
410
|
+
* Close the block that is open, writing any envelope it carries as a `signature_delta` first — last
|
|
411
|
+
* in the block, after all of its text, the order Anthropic's own thinking blocks use.
|
|
412
|
+
*
|
|
413
|
+
* A Chat reasoning block is sealed here, because only now is its text whole; a Responses one was
|
|
414
|
+
* sealed by its item (see `sealReasoningItem`) before this is reached, so it never gets here
|
|
415
|
+
* unsigned.
|
|
416
|
+
*/
|
|
318
417
|
closeBlock() {
|
|
319
418
|
if (!this.open)
|
|
320
419
|
return [];
|
|
321
|
-
const index = this.open
|
|
420
|
+
const { index, kind, block } = this.open;
|
|
421
|
+
let signature = this.open.signature;
|
|
422
|
+
if (signature === undefined && kind === "thinking" && this.wire === "chat") {
|
|
423
|
+
signature = this.signatureForChat(block);
|
|
424
|
+
}
|
|
322
425
|
this.open = null;
|
|
323
|
-
|
|
426
|
+
const out = [];
|
|
427
|
+
if (signature !== undefined)
|
|
428
|
+
out.push({ event: "content_block_delta", data: { type: "content_block_delta", index, delta: { type: "signature_delta", signature } } });
|
|
429
|
+
out.push({ event: "content_block_stop", data: { type: "content_block_stop", index } });
|
|
430
|
+
return out;
|
|
324
431
|
}
|
|
325
|
-
|
|
432
|
+
/**
|
|
433
|
+
* `block` is the caller's entry in `this.content` for this block, when it has one. It is held here
|
|
434
|
+
* rather than found as the last entry, because a Chat caller pushes the entry it is about to open
|
|
435
|
+
* *before* calling this — which would close the block against the wrong entry.
|
|
436
|
+
*/
|
|
437
|
+
openBlock(kind, contentBlock, toolIndex, block) {
|
|
326
438
|
const out = this.closeBlock();
|
|
327
439
|
const index = ++this.blockIndex;
|
|
328
|
-
|
|
440
|
+
if (block !== undefined)
|
|
441
|
+
this.content.push(block);
|
|
442
|
+
this.open = { kind, index, ...(toolIndex === undefined ? {} : { toolIndex }), ...(block === undefined ? {} : { block }) };
|
|
329
443
|
out.push({ event: "content_block_start", data: { type: "content_block_start", index, content_block: contentBlock } });
|
|
330
444
|
return out;
|
|
331
445
|
}
|
|
@@ -371,27 +485,34 @@ export class OpenAiStreamMapper {
|
|
|
371
485
|
for (const choice of choices) {
|
|
372
486
|
const delta = choice.delta ?? {};
|
|
373
487
|
// Thinking arrives before the answer, so it opens the first block of the turn. `reasoning` is
|
|
374
|
-
// accepted alongside `reasoning_content` because vendors on this wire disagree on the name
|
|
375
|
-
|
|
376
|
-
|
|
488
|
+
// accepted alongside `reasoning_content` because vendors on this wire disagree on the name;
|
|
489
|
+
// the name it arrived under is remembered, because the same name is what the reasoning has to
|
|
490
|
+
// be replayed under on the next turn (reasoning.ts).
|
|
491
|
+
const field = typeof delta.reasoning_content === "string" && delta.reasoning_content ? "reasoning_content"
|
|
492
|
+
: typeof delta.reasoning === "string" && delta.reasoning ? "reasoning" : null;
|
|
493
|
+
const reasoning = field === "reasoning_content" ? delta.reasoning_content : field === "reasoning" ? delta.reasoning : "";
|
|
494
|
+
if (field)
|
|
495
|
+
this.chatReasoningField = field;
|
|
377
496
|
if (reasoning) {
|
|
378
497
|
if (!this.open || this.open.kind !== "thinking") {
|
|
379
|
-
|
|
380
|
-
|
|
498
|
+
// A Chat model's reasoning has no sealed blob to resume from, so the carrier is the block
|
|
499
|
+
// itself: the block's text is the reasoning to replay, and its signature is the envelope
|
|
500
|
+
// naming the field to replay it under (reasoning.ts). It is sealed when the block closes,
|
|
501
|
+
// so the envelope is built once the whole string has arrived (see `signatureForChat`).
|
|
502
|
+
out.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }, undefined, { type: "thinking", thinking: "" }));
|
|
381
503
|
}
|
|
382
|
-
const
|
|
383
|
-
if (
|
|
384
|
-
|
|
504
|
+
const block = this.open?.block;
|
|
505
|
+
if (block)
|
|
506
|
+
block.thinking += reasoning;
|
|
385
507
|
out.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.open.index, delta: { type: "thinking_delta", thinking: reasoning } } });
|
|
386
508
|
}
|
|
387
509
|
if (typeof delta.content === "string" && delta.content) {
|
|
388
510
|
if (!this.open || this.open.kind !== "text") {
|
|
389
|
-
|
|
390
|
-
out.push(...this.openBlock("text", { type: "text", text: "" }));
|
|
511
|
+
out.push(...this.openBlock("text", { type: "text", text: "" }, undefined, { type: "text", text: "" }));
|
|
391
512
|
}
|
|
392
|
-
const
|
|
393
|
-
if (
|
|
394
|
-
|
|
513
|
+
const block = this.open?.block;
|
|
514
|
+
if (block)
|
|
515
|
+
block.text += delta.content;
|
|
395
516
|
out.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.open.index, delta: { type: "text_delta", text: delta.content } } });
|
|
396
517
|
}
|
|
397
518
|
if (Array.isArray(delta.tool_calls)) {
|
|
@@ -417,6 +538,42 @@ export class OpenAiStreamMapper {
|
|
|
417
538
|
}
|
|
418
539
|
return out;
|
|
419
540
|
}
|
|
541
|
+
/**
|
|
542
|
+
* Seal a closing thinking block of our own: a Chat block into the envelope that names the field to
|
|
543
|
+
* replay its text under (see `feedChat`).
|
|
544
|
+
*/
|
|
545
|
+
signatureForChat(block) {
|
|
546
|
+
if (this.wire !== "chat" || block?.type !== "thinking" || block.thinking.length === 0)
|
|
547
|
+
return undefined;
|
|
548
|
+
const signature = encodeEnvelope({ w: "chat", p: this.env.p, m: this.env.m, f: this.chatReasoningField });
|
|
549
|
+
block.signature = signature;
|
|
550
|
+
this.reasoningCount++;
|
|
551
|
+
return signature;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Put the finished Responses reasoning item into the envelope its block carries: the wire, this
|
|
555
|
+
* provider and model, and the item itself — type, id, summary and the backend's sealed blob,
|
|
556
|
+
* untouched (reasoning.ts). An item the backend did not seal has nothing to resume, so its block
|
|
557
|
+
* stays unsigned and only its summary survives, as display text.
|
|
558
|
+
*/
|
|
559
|
+
sealReasoningItem(item) {
|
|
560
|
+
const held = this.reasoningItems.get(item.id);
|
|
561
|
+
const block = item.block;
|
|
562
|
+
block.thinking = held?.text.trim() || block.thinking.trim() || (typeof held?.summary === "string" ? held.summary.trim() : "");
|
|
563
|
+
if (!held?.encrypted) {
|
|
564
|
+
// An item the backend did not seal has nothing to resume. The block stays unsigned, so the
|
|
565
|
+
// client treats it as display text and never replays it (reasoning.ts).
|
|
566
|
+
block.signature = "";
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
const reasoning = { type: "reasoning", encrypted_content: held.encrypted };
|
|
570
|
+
if (item.id)
|
|
571
|
+
reasoning.id = item.id;
|
|
572
|
+
if (Array.isArray(held.summary))
|
|
573
|
+
reasoning.summary = held.summary;
|
|
574
|
+
block.signature = encodeEnvelope({ w: "responses", p: this.env.p, m: this.env.m, r: reasoning });
|
|
575
|
+
this.reasoningCount++;
|
|
576
|
+
}
|
|
420
577
|
responseItem(id, kind) {
|
|
421
578
|
if (typeof id === "string")
|
|
422
579
|
return this.responseItemsById.get(id);
|
|
@@ -452,6 +609,24 @@ export class OpenAiStreamMapper {
|
|
|
452
609
|
item.closed = true;
|
|
453
610
|
continue;
|
|
454
611
|
}
|
|
612
|
+
if (item.kind === "reasoning") {
|
|
613
|
+
// Emitted only once the item is done, because the block is handed over whole: the sealed
|
|
614
|
+
// reasoning is what goes in the envelope, and it may only arrive with the completed item.
|
|
615
|
+
// `sealReasoningItem` has already put the envelope on the block by then.
|
|
616
|
+
if (!item.done)
|
|
617
|
+
break;
|
|
618
|
+
const reasoning = item.block;
|
|
619
|
+
out.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }));
|
|
620
|
+
if (reasoning.signature !== undefined)
|
|
621
|
+
this.open = { ...this.open, signature: reasoning.signature };
|
|
622
|
+
if (reasoning.thinking)
|
|
623
|
+
out.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.open.index, delta: { type: "thinking_delta", thinking: reasoning.thinking } } });
|
|
624
|
+
item.emitted = true;
|
|
625
|
+
item.emittedText = reasoning.thinking.length;
|
|
626
|
+
out.push(...this.closeBlock());
|
|
627
|
+
item.closed = true;
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
455
630
|
const tool = item.block;
|
|
456
631
|
out.push(...this.openBlock("tool", { type: "tool_use", id: tool.id, name: tool.name, input: {} }, tool.index));
|
|
457
632
|
if (tool.args) {
|
|
@@ -480,12 +655,28 @@ export class OpenAiStreamMapper {
|
|
|
480
655
|
this.responseItemsById.set(id, responseItem);
|
|
481
656
|
}
|
|
482
657
|
else if (id && item?.type === "function_call") {
|
|
658
|
+
this.lastCallId = item.call_id === undefined || item.call_id === null ? id : String(item.call_id);
|
|
483
659
|
const index = this.content.filter((block) => block.type === "tool_use").length;
|
|
484
660
|
const tool = this.toolFor(index, { id: item.call_id ?? item.id, function: { name: item.name } });
|
|
485
661
|
const responseItem = { id, kind: "tool", block: tool, done: false, emitted: false, closed: false, emittedText: 0 };
|
|
486
662
|
this.responseItems.push(responseItem);
|
|
487
663
|
this.responseItemsById.set(id, responseItem);
|
|
488
664
|
}
|
|
665
|
+
else if (id && item?.type === "reasoning") {
|
|
666
|
+
// The item's sealed reasoning and summary, held until the item is done: the block handed over
|
|
667
|
+
// carries both, and a half-assembled one must not reach the client (reasoning.ts).
|
|
668
|
+
const reasoning = {
|
|
669
|
+
...(typeof item.encrypted_content === "string" && item.encrypted_content ? { encrypted: item.encrypted_content } : {}),
|
|
670
|
+
summary: item.summary,
|
|
671
|
+
text: "",
|
|
672
|
+
};
|
|
673
|
+
this.reasoningItems.set(id, reasoning);
|
|
674
|
+
const block = { type: "thinking", thinking: "" };
|
|
675
|
+
this.content.push(block);
|
|
676
|
+
const responseItem = { id, kind: "reasoning", block, done: false, emitted: false, closed: false, emittedText: 0 };
|
|
677
|
+
this.responseItems.push(responseItem);
|
|
678
|
+
this.responseItemsById.set(id, responseItem);
|
|
679
|
+
}
|
|
489
680
|
}
|
|
490
681
|
else if (type === "response.output_text.delta") {
|
|
491
682
|
const text = typeof ev.delta === "string" ? ev.delta : "";
|
|
@@ -511,10 +702,40 @@ export class OpenAiStreamMapper {
|
|
|
511
702
|
}
|
|
512
703
|
}
|
|
513
704
|
}
|
|
705
|
+
else if (type === "response.reasoning_summary_text.delta" || type === "response.reasoning_text.delta") {
|
|
706
|
+
// The summary, shown as the thinking block's text. When there is none — which is the usual
|
|
707
|
+
// shape — nothing arrives and the block stays empty, exactly as the item was measured.
|
|
708
|
+
const text = typeof ev.delta === "string" ? ev.delta : "";
|
|
709
|
+
const item = this.responseItem(ev.item_id, "reasoning");
|
|
710
|
+
if (item?.kind === "reasoning" && text) {
|
|
711
|
+
const block = item.block;
|
|
712
|
+
block.thinking += text;
|
|
713
|
+
if (this.reasoningItems.get(item.id))
|
|
714
|
+
this.reasoningItems.get(item.id).text += text;
|
|
715
|
+
if (item.emitted && this.open?.kind === "thinking") {
|
|
716
|
+
out.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.open.index, delta: { type: "thinking_delta", thinking: text } } });
|
|
717
|
+
item.emittedText += text.length;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
514
721
|
else if (type === "response.output_item.done") {
|
|
515
|
-
const
|
|
516
|
-
|
|
722
|
+
const done = ev.item;
|
|
723
|
+
const item = this.responseItem(ev.item_id ?? done?.id);
|
|
724
|
+
if (done?.encrypted_content !== undefined || done?.summary !== undefined) {
|
|
725
|
+
// The backend may seal the reasoning only when the item completes; take it from there too.
|
|
726
|
+
const held = this.reasoningItems.get(typeof (ev.item_id ?? done?.id) === "string" ? String(ev.item_id ?? done?.id) : "");
|
|
727
|
+
if (held) {
|
|
728
|
+
if (typeof done.encrypted_content === "string" && done.encrypted_content)
|
|
729
|
+
held.encrypted = done.encrypted_content;
|
|
730
|
+
if (done.summary !== undefined)
|
|
731
|
+
held.summary = done.summary;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
if (item) {
|
|
517
735
|
item.done = true;
|
|
736
|
+
if (item.kind === "reasoning")
|
|
737
|
+
this.sealReasoningItem(item);
|
|
738
|
+
}
|
|
518
739
|
}
|
|
519
740
|
else if (type === "response.completed" || type === "response.incomplete") {
|
|
520
741
|
const response = ev.response;
|
|
@@ -535,6 +756,7 @@ export class OpenAiStreamMapper {
|
|
|
535
756
|
feed(ev, wire) {
|
|
536
757
|
if (this.finished)
|
|
537
758
|
return [];
|
|
759
|
+
this.wire = wire;
|
|
538
760
|
// Some OpenAI-compatible servers emit an ordinary `{error:{...}}` JSON SSE payload
|
|
539
761
|
// instead of the Responses `error` event shape. Do not finish it as a successful answer.
|
|
540
762
|
if (ev.error && typeof ev.error === "object") {
|
|
@@ -22,7 +22,22 @@
|
|
|
22
22
|
// any status or byte is sent, so a retry cannot splice two answers together. Once the first byte is
|
|
23
23
|
// out the turn is committed and this must not be used.
|
|
24
24
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
25
|
+
import { Agent, setGlobalDispatcher } from "undici";
|
|
25
26
|
import { classify } from "../pool.js";
|
|
27
|
+
/**
|
|
28
|
+
* How long a translated provider's response may stay silent. Node's `fetch` gives up after 300s
|
|
29
|
+
* without a byte (`UND_ERR_BODY_TIMEOUT`, reproduced 2026-10-14: an SSE response that went quiet was
|
|
30
|
+
* cut at 300.98s). A reasoning model says nothing while it thinks: Muse Spark 1.3 at xhigh on a
|
|
31
|
+
* 100k+ context thought for minutes per step, and every one of its 529s that day ended at 303–305s,
|
|
32
|
+
* while every step that finished did so under 280s — the whole step thrown away and asked again.
|
|
33
|
+
* OpenCode's own CLI has no such limit. Fifteen minutes is past Claude Code's own request timeout,
|
|
34
|
+
* so the client, not this, decides when a turn has taken too long.
|
|
35
|
+
*/
|
|
36
|
+
export const UPSTREAM_SILENCE_MS = 15 * 60_000;
|
|
37
|
+
/** Every `fetch` in this process waits `ms` for headers and between body bytes. Called at startup. */
|
|
38
|
+
export function allowLongSilences(ms = UPSTREAM_SILENCE_MS) {
|
|
39
|
+
setGlobalDispatcher(new Agent({ bodyTimeout: ms, headersTimeout: ms }));
|
|
40
|
+
}
|
|
26
41
|
/** Attempts in total — one ask plus nine retries, matching the CLI's own ceiling (2026-09-21). */
|
|
27
42
|
export const DEFAULT_ATTEMPTS = 10;
|
|
28
43
|
const RETRY_BASE_MS = 250;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// A translated reasoning model's own turn, carried through Claude Code's transcript.
|
|
2
|
+
//
|
|
3
|
+
// The Responses wire hands its model an opaque reasoning item — `{type:"reasoning", id, summary,
|
|
4
|
+
// encrypted_content}` — and expects it back, byte for byte, in the position it came from, beside the
|
|
5
|
+
// function calls of that same turn. That is not an optimisation: measured 2026-10-14 with Muse Spark
|
|
6
|
+
// 1.3 at xhigh on a real tool loop, runs whose previous reasoning was not returned spent
|
|
7
|
+
// 9.5k–24.5k reasoning tokens each (2,197 per step past 80k context) and one of three ran out of
|
|
8
|
+
// context without ever answering, where the same loop with the items returned spent 3.2k–4.3k per
|
|
9
|
+
// run (725 per step past 80k), answered in all three, and read 94% prompt cache. Dropping the reasoning is why a routed worker "thinks from
|
|
10
|
+
// scratch on every tool call" and then dies. DeepSeek's own Chat API states the same rule for
|
|
11
|
+
// `reasoning_content`: with `tools` present it "must be fully passed back to the API in all
|
|
12
|
+
// subsequent requests", even for turns that made no tool call, and the API answers a 400 otherwise
|
|
13
|
+
// (https://api-docs.deepseek.com/guides/thinking_mode/, read 2026-10-14).
|
|
14
|
+
//
|
|
15
|
+
// The router keeps no state of its own here. Claude Code resends the whole conversation every turn,
|
|
16
|
+
// so the transcript is the storage. The carrier is a `thinking` block's `signature`, which is a
|
|
17
|
+
// free-form string on the wire and is replayed with the block [measured 2026-10-14 against a fake
|
|
18
|
+
// upstream: a thinking block carrying our signature comes back on the next request of a tool loop
|
|
19
|
+
// with the signature byte-identical, and an *empty* thinking text is kept too — which is what a
|
|
20
|
+
// Responses reasoning item with no summary looks like]. Anthropic's own signature is opaque base64
|
|
21
|
+
// this router cannot forge; ours is a small envelope that names the wire, the provider, the model
|
|
22
|
+
// and the vendor's own reasoning, and it is decoded and dropped by `thinking.ts` before a request
|
|
23
|
+
// reaches Anthropic.
|
|
24
|
+
//
|
|
25
|
+
// opencodex (github.com/lidge-jun/opencodex, devlog/_fin/260711_claude_inbound/003_evidence.md
|
|
26
|
+
// lines 93-118) reached the same conclusion and the same carrier — a signature on a thinking block,
|
|
27
|
+
// read back out of the client's own conversation — and we rely on that as a behavioral
|
|
28
|
+
// specification only; no code was copied.
|
|
29
|
+
//
|
|
30
|
+
// `encrypted_content` is the vendor's own sealed blob. It goes in the envelope untouched and comes
|
|
31
|
+
// back untouched: re-encoding or re-signing it would be the "re-signed reasoning" mistake that cost
|
|
32
|
+
// an earlier translator 12–20% cache hit (ARCHITECTURE §4, "Cache-safety decisions").
|
|
33
|
+
//
|
|
34
|
+
// `include:["reasoning.encrypted_content"]` is asked for on every turn where the vendor is known to
|
|
35
|
+
// take it — the ChatGPT backend, as Codex asks, and a preset marked `sealedReasoning` (presets.ts),
|
|
36
|
+
// as OpenCode's own client asks. A `store: false` backend that is not asked hands back nothing, so
|
|
37
|
+
// waiting until a conversation already carried reasoning would never start one. For any other
|
|
38
|
+
// Responses vendor it is asked for only once the conversation already carries that vendor's
|
|
39
|
+
// reasoning, so a vendor never measured keeps the request it had. Measured 2026-10-14 against
|
|
40
|
+
// OpenCode Go (`muse-spark-1.3-contributor`, one tool-bearing turn): the sealed item came back with
|
|
41
|
+
// `include` and without it, both 1426 bytes, and replaying it in place answered 200. The same vendor
|
|
42
|
+
// also accepted a tampered blob and a turn with the item dropped entirely (both 200), so the 4xx
|
|
43
|
+
// retry below is a safety net for a vendor or an account that is stricter.
|
|
44
|
+
/** Names an envelope as ours. Anthropic's own signatures never start with it. */
|
|
45
|
+
export const ENVELOPE_PREFIX = "clauderipple.v1:";
|
|
46
|
+
/** Thrown when a signature looks like ours and does not decode. */
|
|
47
|
+
export class UndecodableEnvelope extends Error {
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Our marker on a block, without decoding it: the cheap test `thinking.ts` and the request builders
|
|
51
|
+
* need before doing any work. Anthropic's own signatures are base64 and never carry this prefix.
|
|
52
|
+
*/
|
|
53
|
+
export function isOurSignature(signature) {
|
|
54
|
+
return typeof signature === "string" && signature.startsWith(ENVELOPE_PREFIX);
|
|
55
|
+
}
|
|
56
|
+
/** The `signature` of a block of ours: the envelope, base64url so it survives any signature field. */
|
|
57
|
+
export function encodeEnvelope(envelope) {
|
|
58
|
+
return ENVELOPE_PREFIX + Buffer.from(JSON.stringify(envelope), "utf8").toString("base64url");
|
|
59
|
+
}
|
|
60
|
+
function envelope(value) {
|
|
61
|
+
if (!value || typeof value !== "object")
|
|
62
|
+
return null;
|
|
63
|
+
const e = value;
|
|
64
|
+
if (typeof e.p !== "string" || typeof e.m !== "string")
|
|
65
|
+
return null;
|
|
66
|
+
if (e.w === "chat")
|
|
67
|
+
return typeof e.f === "string" && e.f.length > 0 ? { w: "chat", p: e.p, m: e.m, f: e.f } : null;
|
|
68
|
+
if (e.w === "responses")
|
|
69
|
+
return e.r && typeof e.r === "object" ? { w: "responses", p: e.p, m: e.m, r: e.r } : null;
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The envelope on a signature, or null when it is not ours. A signature that carries our prefix and
|
|
74
|
+
* does not decode is a broken carrier, not a foreign one, so it throws rather than passing silently
|
|
75
|
+
* as someone else's — the caller logs it and moves on.
|
|
76
|
+
*/
|
|
77
|
+
export function decodeEnvelope(signature) {
|
|
78
|
+
if (!isOurSignature(signature))
|
|
79
|
+
return null;
|
|
80
|
+
const text = signature.slice(ENVELOPE_PREFIX.length);
|
|
81
|
+
// Guard the decode: a signature is attacker-visible text and a gigabyte of base64 must not become
|
|
82
|
+
// a gigabyte of JSON. A sealed reasoning blob runs a few KB.
|
|
83
|
+
if (text.length > 512 * 1024)
|
|
84
|
+
throw new UndecodableEnvelope("envelope too large");
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(Buffer.from(text, "base64url").toString("utf8"));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new UndecodableEnvelope("envelope did not decode");
|
|
91
|
+
}
|
|
92
|
+
const decoded = envelope(parsed);
|
|
93
|
+
if (!decoded)
|
|
94
|
+
throw new UndecodableEnvelope("envelope was not a recognizable shape");
|
|
95
|
+
return decoded;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Whether a stored item is worth replaying at all. A reasoning item with no `encrypted_content`
|
|
99
|
+
* carries nothing the backend can resume — the summary is text it thinks in, not state — so it is
|
|
100
|
+
* not carried and never replayed. Measured 2026-10-14 (Codex records, 161 items across one day):
|
|
101
|
+
* every item the CLI replayed had the sealed blob, and most had an empty summary.
|
|
102
|
+
*/
|
|
103
|
+
export function replayableReasoning(stored) {
|
|
104
|
+
if (stored.type !== "reasoning")
|
|
105
|
+
return null;
|
|
106
|
+
const encrypted = stored.encrypted_content;
|
|
107
|
+
if (typeof encrypted !== "string" || encrypted.length === 0)
|
|
108
|
+
return null;
|
|
109
|
+
// Only the fields the wire defines are forwarded: a vendor's item may carry bookkeeping
|
|
110
|
+
// (`internal_chat_message_metadata_passthrough`) that is meaningless and unverified to send back.
|
|
111
|
+
const out = { type: "reasoning", encrypted_content: encrypted };
|
|
112
|
+
if (typeof stored.id === "string" && stored.id.length > 0)
|
|
113
|
+
out.id = stored.id;
|
|
114
|
+
if (Array.isArray(stored.summary))
|
|
115
|
+
out.summary = stored.summary;
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The envelope on an assistant block, when it is ours *and* usable here: the right wire, and the
|
|
120
|
+
* same provider and model that made it. Anything else returns null as if the block were foreign —
|
|
121
|
+
* reasoning is state, and state from another model is not merely useless, it is the thing a vendor
|
|
122
|
+
* refuses. A signature that is ours but broken, or names another model, reports through `onDrop` so
|
|
123
|
+
* the caller can count it; neither is an error worth failing a turn over.
|
|
124
|
+
*/
|
|
125
|
+
export function envelopeFor(block, wire, env, onDrop) {
|
|
126
|
+
const b = block;
|
|
127
|
+
if (!b || typeof b !== "object" || b.type !== "thinking" || !isOurSignature(b.signature))
|
|
128
|
+
return null;
|
|
129
|
+
let decoded;
|
|
130
|
+
try {
|
|
131
|
+
const got = decodeEnvelope(b.signature);
|
|
132
|
+
if (!got)
|
|
133
|
+
return null;
|
|
134
|
+
decoded = got;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
onDrop("the envelope did not decode");
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
if (decoded.w !== wire)
|
|
141
|
+
return null;
|
|
142
|
+
if (env && (decoded.p !== env.p || decoded.m !== env.m)) {
|
|
143
|
+
onDrop(`reasoning came from ${decoded.p}/${decoded.m}`);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
return decoded;
|
|
147
|
+
}
|
|
148
|
+
/** `type` matters as much as the wording: a vendor that says "reasoning" is answering about this. */
|
|
149
|
+
const REASONING_REFUSAL = /reasoning|encrypted|\binput\[\d+\]|\bitem\b|signature/i;
|
|
150
|
+
/**
|
|
151
|
+
* Whether a 4xx refusal is about the reasoning that was replayed, and so is worth one retry without
|
|
152
|
+
* it. Only a request that carried replayed reasoning can be refused for carrying it, and a sealed
|
|
153
|
+
* blob minted on one account or one model is the thing most likely to be refused on another.
|
|
154
|
+
*/
|
|
155
|
+
export function refusesReplayedReasoning(status, message) {
|
|
156
|
+
return status >= 400 && status < 500 && (status === 422 || REASONING_REFUSAL.test(message));
|
|
157
|
+
}
|
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
// Thinking blocks another model left in a conversation that is now going to Anthropic.
|
|
2
2
|
//
|
|
3
|
-
// The translated providers hand the CLI their reasoning as `thinking` blocks
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
3
|
+
// The translated providers hand the CLI their reasoning as `thinking` blocks, and an empty `thinking`
|
|
4
|
+
// when the upstream sent a reasoning item without a summary. The CLI keeps them in the transcript, so
|
|
5
|
+
// a session switched from such a model to a Claude model replays them, and Anthropic refuses the
|
|
6
|
+
// whole request: "messages.2.content.0.thinking: each thinking block must contain thinking" (measured
|
|
7
|
+
// 2026-09-28, gpt-6-astra → claude-opus-5-5, every retry of the turn).
|
|
8
|
+
//
|
|
9
|
+
// A block Anthropic wrote always carries a signature of Anthropic's own, a base64 string this router
|
|
10
|
+
// cannot produce. A translation provider cannot produce one either, so it signs with an envelope of
|
|
11
|
+
// its own naming where the reasoning came from (reasoning.ts) — which Anthropic would refuse just as
|
|
12
|
+
// fast, and which is why those go too. What is left that is signed is Anthropic's.
|
|
13
|
+
//
|
|
14
|
+
// The translated adapters themselves keep their own replayed reasoning (that is the point of the
|
|
15
|
+
// envelope), so this is the one place a translated provider's thinking must still be dropped: a
|
|
16
|
+
// CLI-side switch to a Claude model. Gemini and the other translators drop `thinking` on their own.
|
|
17
|
+
import { isOurSignature } from "./reasoning.js";
|
|
11
18
|
function foreign(block) {
|
|
12
19
|
const b = block;
|
|
13
|
-
|
|
20
|
+
if (!b || typeof b !== "object" || b.type !== "thinking")
|
|
21
|
+
return false;
|
|
22
|
+
// Unsigned: a translated provider's reasoning with nothing to carry, or an empty reasoning item.
|
|
23
|
+
// Ours: a translated provider's envelope, which only its own adapter can read back.
|
|
24
|
+
return !(typeof b.signature === "string" && b.signature.length > 0) || isOurSignature(b.signature);
|
|
14
25
|
}
|
|
15
26
|
/**
|
|
16
27
|
* Removes unsigned thinking blocks from assistant turns, in place, and returns how many went.
|