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