clauderipple 0.9.4 → 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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.5 — 2026-10-14
4
+
5
+ ### Fixed
6
+
7
+ - **Routed reasoning models keep their train of thought between tool calls.** The router dropped
8
+ every translated model's previous reasoning, so on each tool call Muse, GPT and DeepSeek reasoned
9
+ the whole task again from scratch. Workers took minutes per step, kept re-deriving the same plan,
10
+ and sometimes stopped with no answer. The model's own reasoning now travels in Claude Code's
11
+ transcript and goes back to it unchanged, the way OpenCode's and Codex's own clients do it. On four
12
+ real fixes graded by their hidden tests, Muse Spark 1.3 at xhigh finished in 90–584 seconds, where
13
+ 0.9.4 took up to 13 minutes and twice ran past 30 minutes without finishing. Switching a session
14
+ to a Claude model still works: the carried reasoning is left out of requests to Anthropic.
15
+
3
16
  ## 0.9.4 — 2026-10-14
4
17
 
5
18
  ### Fixed
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "private": true,
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
4
4
  "type": "commonjs"
5
5
  }
@@ -199,6 +199,9 @@ export const PRESETS = [
199
199
  authHeader: "authorization-bearer",
200
200
  wire: "responses",
201
201
  sessionHeader: "x-opencode-session",
202
+ // Measured 2026-10-14 on muse-spark-1.3-contributor: `include` and `summary` are accepted, the
203
+ // sealed item comes back on a tool-bearing turn, and replaying it in place answers 200.
204
+ sealedReasoning: true,
202
205
  modelsUrl: "https://opencode.ai/zen/go/v1/models",
203
206
  modelsAuthHeader: "authorization-bearer",
204
207
  // Every wire below is from the endpoint table OpenCode publishes per model
@@ -8,6 +8,7 @@ import { SseParser } from "./sse.js";
8
8
  import { fetchWithRetry } from "../retry.js";
9
9
  import { looksLikeAuth } from "../openai/index.js";
10
10
  import { StreamMapper, conversationKey, estimateTokens, formatSse, serverToolNames, toResponsesRequest, toolNameRestoreMap } from "./translate.js";
11
+ import { refusesReplayedReasoning } from "../../reasoning.js";
11
12
  import { providerExhaustion } from "../../health.js";
12
13
  import { credentialHeaderValues, redactErrorText } from "../../redact.js";
13
14
  import { codexClientVersion, latestCodexVersion, parseCodexCatalog } from "./catalog.js";
@@ -963,6 +964,27 @@ export class ChatGptAdapter {
963
964
  res.end();
964
965
  return { status: upstream.status, bytes, ...(usage ? { usage } : {}), note: `codex passthrough account=${owner ? owner.slice(0, 8) : "caller"}` };
965
966
  }
967
+ /**
968
+ * The options one turn is translated with. The conversation is the storage (reasoning.ts), so what
969
+ * to replay is read out of the request itself; `carried` is filled in as the input is built and the
970
+ * outcome lands in the request log. `replay: false` is the retry after the backend refused that
971
+ * carrier, and it puts back exactly the request this adapter sent before any of this existed.
972
+ */
973
+ responsesOptions(carried, model, effort, replay = true) {
974
+ return {
975
+ model,
976
+ effort: effort ?? this.cfg.defaultEffort ?? "high",
977
+ identity: this.cfg.identity ?? true,
978
+ ...(this.cfg.instructionsAppend ? { instructionsAppend: this.cfg.instructionsAppend } : {}),
979
+ replay,
980
+ // The envelope names this provider and this model, and the request builder refuses anything
981
+ // else: reasoning outlives the turn that made it, and a conversation moved to another model
982
+ // must not carry the old one's sealed state with it (reasoning.ts).
983
+ env: { p: this.name, m: model },
984
+ carried,
985
+ log: (line) => this.log.info(`chatgpt ${this.name}: ${line} (${model})`),
986
+ };
987
+ }
966
988
  /** Handle a fully-read Messages request. `model`/`effort` already resolved by routing. */
967
989
  async handle(req, res, path, json, model, effort) {
968
990
  if (path.startsWith("/v1/messages/count_tokens")) {
@@ -975,13 +997,9 @@ export class ChatGptAdapter {
975
997
  const serverTools = serverToolNames(json.tools);
976
998
  if (serverTools.size > 0)
977
999
  this.log.warn(`chatgpt ${this.name}: dropped server tools for ${model}: ${[...serverTools].join(", ")} (Anthropic runs these; this provider cannot)`);
978
- const upstreamReq = toResponsesRequest(json, {
979
- model,
980
- effort: effort ?? this.cfg.defaultEffort ?? "high",
981
- identity: this.cfg.identity ?? true,
982
- ...(this.cfg.instructionsAppend ? { instructionsAppend: this.cfg.instructionsAppend } : {}),
983
- });
984
- const body = JSON.stringify(upstreamReq);
1000
+ const carried = { replayed: 0, undecodable: 0 };
1001
+ let upstreamReq = toResponsesRequest(json, this.responsesOptions(carried, model, effort));
1002
+ let body = JSON.stringify(upstreamReq);
985
1003
  const cacheKey = upstreamReq.prompt_cache_key;
986
1004
  // Input estimate for message_start: a conversation only grows, so the last measured total is a floor.
987
1005
  const startInput = Math.max(estimateTokens(json), this.lastInputByKey.get(cacheKey) ?? 0);
@@ -991,7 +1009,7 @@ export class ChatGptAdapter {
991
1009
  // One account answers the turn. Which one is decided here, and a refusal before any byte has
992
1010
  // reached the client moves the same turn to the next account, so the client is answered on its
993
1011
  // first ask. The conversation stays on the account that answered: moving it costs the cache.
994
- const sent = await this.sendToAnAccount({
1012
+ const ask = () => this.sendToAnAccount({
995
1013
  conversation: cacheKey,
996
1014
  path: "/codex/responses",
997
1015
  body,
@@ -1014,6 +1032,18 @@ export class ChatGptAdapter {
1014
1032
  };
1015
1033
  },
1016
1034
  });
1035
+ let sent = await ask();
1036
+ // A 4xx that names reasoning is the backend refusing the sealed reasoning we replayed — the blob
1037
+ // was minted by the model that is no longer answering, or by another account. The summary is
1038
+ // worth nothing without it, so the turn is asked once without: replaying is an optimisation and
1039
+ // must never be the reason a user's turn fails (reasoning.ts).
1040
+ if (carried.replayed > 0 && sent.kind === "refused" && refusesReplayedReasoning(sent.status, sent.text)) {
1041
+ this.log.warn(`chatgpt ${this.name}: upstream ${sent.status} refused ${carried.replayed} replayed reasoning item(s) for ${model}; asking again without them: ${sent.text.slice(0, 200)}`);
1042
+ upstreamReq = toResponsesRequest(json, this.responsesOptions({ replayed: 0, undecodable: 0 }, model, effort, false));
1043
+ body = JSON.stringify(upstreamReq);
1044
+ carried.replayed = 0;
1045
+ sent = await ask();
1046
+ }
1017
1047
  if (sent.kind !== "ok") {
1018
1048
  res.off("close", onClose);
1019
1049
  if (sent.kind === "aborted")
@@ -1061,7 +1091,7 @@ export class ChatGptAdapter {
1061
1091
  if (this.cfg.debugDump === "all")
1062
1092
  this.dump(upstream.status, json, upstreamReq, "");
1063
1093
  const wantStream = json.stream === true;
1064
- const mapper = new StreamMapper(model, startInput, toolNameRestoreMap(json));
1094
+ const mapper = new StreamMapper(model, startInput, toolNameRestoreMap(json), this.name);
1065
1095
  const parser = new SseParser();
1066
1096
  let bytes = 0;
1067
1097
  let ping = null;
@@ -1179,8 +1209,8 @@ export class ChatGptAdapter {
1179
1209
  exhaustion,
1180
1210
  bytes,
1181
1211
  note: failure
1182
- ? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens})`
1183
- : `in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens} stop=${mapper.stopReason}${upstreamReq.service_tier ? " fast" : ""}`,
1212
+ ? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens} replayed=${carried.replayed} reasoning=${mapper.reasoningCount})`
1213
+ : `in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens} stop=${mapper.stopReason} replayed=${carried.replayed} reasoning=${mapper.reasoningCount}${upstreamReq.service_tier ? " fast" : ""}`,
1184
1214
  usage: {
1185
1215
  input: u.input_tokens,
1186
1216
  cached: u.cache_read_input_tokens,
@@ -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 } : {}),
@@ -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") {
@@ -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.
@@ -5,4 +5,4 @@
5
5
  // from the manifests. Until 0.1.1 the router and the CLI each carried their own literal, and both
6
6
  // still said "0.1.0" in the 0.1.1 release: after an update nobody could tell which router was
7
7
  // running, and the tray app had nothing to compare (2026-09-15, reported from a Windows install).
8
- export const VERSION = "0.9.4";
8
+ export const VERSION = "0.9.5";
@@ -731,8 +731,13 @@ must preserve **Data: Artificial Analysis (artificialanalysis.ai)** attribution.
731
731
  - `WebFetch` needs none of this: the CLI fetches the URL itself (its own transport, cache and
732
732
  preflight; there is no `web_fetch` server tool in the binary) and has the session's model read
733
733
  the text. It works on any provider.
734
- - Cache-safety decisions: thinking blocks are dropped from replayed history;
735
- no reasoning `include`; identity line and `instructionsAppend` are constant
734
+ - Cache-safety decisions: a translated provider's own reasoning that comes back with the
735
+ request is sent back **byte-identically, in its original position** — that is what the
736
+ vendor's native clients do, and it is not a `cache_control` breakpoint; the forbidden
737
+ thing is *altering or re-signing* reasoning, which is what cost 12–20% cache (2026-09-20).
738
+ A thinking block of ours that names no vendor model, or another model than this request's,
739
+ is dropped (§5, "Muse workers"); foreign thinking of any other kind is dropped from
740
+ replayed history; identity line and `instructionsAppend` are constant
736
741
  text; `prompt_cache_key` = sha256(metadata.user_id + first user message),
737
742
  and sha256(system prompt) for a request that carries no `metadata.user_id`
738
743
  and one lone user turn — see the conversation-key note in §4.
@@ -1061,9 +1066,11 @@ what we do not have yet — so that adding a provider does not start with readin
1061
1066
  config still written as three providers is folded on the way in and its `direct` and
1062
1067
  `routes` references follow; nothing is rewritten on disk, so it stays readable by an
1063
1068
  older router until the GUI saves it.
1064
- - Translation deliberately strips `thinking`, `context_management`, `thread`,
1069
+ - Translation deliberately strips `context_management`, `thread`,
1065
1070
  `diagnostics`, and `container`; `thread:continue` is refused exactly as in §4a so
1066
- the CLI resends a full stateless history. The per-turn
1071
+ the CLI resends a full stateless history. Thinking is stripped by default too —
1072
+ except the reasoning this adapter itself handed out, which returns as the vendor's
1073
+ own item (see the reasoning-replay note below). The per-turn
1067
1074
  `x-anthropic-billing-header` system block is also stripped because its changing
1068
1075
  value breaks every vendor's stable-prefix cache opportunity. Orphan tool results
1069
1076
  are sent as user text, not a tool result with no antecedent call.
@@ -1085,6 +1092,50 @@ what we do not have yet — so that adding a provider does not start with readin
1085
1092
  before (`catalog.ts`, `capabilities.ts`). Chat wire
1086
1093
  forwards Anthropic `stop_sequences` as `stop`; Responses wire deliberately omits
1087
1094
  them because its common stateless schema has no corresponding universal field.
1095
+ - **Reasoning replay (both wires, 2026-10-14).** A reasoning model on either wire
1096
+ expects its own previous reasoning back on the next turn of a tool loop, and does
1097
+ badly without it. Measured on OpenCode Go's Muse at `xhigh`, same task, 3 runs
1098
+ each: with no replay 9.5k–24.5k reasoning tokens per run (2,197 per step past 80k
1099
+ context) and one run exhausted the context with no answer; with replay 3.2k–4.3k
1100
+ per run (725 per step past 80k) and all three answered, 94% cached.
1101
+ Native clients (OpenCode, the Codex CLI) do this by carrying the vendor's item in
1102
+ their own transcript and putting it back byte-for-byte where it was.
1103
+ Claude Code's transcript is the same medium, so the carrier is the **signature of a
1104
+ thinking block**: `reasoning.ts` encodes `clauderipple.v1:` + base64url JSON
1105
+ naming the wire, provider and model and the thing to restore — the vendor's whole
1106
+ Responses item for the Responses wire, or just the field name
1107
+ (`reasoning_content` / `reasoning`) for Chat. On the Responses wire the item is
1108
+ `{type:"reasoning", id, summary, encrypted_content}` and the request asks for it
1109
+ with `include:["reasoning.encrypted_content"]` (with `store:false` and
1110
+ `reasoning.summary:"auto"`, both of which the chatgpt adapter already sent); the
1111
+ block's text is the summary, or empty when the item had none. `include` is sent on
1112
+ every turn to the ChatGPT backend (as Codex sends it) and to a preset marked
1113
+ `sealedReasoning` (OpenCode Go, as OpenCode's own client sends it): a `store:false`
1114
+ backend that is not asked hands back nothing, so asking only once a conversation
1115
+ already carried reasoning would never start one. Any other Responses vendor is asked
1116
+ only once its own reasoning is already being carried, so an unmeasured vendor keeps
1117
+ its old request. Measured 2026-10-14 against OpenCode Go: the sealed item came back
1118
+ with `include` and without it (1426 bytes either way), replaying it in place
1119
+ answered 200, and the vendor tolerated both a tampered blob and a dropped item. On Chat the
1120
+ block's own text *is* the reasoning and it is reassembled into the field it arrived
1121
+ under on that assistant message — DeepSeek's docs make that mandatory whenever
1122
+ `tools` is present (`https://api-docs.deepseek.com/guides/thinking_mode/`), and it
1123
+ is harmless where a vendor ignores it, so this is not gated by an allowlist.
1124
+ Restore happens only when the envelope names this request's provider and model
1125
+ (model scope validation): another model's or account's `encrypted_content` is a
1126
+ 400, and the envelope says whose it is. This is not exposed as a setting, and it
1127
+ does not weaken the cache lesson above — the vendor's bytes are re-sent unchanged,
1128
+ never re-signed or edited. `opencodex` (github.com/lidge-jun/opencodex,
1129
+ `devlog/_fin/260711_claude_inbound/003_evidence.md:93-118`) uses the same
1130
+ envelope-in-the-transcript approach and measured that Claude Code replays thinking
1131
+ blocks with their signature and does not validate it client-side; no code was
1132
+ copied from it. A 4xx that names reasoning/encrypted/item is answered by asking
1133
+ **once more without any replayed reasoning**, so an account switch that invalidates
1134
+ `encrypted_content` costs a little cache and not the turn. `router.log` and
1135
+ `requests.jsonl` carry `replayed=<n>` (items put back) and `reasoning=<n>` (items
1136
+ handed to the client). Gemini and the other translators strip these blocks; a
1137
+ conversation switched to a Claude model does too (`thinking.ts`), since Anthropic
1138
+ would reject a foreign signature.
1088
1139
  - We do not assert a provider-wide cache hit rate: the adapter records
1089
1140
  `prompt_tokens_details.cached_tokens` / `input_tokens_details.cached_tokens` when
1090
1141
  a vendor returns it, and otherwise logs zero cache read. The byte-stable
@@ -1386,6 +1437,7 @@ plan unless marked otherwise.
1386
1437
  | Adding a provider in the settings window restored three OpenCode effort ladders realigned by hand five hours earlier, so the app's effort menus for DeepSeek and GLM never narrowed (2026-10-02 23:13, found 10-05). `PUT /api/config` takes the whole config, and the screen built it from the copy it loaded at open and never re-read; the router itself also writes `config.json` (a measurement's results), so any of those was undone by the next unrelated save | Every GUI save fetches the router's config first and applies its own change to that. A provider form, which holds per-model fields (wire, ladder, window) from when it opened and has no control for them, takes a saved model's values from the router's copy. The provider and client screens re-read the config whenever they load. |
1387
1438
  | In picker mode, claude.ai requests failed as `502 … ECONNRESET` in under a second — 259 times in four days (2026-10-03..06), mostly `current_user_access`, `desktop/features` and `event_logging`. The kept-alive upstream socket had been closed by claude.ai as the next request was written on it, and Node does not resend | A GET/HEAD/OPTIONS whose reused socket resets before any response is sent again (twice at most), logged `STALE`. A POST is never resent: a reset does not prove the upstream did not act on it. Not yet shown to be the cause of the Windows "chat history loads forever" report (DC, 2026-10-06). |
1388
1439
  | An API-key provider with no declared effort ladder — DeepSeek, Kimi, GLM, MiniMax, Qwen, Groq, Mistral, a key and a URL — stripped every effort the user picked (`effort high→removed`) and the Claude app showed no effort menu for its models (DC reports, 2026-10-06). Declaring nothing was meant as "do not guess", but DeepSeek's API, for one, validates `output_config.effort` and maps it onto low/high/max | When nothing declares a ladder (no config ladder, no preset ladder, OpenAI-compatible `reasoning` not `effort` — the form writes `none` by default), a measurement sends a level no vendor defines first: accepted means the field is ignored (no effort, `[]`); refused means it is validated, and the levels the refusal names (else every known one) are sent one by one and the accepted ones become the model's ladder. A 429/5xx concludes nothing. The router runs this a minute after start for unsettled models, so existing providers need no re-save. The DeepSeek preset now declares low/high/max from its docs. |
1440
+ | Muse workers spent minutes per tool call, repeated the same reasoning every step, and one ended with no answer at all (2026-10-14). A reasoning model on a translated wire was getting its own previous reasoning dropped, so each step reasoned the whole problem again from scratch: measured at `xhigh` on OpenCode Go, 9.5k–24.5k reasoning tokens per run without replay against 3.2k–4.3k with it (3 runs each; 2,197 against 725 per step past 80k context; one no-replay run exhausted the context) | The vendor's reasoning is carried in the client's transcript and put back byte-identically at its original position: the Responses item whole, under `include:["reasoning.encrypted_content"]`, and on Chat its text as `reasoning_content`. `reasoning.ts` states the envelope; a block whose envelope names another provider or model is dropped, and a 4xx naming the reasoning is retried once without it. |
1389
1441
  | ChatGPT accounts that had bought Codex credits were rested by the router the moment a window read 100%, though OpenAI serves them from credits past the window | `exhaustedForMs` ignores a full window while `credits.has_credits` or `unlimited` holds (and `overage_limit_reached` does not); an actual 429 still rests the account until the window resets. |
1390
1442
 
1391
1443
  ## 6. Blocked paths (measured, do not retry)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clauderipple",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
4
4
  "description": "Use GPT (any ChatGPT plan with Codex), DeepSeek and 400+ other models in the Claude Desktop Code tab and Claude Code while staying signed in to your Claude subscription — no third-party gateway mode. Several ChatGPT accounts that take over when one runs out, in Codex too.",
5
5
  "license": "GPL-3.0-only",
6
6
  "workspaces": [