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.
@@ -3,11 +3,15 @@
3
3
  // Prompt-cache rule: the Responses `input` we build must be a byte-stable prefix of the
4
4
  // next turn's `input`. Claude Code resends the whole history every turn, so the mapping
5
5
  // has to be deterministic and must not inject anything that varies (timestamps, salts,
6
- // re-signed reasoning). Thinking blocks from earlier assistant turns are dropped for the
7
- // same reason. `prompt_cache_key` is derived from the conversation's first user message — or,
6
+ // re-signed reasoning). A reasoning item that came back from the backend is the exception
7
+ // that proves the rule: it is put back exactly as it arrived, so the bytes are the
8
+ // backend's own, and a turn that carried one maps the same way every time. Thinking
9
+ // blocks with no stored item still have nothing to put back and are dropped as before.
10
+ // `prompt_cache_key` is derived from the conversation's first user message — or,
8
11
  // for a request that is not a conversation at all, from its system prompt (see `conversationKey`).
9
12
  import crypto from "node:crypto";
10
13
  import { identityLine } from "../../identity.js";
14
+ import { encodeEnvelope, envelopeFor, isOurSignature, replayableReasoning } from "../../reasoning.js";
11
15
  function blockText(c) {
12
16
  if (typeof c === "string")
13
17
  return c;
@@ -235,6 +239,7 @@ export function toResponsesRequest(req, opts) {
235
239
  // whose call_id has no function_call in the same input ("No tool call found…", measured 2026-09-13).
236
240
  // Such results are sent as plain user text instead.
237
241
  const knownCalls = new Set();
242
+ let lastCallId;
238
243
  for (const m of req.messages) {
239
244
  const role = m.role === "assistant" ? "assistant" : "user";
240
245
  if (typeof m.content === "string") {
@@ -251,6 +256,32 @@ export function toResponsesRequest(req, opts) {
251
256
  pending = [];
252
257
  };
253
258
  for (const b of m.content) {
259
+ // The reasoning this turn came back with, put back exactly where it was: the CLI replays the
260
+ // block in front of the function calls it belongs to, so iterating in order reproduces the
261
+ // original positions, parallel calls included. `lastCallId` is the call the carrier arrived
262
+ // after — the response path addresses the next turn's summary to it (see `onArrival`) — and is
263
+ // undefined for a lead-in item, which is where such a summary belongs.
264
+ const decoded = envelopeFor(b, "responses", opts.env, (reason) => {
265
+ (opts.carried ?? (opts.carried = { replayed: 0, undecodable: 0 })).undecodable++;
266
+ if (opts.replay !== false)
267
+ opts.log?.(`dropped a reasoning carrier: ${reason}`);
268
+ });
269
+ // A carrier for the other wire is not ours to touch and falls through to the drop below.
270
+ if (decoded) {
271
+ if (opts.replay !== false) {
272
+ const item = replayableReasoning(decoded.r);
273
+ if (item) {
274
+ flush();
275
+ input.push(item);
276
+ (opts.carried ?? (opts.carried = { replayed: 0, undecodable: 0 })).replayed++;
277
+ opts.onArrival?.(item, lastCallId);
278
+ }
279
+ else {
280
+ (opts.carried ?? (opts.carried = { replayed: 0, undecodable: 0 })).undecodable++;
281
+ }
282
+ }
283
+ continue;
284
+ }
254
285
  switch (b.type) {
255
286
  case "text": {
256
287
  const t = b.text;
@@ -272,6 +303,7 @@ export function toResponsesRequest(req, opts) {
272
303
  flush();
273
304
  const tu = b;
274
305
  knownCalls.add(tu.id);
306
+ lastCallId = tu.id;
275
307
  input.push({ type: "function_call", call_id: tu.id, name: toolNameForResponses(tu.name), arguments: typeof tu.input === "string" ? tu.input : JSON.stringify(tu.input ?? {}) });
276
308
  break;
277
309
  }
@@ -293,7 +325,8 @@ export function toResponsesRequest(req, opts) {
293
325
  break;
294
326
  }
295
327
  default:
296
- // thinking / redacted_thinking / unknown: dropped on purpose (see header comment)
328
+ // thinking, an unmarked redacted_thinking, anything unknown: no stored item to put back,
329
+ // so dropped as before (see header comment)
297
330
  break;
298
331
  }
299
332
  }
@@ -327,6 +360,11 @@ export function toResponsesRequest(req, opts) {
327
360
  prompt_cache_key: conversationId(req),
328
361
  client_metadata: { session_id: conversationId(req), thread_id: conversationId(req), turn_id: crypto.randomUUID(), "x-codex-window-id": `${conversationId(req)}:0` },
329
362
  };
363
+ // On every turn, as Codex asks. With `store: false` the backend keeps nothing, and without
364
+ // `include` it hands back no sealed item either — so a conversation that waited until it already
365
+ // carried reasoning before asking would never carry any. Only the retry after a refusal leaves it off.
366
+ if (opts.replay !== false)
367
+ out.include = ["reasoning.encrypted_content"];
330
368
  if (tools.length > 0) {
331
369
  out.tools = tools;
332
370
  out.parallel_tool_calls = !(tc?.disable_parallel_tool_use ?? false);
@@ -348,6 +386,7 @@ export function estimateTokens(req) {
348
386
  export class StreamMapper {
349
387
  started = false;
350
388
  blockIndex = -1;
389
+ /** `signed` marks a thinking block that already carries its signature — the envelope we wrote. */
351
390
  open = null;
352
391
  sawToolCall = false;
353
392
  finished = false;
@@ -360,14 +399,30 @@ export class StreamMapper {
360
399
  /** Accumulated content for non-streaming responses. */
361
400
  content = [];
362
401
  stopReason = "end_turn";
402
+ /**
403
+ * How many reasoning items this response carried whole — the ones the client will replay. The
404
+ * `reasoning=` figure the request log shows next to `replayed=`.
405
+ */
406
+ reasoningCount = 0;
407
+ /** The reasoning item being assembled: its identity so far and the call it followed. */
408
+ pendingReasoning = null;
409
+ /** The most recent function_call item added, which is what a following reasoning item describes. */
410
+ lastCallId;
363
411
  /** Input-token figure announced in message_start (the real one only arrives with response.completed). */
364
412
  startInput;
365
413
  /** Mangled tool name → the name Claude Code knows, from `toolNameRestoreMap`. */
366
414
  toolNames;
367
- constructor(model, startInput = 0, toolNames = new Map()) {
415
+ /**
416
+ * The provider name that goes in each reasoning envelope (see reasoning.ts). A blob sealed here is
417
+ * only sent back here: reasoning outlives the request that made it, and a conversation moved to
418
+ * another provider must not carry this one's sealed state with it.
419
+ */
420
+ provider;
421
+ constructor(model, startInput = 0, toolNames = new Map(), provider = "") {
368
422
  this.model = model;
369
423
  this.startInput = startInput;
370
424
  this.toolNames = toolNames;
425
+ this.provider = provider;
371
426
  this.messageId = `msg_${crypto.randomBytes(12).toString("hex")}`;
372
427
  }
373
428
  get isFinished() {
@@ -393,11 +448,17 @@ export class StreamMapper {
393
448
  },
394
449
  ];
395
450
  }
451
+ /**
452
+ * A thinking block must carry a signature even when there is none to give — an unsigned one is
453
+ * refused the moment the session moves to a Claude model (`thinking.ts`) — so a block that was
454
+ * opened without one gets an empty `signature_delta` as it closes. A block that already carries
455
+ * the envelope is left alone: a second, empty delta would replace it on the client.
456
+ */
396
457
  closeBlock() {
397
458
  if (!this.open)
398
459
  return [];
399
460
  const evs = [];
400
- if (this.open.kind === "thinking")
461
+ if (this.open.kind === "thinking" && !this.open.signed)
401
462
  evs.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.blockIndex, delta: { type: "signature_delta", signature: "" } } });
402
463
  evs.push({ event: "content_block_stop", data: { type: "content_block_stop", index: this.blockIndex } });
403
464
  this.open = null;
@@ -410,6 +471,56 @@ export class StreamMapper {
410
471
  evs.push({ event: "content_block_start", data: { type: "content_block_start", index: this.blockIndex, content_block: block } });
411
472
  return evs;
412
473
  }
474
+ /**
475
+ * Finish the reasoning item being assembled and hand the client the block that carries it: a
476
+ * thinking block whose text is the summary (empty when the backend sent none — measured
477
+ * 2026-10-14: Claude Code keeps and replays such a block, signature and all) and whose signature
478
+ * is the envelope around the sealed reasoning.
479
+ *
480
+ * An item with nothing sealed is only worth its summary, and then only if it has one: there is
481
+ * nothing to replay, and an empty thinking block is refused by Anthropic the moment the session
482
+ * switches to a Claude model (`thinking.ts`).
483
+ */
484
+ sealReasoning() {
485
+ const pending = this.pendingReasoning;
486
+ if (!pending)
487
+ return [];
488
+ this.pendingReasoning = null;
489
+ const text = pending.text.trim() || (typeof pending.summary === "string" ? pending.summary.trim() : "");
490
+ const events = [];
491
+ if (!pending.encrypted) {
492
+ if (text) {
493
+ events.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }));
494
+ events.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.blockIndex, delta: { type: "thinking_delta", thinking: text } } });
495
+ events.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.blockIndex, delta: { type: "signature_delta", signature: "" } } });
496
+ events.push(...this.closeBlock());
497
+ this.content.push({ type: "thinking", thinking: text, signature: "" });
498
+ }
499
+ return events;
500
+ }
501
+ const item = { type: "reasoning", encrypted_content: pending.encrypted };
502
+ if (pending.id)
503
+ item.id = pending.id;
504
+ if (Array.isArray(pending.summary))
505
+ item.summary = pending.summary;
506
+ const signature = encodeEnvelope(this.envelopeFor(item));
507
+ events.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }));
508
+ if (text)
509
+ events.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.blockIndex, delta: { type: "thinking_delta", thinking: text } } });
510
+ // The signature must arrive on this very block: it is the carrier, and the client keeps the block
511
+ // whole or not at all. It goes last, after the text, which is the order Anthropic's own blocks use.
512
+ events.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.blockIndex, delta: { type: "signature_delta", signature } } });
513
+ if (this.open)
514
+ this.open.signed = true;
515
+ events.push(...this.closeBlock());
516
+ this.content.push({ type: "thinking", thinking: text, signature });
517
+ this.reasoningCount++;
518
+ return events;
519
+ }
520
+ /** Where this item's envelope says it came from — this adapter's own provider and model. */
521
+ envelopeFor(item) {
522
+ return { w: "responses", p: this.provider, m: this.model, r: item };
523
+ }
413
524
  /** Map one upstream event. Returns Anthropic events to emit (possibly none). */
