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.
@@ -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: toResponsesInput(req),
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.index;
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
- return [{ event: "content_block_stop", data: { type: "content_block_stop", index } }];
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
- openBlock(kind, contentBlock, toolIndex) {
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
- this.open = { kind, index, ...(toolIndex === undefined ? {} : { toolIndex }) };
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
- const reasoning = typeof delta.reasoning_content === "string" && delta.reasoning_content ? delta.reasoning_content
376
- : typeof delta.reasoning === "string" && delta.reasoning ? delta.reasoning : "";
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
- this.content.push({ type: "thinking", thinking: "" });
380
- out.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }));
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 last = this.content[this.content.length - 1];
383
- if (last?.type === "thinking")
384
- last.thinking += reasoning;
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
- this.content.push({ type: "text", text: "" });
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 last = this.content[this.content.length - 1];
393
- if (last?.type === "text")
394
- last.text += delta.content;
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 item = this.responseItem(ev.item_id ?? ev.item?.id);
516
- if (item)
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 with no signature —
4
- // there is no Anthropic signature to give — and an empty `thinking` when the upstream sent a
5
- // reasoning item without a summary. The CLI keeps them in the transcript, so a session switched
6
- // from such a model to a Claude model replays them, and Anthropic refuses the whole request:
7
- // "messages.2.content.0.thinking: each thinking block must contain thinking" (measured 2026-09-28,
8
- // gpt-6-astra → claude-opus-5-5, every retry of the turn). A block Anthropic wrote always carries a
9
- // signature, so an unsigned one is foreign and goes; the translated providers drop thinking from
10
- // replayed history for the same reason (ARCHITECTURE §4, "Cache-safety decisions").
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
- return !!b && typeof b === "object" && b.type === "thinking" && !(typeof b.signature === "string" && b.signature.length > 0);
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.