414
525
  feed(ev) {
415
526
  const type = ev.type;
@@ -426,12 +537,23 @@ export class StreamMapper {
426
537
  // The model echoes the mangled name; Claude Code only recognises the original.
427
538
  const called = item.name ?? "tool";
428
539
  const name = this.toolNames.get(called) ?? called;
540
+ this.lastCallId = id;
429
541
  this.content.push({ type: "tool_use", id, name, input: {}, _args: "" });
430
542
  out.push(...this.openBlock("tool", { type: "tool_use", id, name, input: {} }, item.id));
431
543
  }
432
544
  else if (item.type === "reasoning") {
433
- this.content.push({ type: "thinking", thinking: "", signature: "" });
434
- out.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }, item.id));
545
+ // The item's reasoning is held until it is complete and handed over whole (see
546
+ // `sealReasoning`): one thinking block whose signature carries the envelope. The summary,
547
+ // when the backend sends one, is that block's text — and the summary deltas below fill it
548
+ // in. Passing the summary through `openBlock` here as well would split one item across two
549
+ // blocks, so this opens nothing.
550
+ this.pendingReasoning = {
551
+ ...(item.id ? { id: item.id } : {}),
552
+ ...(typeof item.encrypted_content === "string" && item.encrypted_content ? { encrypted: item.encrypted_content } : {}),
553
+ ...(item.summary !== undefined ? { summary: item.summary } : {}),
554
+ text: "",
555
+ callId: this.lastCallId,
556
+ };
435
557
  }
436
558
  else if (item.type === "message") {
437
559
  this.content.push({ type: "text", text: "" });
@@ -454,6 +576,13 @@ export class StreamMapper {
454
576
  case "response.reasoning_summary_text.delta":
455
577
  case "response.reasoning_text.delta": {
456
578
  const delta = String(ev.delta ?? "");
579
+ // The summary is the text of the reasoning item's block, and that block also carries the
580
+ // envelope — both are written when the item is done, so the block is not opened here. One
581
+ // item is one block; streaming the text into a block of its own would hand the client two.
582
+ if (this.pendingReasoning) {
583
+ this.pendingReasoning.text += delta;
584
+ break;
585
+ }
457
586
  if (!this.open || this.open.kind !== "thinking") {
458
587
  this.content.push({ type: "thinking", thinking: "", signature: "" });
459
588
  out.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }));
@@ -488,7 +617,18 @@ export class StreamMapper {
488
617
  break;
489
618
  }
490
619
  case "response.output_item.done": {
620
+ const done = ev.item;
621
+ // The backend may put `encrypted_content` only on the completed item, so the seal is taken
622
+ // here as well as from `response.output_item.added`, and the item is only handed over now
623
+ // anyway — a half-assembled reasoning block must not reach the client.
624
+ if (done?.type === "reasoning" && this.pendingReasoning) {
625
+ if (typeof done.encrypted_content === "string" && done.encrypted_content)
626
+ this.pendingReasoning.encrypted = done.encrypted_content;
627
+ if (done.summary !== undefined)
628
+ this.pendingReasoning.summary = done.summary;
629
+ }
491
630
  out.push(...this.closeBlock());
631
+ out.push(...this.sealReasoning());
492
632
  break;
493
633
  }
494
634
  case "response.completed":
@@ -8,6 +8,8 @@ import { credentialHeaderValues, redactErrorText } from "../../redact.js";
8
8
  import { SseParser } from "../chatgpt/sse.js";
9
9
  import { fetchWithRetry } from "../retry.js";
10
10
  import { estimateTokens, formatSse, OpenAiStreamMapper, toOpenAiRequest } from "./translate.js";
11
+ import { refusesReplayedReasoning } from "../../reasoning.js";
12
+ import { PRESETS } from "../../presets.js";
11
13
  import { conversationKey, serverToolNames, toolNameRestoreMap } from "../chatgpt/translate.js";
12
14
  const PING_MS = 15_000;
13
15
  function anthropicError(status, type, message) {
@@ -110,15 +112,25 @@ export class OpenAiCompatibleAdapter {
110
112
  const serverTools = serverToolNames(json.tools);
111
113
  if (serverTools.size > 0)
112
114
  this.log.warn(`${this.tag} ${this.name}: dropped server tools for ${model}: ${[...serverTools].join(", ")} (Anthropic runs these; this provider cannot)`);
113
- const upstreamRequest = toOpenAiRequest(json, {
115
+ // The conversation is the storage for a reasoning model's reasoning (reasoning.ts), so what to
116
+ // replay is read out of the request itself and the tally is reported in the request log. `env`
117
+ // names this provider and model: reasoning sealed by another model is never replayed here.
118
+ const carried = { replayed: 0, undecodable: 0 };
119
+ const translate = (replay = true) => toOpenAiRequest(json, {
114
120
  model,
115
121
  wire,
116
122
  ...(effort ? { effort } : {}),
117
123
  caps,
124
+ replay,
125
+ env: { p: this.name, m: model },
126
+ ...(PRESETS.find((entry) => entry.id === this.cfg.preset)?.sealedReasoning ? { sealedReasoning: true } : {}),
127
+ carried,
128
+ log: (line) => this.log.info(`${this.tag} ${this.name}: ${line} (${model})`),
118
129
  ...(this.cfg.identity === undefined ? {} : { identity: this.cfg.identity }),
119
130
  ...(this.cfg.instructionsAppend ? { instructionsAppend: this.cfg.instructionsAppend } : {}),
120
131
  });
121
- const requestBody = JSON.stringify(upstreamRequest);
132
+ const upstreamRequest = translate();
133
+ let requestBody = JSON.stringify(upstreamRequest);
122
134
  // Some vendors key their prompt cache on a session header rather than on the request's own
123
135
  // shape, and hand a cold cache to anyone who does not send one. `conversationKey` is the value
124
136
  // this codebase already trusts to be stable for one conversation and different between two.
@@ -173,6 +185,17 @@ export class OpenAiCompatibleAdapter {
173
185
  upstream = new Response(refusal, { status: 401 });
174
186
  }
175
187
  }
188
+ // A 4xx that names reasoning is this vendor refusing the reasoning the conversation carried
189
+ // back — a field it does not take, or a value from another model. The turn goes again without
190
+ // it: replaying is an optimisation the user's turn must never depend on (reasoning.ts).
191
+ if (carried.replayed > 0 && refusesReplayedReasoning(upstream.status, await upstream.clone().text().catch(() => ""))) {
192
+ const refusal = await upstream.text().catch(() => "");
193
+ this.log.warn(`${this.tag} ${this.name}: upstream ${upstream.status} refused ${carried.replayed} replayed reasoning turn(s) for ${model}; asking again without them: ${refusal.replace(/\s+/g, " ").slice(0, 200)}`);
194
+ carried.replayed = 0;
195
+ carried.undecodable = 0;
196
+ requestBody = JSON.stringify(translate(false));
197
+ upstream = await ask();
198
+ }
176
199
  }
177
200
  catch (error) {
178
201
  res.off("close", onClose);
@@ -202,7 +225,8 @@ export class OpenAiCompatibleAdapter {
202
225
  return { status: out.status, bytes: Buffer.byteLength(out.body), note: `upstream ${upstream.status}`, exhaustion: providerExhaustion(upstream.status, text, retryAfterMs(Object.fromEntries(upstream.headers))) };
203
226
  }
204
227
  const wantStream = json.stream === true;
205
- const mapper = new OpenAiStreamMapper(model, startInput, toolNameRestoreMap(json));
228
+ const mapper = new OpenAiStreamMapper(model, startInput, toolNameRestoreMap(json), { p: this.name, m: model });
229
+ mapper.wire = wire;
206
230
  mapper.redactError = redact;
207
231
  const parser = new SseParser();
208
232
  const reader = upstream.body.getReader();
@@ -283,8 +307,8 @@ export class OpenAiCompatibleAdapter {
283
307
  exhaustion: failure ? providerExhaustion(429, `${failure.code ?? ""} ${failure.message}`, retryAfterMs(Object.fromEntries(upstream.headers))) : undefined,
284
308
  bytes,
285
309
  note: failure
286
- ? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens})`
287
- : `in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens} stop=${mapper.stopReason}`,
310
+ ? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens} replayed=${carried.replayed} reasoning=${mapper.reasoningCount})`
311
+ : `in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens} stop=${mapper.stopReason} replayed=${carried.replayed} reasoning=${mapper.reasoningCount}`,
288
312
  usage: { input: usage.input_tokens, cached: usage.cache_read_input_tokens, output: usage.output_tokens },
289
313
  stopReason: mapper.stopReason,
290
314
  ...(wantStream || !failure ? { messageId: mapper.messageId } : {}),