pi-freeflow 1.26.0 → 1.26.1

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/src/responses.ts CHANGED
@@ -1,264 +1,425 @@
1
- /**
2
- * Responses-API reasoning portability.
3
- *
4
- * Reasoning items can carry an opaque `encrypted_content` blob. Upstream binds
5
- * each blob to the caller that issued it, and that caller is upstream-internal.
6
- * Measured, not inferred: on 2026-09-13 the same relay returned 200 at
7
- * 06:28:11 and 400 at 06:28:12 (and 200 again one second later), the identical
8
- * 400 also came back on the relay-bypassed direct path at 06:28:58, and a 45s
9
- * burst hit ten different conversations before all requests went back to 200.
10
- * A blob issued by one upstream service instance is unreadable to another, so
11
- * neither the relay nor the client egress explains the rejection.
12
- *
13
- * When it happens the host re-sends the same rejected history until the session
14
- * dies, because the rejection is a 400 (never retried by the relay pool) and
15
- * nothing in the host learns which items poisoned the request.
16
- *
17
- * Recovery, daemon-side: on that exact 400, retry once with every blob stripped
18
- * (messages, item ids, tool calls and `include` all survive; verified live), then
19
- * remember the hashes of the blobs that were present. Later turns strip only
20
- * those rejected blobs, so blobs the current instance issued keep flowing and
21
- * the conversation keeps its recent reasoning.
22
- */
23
-
24
- import { createHash } from "node:crypto";
25
-
26
- /** Upstream rejection for a reasoning blob issued to a different caller. */
27
- const CALLER_MISMATCH_PATTERN =
28
- /encrypted_content[\s\S]{0,32}?was not issued to this caller/i;
29
-
30
- /** Marker field on reasoning items. */
31
- const ENCRYPTED_CONTENT = "encrypted_content";
32
-
33
- /** How long a conversation remembers its rejected blobs. */
34
- const REJECTED_TTL_MS = 12 * 60 * 60 * 1_000;
35
- /** Conversations tracked before the oldest entry is dropped. */
36
- const MAX_TRACKED_CONVERSATIONS = 1_024;
37
- /** Rejected blob hashes kept per conversation before the oldest are dropped. */
38
- const MAX_REJECTED_BLOBS_PER_CONVERSATION = 4_096;
39
- /** Hash length kept per blob; a collision only strips one extra item. */
40
- const HASH_LENGTH = 32;
41
-
42
- /** How long a conversation remembers which path issued its reasoning. */
43
- const ISSUER_TTL_MS = 12 * 60 * 60 * 1_000;
44
- /** Conversations with issuer affinity tracked before the oldest is dropped. */
45
- const MAX_TRACKED_ISSUERS = 1_024;
46
-
47
- /** Conversation -> the relay (or direct path) that served its last success. */
48
- const issuerByConversation = new Map<string, { relay: string | null; expiresAt: number }>();
49
-
50
- interface RejectedReasoning {
51
- hashes: Set<string>;
52
- expiresAt: number;
53
- }
54
-
55
- const rejectedByConversation = new Map<string, RejectedReasoning>();
56
-
57
- /** True when the upstream error text names the caller-bound reasoning blob. */
58
- export function isReasoningCallerMismatch(errorText: string): boolean {
59
- return CALLER_MISMATCH_PATTERN.test(errorText);
60
- }
61
-
62
- /**
63
- * Conversation identity for responses requests. The host sends a stable
64
- * `prompt_cache_key` per conversation; without one the rejected blobs cannot be
65
- * remembered, and each burst costs one extra round trip instead of one total.
66
- */
67
- export function responsesConversationKey(body: unknown): string | null {
68
- if (typeof body !== "object" || body === null) return null;
69
- const key = (body as Record<string, unknown>).prompt_cache_key;
70
- return typeof key === "string" && key.length > 0 ? key : null;
71
- }
72
-
73
- /**
74
- * Record which path served this conversation's last successful response, so the
75
- * next turn can stay there (affinity) instead of re-sending reasoning the new
76
- * backend cannot read. `null` means the direct path, which is a different
77
- * backend from any relay.
78
- */
79
- export function rememberIssuerRelay(conversationKey: string, relay: string | null): void {
80
- pruneReasoningState();
81
- if (
82
- !issuerByConversation.has(conversationKey) &&
83
- issuerByConversation.size >= MAX_TRACKED_ISSUERS
84
- ) {
85
- const oldest = issuerByConversation.keys().next();
86
- if (!oldest.done) issuerByConversation.delete(oldest.value);
87
- }
88
- issuerByConversation.set(conversationKey, {
89
- relay,
90
- expiresAt: Date.now() + ISSUER_TTL_MS,
91
- });
92
- }
93
-
94
- /**
95
- * Where this conversation's reasoning came from: a relay URL, `null` for the
96
- * direct path, or `undefined` when nothing is known (new conversation, or the
97
- * record expired), in which case nothing can be replayed incompatibly.
98
- */
99
- export function issuerRelayFor(conversationKey: string): string | null | undefined {
100
- const entry = issuerByConversation.get(conversationKey);
101
- if (!entry) return undefined;
102
- if (entry.expiresAt <= Date.now()) {
103
- issuerByConversation.delete(conversationKey);
104
- return undefined;
105
- }
106
- return entry.relay;
107
- }
108
-
109
- /** Hash of one encrypted blob, used to recognize a rejected item on replay. */
110
- function blobHash(blob: string): string {
111
- return createHash("sha256").update(blob, "utf8").digest("hex").slice(0, HASH_LENGTH);
112
- }
113
-
114
- /** Parse a request body, returning its input array when it has one to rewrite. */
115
- function parseInput(raw: Buffer): { body: Record<string, unknown>; input: unknown[] } | null {
116
- const text = raw.toString("utf8");
117
- // Cheap gate: most requests carry no encrypted reasoning at all.
118
- if (!text.includes(ENCRYPTED_CONTENT)) return null;
119
- let body: unknown;
120
- try {
121
- body = JSON.parse(text);
122
- } catch {
123
- return null;
124
- }
125
- if (typeof body !== "object" || body === null) return null;
126
- const record = body as Record<string, unknown>;
127
- if (!Array.isArray(record.input)) return null;
128
- return { body: record, input: record.input };
129
- }
130
-
131
- /**
132
- * Remove every caller-bound reasoning blob, leaving all other fields untouched
133
- * (item ids, summaries, tool calls, `include`). Used for the one-shot retry of a
134
- * rejected request, where the offending blob cannot be identified. Returns null
135
- * when the body carries nothing to strip.
136
- */
137
- export function stripReasoningEncryption(raw: Buffer): Buffer | null {
138
- const parsed = parseInput(raw);
139
- if (!parsed) return null;
140
- let stripped = 0;
141
- for (const item of parsed.input) {
142
- if (typeof item !== "object" || item === null) continue;
143
- const record = item as Record<string, unknown>;
144
- if (typeof record[ENCRYPTED_CONTENT] !== "string") continue;
145
- delete record[ENCRYPTED_CONTENT];
146
- stripped += 1;
147
- }
148
- if (stripped === 0) return null;
149
- return Buffer.from(JSON.stringify(parsed.body), "utf8");
150
- }
151
-
152
- /**
153
- * Make a Zen responses body safe to serve from Kilo after failover: drop
154
- * the server-side chain pointer Kilo cannot resolve and every caller-bound
155
- * reasoning blob Kilo cannot read. Fresh sessions carry neither, so this is
156
- * a no-op for them; resumed histories lose cached reasoning for one turn
157
- * instead of failing the whole request.
158
- */
159
- export function prepareResponsesFailoverBody(parsedBody: Record<string, unknown>): void {
160
- delete parsedBody.previous_response_id;
161
- const input = parsedBody.input;
162
- if (!Array.isArray(input)) return;
163
- for (const item of input) {
164
- if (typeof item !== "object" || item === null) continue;
165
- const record = item as Record<string, unknown>;
166
- if (typeof record[ENCRYPTED_CONTENT] !== "string") continue;
167
- delete record[ENCRYPTED_CONTENT];
168
- }
169
- }
170
-
171
- /**
172
- * Remove only the blobs this conversation already had rejected, keeping every
173
- * blob the current caller issued. Returns null when nothing was rejected yet or
174
- * no rejected blob is present on this request.
175
- */
176
- export function stripRejectedReasoning(raw: Buffer, conversationKey: string | null): Buffer | null {
177
- if (conversationKey === null) return null;
178
- const rejected = rejectedByConversation.get(conversationKey);
179
- if (!rejected || rejected.hashes.size === 0) return null;
180
- if (rejected.expiresAt <= Date.now()) {
181
- rejectedByConversation.delete(conversationKey);
182
- return null;
183
- }
184
- const parsed = parseInput(raw);
185
- if (!parsed) return null;
186
-
187
- let stripped = 0;
188
- for (const item of parsed.input) {
189
- if (typeof item !== "object" || item === null) continue;
190
- const record = item as Record<string, unknown>;
191
- const blob = record[ENCRYPTED_CONTENT];
192
- if (typeof blob !== "string") continue;
193
- if (!rejected.hashes.has(blobHash(blob))) continue;
194
- delete record[ENCRYPTED_CONTENT];
195
- stripped += 1;
196
- }
197
- if (stripped === 0) return null;
198
- rejected.expiresAt = Date.now() + REJECTED_TTL_MS;
199
- return Buffer.from(JSON.stringify(parsed.body), "utf8");
200
- }
201
-
202
- /**
203
- * Remember every blob carried by a request upstream just rejected, so the next
204
- * turns of this conversation can drop them. Returns the number remembered.
205
- */
206
- export function rememberRejectedReasoning(raw: Buffer, conversationKey: string): number {
207
- const parsed = parseInput(raw);
208
- if (!parsed) return 0;
209
-
210
- pruneReasoningState();
211
- let entry = rejectedByConversation.get(conversationKey);
212
- if (!entry) {
213
- if (rejectedByConversation.size >= MAX_TRACKED_CONVERSATIONS) {
214
- const oldest = rejectedByConversation.keys().next();
215
- if (!oldest.done) rejectedByConversation.delete(oldest.value);
216
- }
217
- entry = { hashes: new Set<string>(), expiresAt: 0 };
218
- rejectedByConversation.set(conversationKey, entry);
219
- }
220
-
221
- let added = 0;
222
- for (const item of parsed.input) {
223
- if (typeof item !== "object" || item === null) continue;
224
- const blob = (item as Record<string, unknown>)[ENCRYPTED_CONTENT];
225
- if (typeof blob !== "string") continue;
226
- entry.hashes.add(blobHash(blob));
227
- added += 1;
228
- while (entry.hashes.size > MAX_REJECTED_BLOBS_PER_CONVERSATION) {
229
- const oldest = entry.hashes.values().next();
230
- if (oldest.done) break;
231
- entry.hashes.delete(oldest.value);
232
- }
233
- }
234
- entry.expiresAt = Date.now() + REJECTED_TTL_MS;
235
- return added;
236
- }
237
-
238
- /** Number of rejected blobs remembered for a conversation (debug/test). */
239
- export function rejectedReasoningCount(conversationKey: string): number {
240
- const entry = rejectedByConversation.get(conversationKey);
241
- if (!entry) return 0;
242
- if (entry.expiresAt <= Date.now()) {
243
- rejectedByConversation.delete(conversationKey);
244
- return 0;
245
- }
246
- return entry.hashes.size;
247
- }
248
-
249
- /** Drop conversations whose rejected-blob or issuer memory expired. */
250
- function pruneReasoningState(): void {
251
- const now = Date.now();
252
- for (const [key, entry] of rejectedByConversation) {
253
- if (entry.expiresAt <= now) rejectedByConversation.delete(key);
254
- }
255
- for (const [key, entry] of issuerByConversation) {
256
- if (entry.expiresAt <= now) issuerByConversation.delete(key);
257
- }
258
- }
259
-
260
- /** Test-only: clear tracked conversations. */
261
- export function _resetReasoningStateForTest(): void {
262
- rejectedByConversation.clear();
263
- issuerByConversation.clear();
264
- }
1
+ /**
2
+ * Responses-API reasoning portability.
3
+ *
4
+ * Reasoning items can carry an opaque `encrypted_content` blob. Upstream binds
5
+ * each blob to the caller that issued it, and that caller is upstream-internal.
6
+ * Measured, not inferred: on 2026-09-13 the same relay returned 200 at
7
+ * 06:28:11 and 400 at 06:28:12 (and 200 again one second later), the identical
8
+ * 400 also came back on the relay-bypassed direct path at 06:28:58, and a 45s
9
+ * burst hit ten different conversations before all requests went back to 200.
10
+ * A blob issued by one upstream service instance is unreadable to another, so
11
+ * neither the relay nor the client egress explains the rejection.
12
+ *
13
+ * When it happens the host re-sends the same rejected history until the session
14
+ * dies, because the rejection is a 400 (never retried by the relay pool) and
15
+ * nothing in the host learns which items poisoned the request.
16
+ *
17
+ * Recovery, daemon-side: on that exact 400, retry once with every blob stripped
18
+ * (messages, item ids, tool calls and `include` all survive; verified live), then
19
+ * remember the hashes of the blobs that were present. Later turns strip only
20
+ * those rejected blobs, so blobs the current instance issued keep flowing and
21
+ * the conversation keeps its recent reasoning.
22
+ */
23
+
24
+ import { createHash } from "node:crypto";
25
+
26
+ /** Upstream rejection for a reasoning blob issued to a different caller. */
27
+ const CALLER_MISMATCH_PATTERN =
28
+ /encrypted_content[\s\S]{0,32}?was not issued to this caller/i;
29
+
30
+ /** Upstream rejection for a reasoning reference that aged out server-side. */
31
+ const EXPIRED_REASONING_PATTERN =
32
+ /referenced reasoning item[\s\S]{0,256}?was not found or has expired/i;
33
+
34
+ /** Reasoning reference ids (`rs_...`; colon-joined pairs yield two tokens). */
35
+ const REASONING_ID_PATTERN = /rs_[A-Za-z0-9_]+/g;
36
+
37
+ /** Marker field on reasoning items. */
38
+ const ENCRYPTED_CONTENT = "encrypted_content";
39
+
40
+ /** How long a conversation remembers its rejected blobs. */
41
+ const REJECTED_TTL_MS = 12 * 60 * 60 * 1_000;
42
+ /** Conversations tracked before the oldest entry is dropped. */
43
+ const MAX_TRACKED_CONVERSATIONS = 1_024;
44
+ /** Rejected blob hashes kept per conversation before the oldest are dropped. */
45
+ const MAX_REJECTED_BLOBS_PER_CONVERSATION = 4_096;
46
+ /** Hash length kept per blob; a collision only strips one extra item. */
47
+ const HASH_LENGTH = 32;
48
+
49
+ /** How long a conversation remembers which path issued its reasoning. */
50
+ const ISSUER_TTL_MS = 12 * 60 * 60 * 1_000;
51
+ /** Conversations with issuer affinity tracked before the oldest is dropped. */
52
+ const MAX_TRACKED_ISSUERS = 1_024;
53
+
54
+ /** Conversation -> the relay (or direct path) that served its last success. */
55
+ const issuerByConversation = new Map<string, { relay: string | null; expiresAt: number }>();
56
+
57
+ interface RejectedReasoning {
58
+ hashes: Set<string>;
59
+ expiresAt: number;
60
+ }
61
+
62
+ const rejectedByConversation = new Map<string, RejectedReasoning>();
63
+
64
+ interface RejectedReasoningIds {
65
+ ids: Set<string>;
66
+ expiresAt: number;
67
+ }
68
+
69
+ const rejectedIdsByConversation = new Map<string, RejectedReasoningIds>();
70
+
71
+ /** True when the upstream error text names the caller-bound reasoning blob. */
72
+ export function isReasoningCallerMismatch(errorText: string): boolean {
73
+ return CALLER_MISMATCH_PATTERN.test(errorText);
74
+ }
75
+
76
+ /** True when the upstream error text names an expired reasoning reference. */
77
+ export function isExpiredReasoningReference(errorText: string): boolean {
78
+ return EXPIRED_REASONING_PATTERN.test(errorText);
79
+ }
80
+
81
+ /** Every `rs_...` token named by an expired-reference error. */
82
+ export function extractExpiredReasoningIds(errorText: string): string[] {
83
+ return errorText.match(REASONING_ID_PATTERN) ?? [];
84
+ }
85
+
86
+ /**
87
+ * Conversation identity for responses requests. The host sends a stable
88
+ * `prompt_cache_key` per conversation; without one the rejected blobs cannot be
89
+ * remembered, and each burst costs one extra round trip instead of one total.
90
+ */
91
+ export function responsesConversationKey(body: unknown): string | null {
92
+ if (typeof body !== "object" || body === null) return null;
93
+ const key = (body as Record<string, unknown>).prompt_cache_key;
94
+ return typeof key === "string" && key.length > 0 ? key : null;
95
+ }
96
+
97
+ /**
98
+ * Record which path served this conversation's last successful response, so the
99
+ * next turn can stay there (affinity) instead of re-sending reasoning the new
100
+ * backend cannot read. `null` means the direct path, which is a different
101
+ * backend from any relay.
102
+ */
103
+ export function rememberIssuerRelay(conversationKey: string, relay: string | null): void {
104
+ pruneReasoningState();
105
+ if (
106
+ !issuerByConversation.has(conversationKey) &&
107
+ issuerByConversation.size >= MAX_TRACKED_ISSUERS
108
+ ) {
109
+ const oldest = issuerByConversation.keys().next();
110
+ if (!oldest.done) issuerByConversation.delete(oldest.value);
111
+ }
112
+ issuerByConversation.set(conversationKey, {
113
+ relay,
114
+ expiresAt: Date.now() + ISSUER_TTL_MS,
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Where this conversation's reasoning came from: a relay URL, `null` for the
120
+ * direct path, or `undefined` when nothing is known (new conversation, or the
121
+ * record expired), in which case nothing can be replayed incompatibly.
122
+ */
123
+ export function issuerRelayFor(conversationKey: string): string | null | undefined {
124
+ const entry = issuerByConversation.get(conversationKey);
125
+ if (!entry) return undefined;
126
+ if (entry.expiresAt <= Date.now()) {
127
+ issuerByConversation.delete(conversationKey);
128
+ return undefined;
129
+ }
130
+ return entry.relay;
131
+ }
132
+
133
+ /** Hash of one encrypted blob, used to recognize a rejected item on replay. */
134
+ function blobHash(blob: string): string {
135
+ return createHash("sha256").update(blob, "utf8").digest("hex").slice(0, HASH_LENGTH);
136
+ }
137
+
138
+ /** Parse a request body, returning its input array when it has one to rewrite. */
139
+ function parseInput(raw: Buffer): { body: Record<string, unknown>; input: unknown[] } | null {
140
+ const text = raw.toString("utf8");
141
+ // Cheap gate: most requests carry no encrypted reasoning at all.
142
+ if (!text.includes(ENCRYPTED_CONTENT)) return null;
143
+ let body: unknown;
144
+ try {
145
+ body = JSON.parse(text);
146
+ } catch {
147
+ return null;
148
+ }
149
+ if (typeof body !== "object" || body === null) return null;
150
+ const record = body as Record<string, unknown>;
151
+ if (!Array.isArray(record.input)) return null;
152
+ return { body: record, input: record.input };
153
+ }
154
+
155
+ /** Parse a request body without the blob gate: expired-reference bodies carry no `encrypted_content`. */
156
+ function parseReasoningBody(raw: Buffer): { body: Record<string, unknown>; input: unknown[] } | null {
157
+ let body: unknown;
158
+ try {
159
+ body = JSON.parse(raw.toString("utf8"));
160
+ } catch {
161
+ return null;
162
+ }
163
+ if (typeof body !== "object" || body === null) return null;
164
+ const record = body as Record<string, unknown>;
165
+ if (!Array.isArray(record.input)) return null;
166
+ return { body: record, input: record.input };
167
+ }
168
+
169
+ /**
170
+ * Remove every caller-bound reasoning blob, leaving all other fields untouched
171
+ * (item ids, summaries, tool calls, `include`). Used for the one-shot retry of a
172
+ * rejected request, where the offending blob cannot be identified. Returns null
173
+ * when the body carries nothing to strip.
174
+ */
175
+ export function stripReasoningEncryption(raw: Buffer): Buffer | null {
176
+ const parsed = parseInput(raw);
177
+ if (!parsed) return null;
178
+ let stripped = 0;
179
+ for (const item of parsed.input) {
180
+ if (typeof item !== "object" || item === null) continue;
181
+ const record = item as Record<string, unknown>;
182
+ if (typeof record[ENCRYPTED_CONTENT] !== "string") continue;
183
+ delete record[ENCRYPTED_CONTENT];
184
+ stripped += 1;
185
+ }
186
+ if (stripped === 0) return null;
187
+ return Buffer.from(JSON.stringify(parsed.body), "utf8");
188
+ }
189
+
190
+ /**
191
+ * Drop the server-side chain pointer a new backend cannot resolve. Returns
192
+ * null when the body carries no pointer.
193
+ */
194
+ export function stripPreviousResponseId(raw: Buffer): Buffer | null {
195
+ let body: unknown;
196
+ try {
197
+ body = JSON.parse(raw.toString("utf8"));
198
+ } catch {
199
+ return null;
200
+ }
201
+ if (typeof body !== "object" || body === null) return null;
202
+ const record = body as Record<string, unknown>;
203
+ if (!("previous_response_id" in record)) return null;
204
+ delete record.previous_response_id;
205
+ return Buffer.from(JSON.stringify(record), "utf8");
206
+ }
207
+
208
+ /**
209
+ * Drop every reasoning item plus the chain pointer: the poisoned history
210
+ * cannot be resolved server-side, so one turn of reasoning context is lost
211
+ * instead of failing the whole request. Returns null when nothing to drop.
212
+ */
213
+ export function stripUnresolvableReasoning(raw: Buffer): Buffer | null {
214
+ const parsed = parseReasoningBody(raw);
215
+ if (!parsed) return stripPreviousResponseId(raw);
216
+ const kept: unknown[] = [];
217
+ let stripped = 0;
218
+ for (const item of parsed.input) {
219
+ if (typeof item === "object" && item !== null && (item as Record<string, unknown>).type === "reasoning") {
220
+ stripped += 1;
221
+ continue;
222
+ }
223
+ kept.push(item);
224
+ }
225
+ const hadPointer = "previous_response_id" in parsed.body;
226
+ if (stripped === 0 && !hadPointer) return null;
227
+ if (stripped > 0) parsed.body.input = kept;
228
+ if (hadPointer) delete parsed.body.previous_response_id;
229
+ return Buffer.from(JSON.stringify(parsed.body), "utf8");
230
+ }
231
+
232
+ /**
233
+ * Make a Zen responses body safe to serve from Kilo after failover: drop
234
+ * the server-side chain pointer Kilo cannot resolve and every caller-bound
235
+ * reasoning blob Kilo cannot read. Fresh sessions carry neither, so this is
236
+ * a no-op for them; resumed histories lose cached reasoning for one turn
237
+ * instead of failing the whole request.
238
+ */
239
+ export function prepareResponsesFailoverBody(parsedBody: Record<string, unknown>): void {
240
+ delete parsedBody.previous_response_id;
241
+ const input = parsedBody.input;
242
+ if (!Array.isArray(input)) return;
243
+ for (const item of input) {
244
+ if (typeof item !== "object" || item === null) continue;
245
+ const record = item as Record<string, unknown>;
246
+ if (typeof record[ENCRYPTED_CONTENT] !== "string") continue;
247
+ delete record[ENCRYPTED_CONTENT];
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Remove only the blobs this conversation already had rejected, keeping every
253
+ * blob the current caller issued. Returns null when nothing was rejected yet or
254
+ * no rejected blob is present on this request.
255
+ */
256
+ export function stripRejectedReasoning(raw: Buffer, conversationKey: string | null): Buffer | null {
257
+ if (conversationKey === null) return null;
258
+ const rejected = rejectedByConversation.get(conversationKey);
259
+ if (!rejected || rejected.hashes.size === 0) return null;
260
+ if (rejected.expiresAt <= Date.now()) {
261
+ rejectedByConversation.delete(conversationKey);
262
+ return null;
263
+ }
264
+ const parsed = parseInput(raw);
265
+ if (!parsed) return null;
266
+
267
+ let stripped = 0;
268
+ for (const item of parsed.input) {
269
+ if (typeof item !== "object" || item === null) continue;
270
+ const record = item as Record<string, unknown>;
271
+ const blob = record[ENCRYPTED_CONTENT];
272
+ if (typeof blob !== "string") continue;
273
+ if (!rejected.hashes.has(blobHash(blob))) continue;
274
+ delete record[ENCRYPTED_CONTENT];
275
+ stripped += 1;
276
+ }
277
+ if (stripped === 0) return null;
278
+ rejected.expiresAt = Date.now() + REJECTED_TTL_MS;
279
+ return Buffer.from(JSON.stringify(parsed.body), "utf8");
280
+ }
281
+
282
+ /**
283
+ * Remember every blob carried by a request upstream just rejected, so the next
284
+ * turns of this conversation can drop them. Returns the number remembered.
285
+ */
286
+ export function rememberRejectedReasoning(raw: Buffer, conversationKey: string): number {
287
+ const parsed = parseInput(raw);
288
+ if (!parsed) return 0;
289
+
290
+ pruneReasoningState();
291
+ let entry = rejectedByConversation.get(conversationKey);
292
+ if (!entry) {
293
+ if (rejectedByConversation.size >= MAX_TRACKED_CONVERSATIONS) {
294
+ const oldest = rejectedByConversation.keys().next();
295
+ if (!oldest.done) rejectedByConversation.delete(oldest.value);
296
+ }
297
+ entry = { hashes: new Set<string>(), expiresAt: 0 };
298
+ rejectedByConversation.set(conversationKey, entry);
299
+ }
300
+
301
+ let added = 0;
302
+ for (const item of parsed.input) {
303
+ if (typeof item !== "object" || item === null) continue;
304
+ const blob = (item as Record<string, unknown>)[ENCRYPTED_CONTENT];
305
+ if (typeof blob !== "string") continue;
306
+ entry.hashes.add(blobHash(blob));
307
+ added += 1;
308
+ while (entry.hashes.size > MAX_REJECTED_BLOBS_PER_CONVERSATION) {
309
+ const oldest = entry.hashes.values().next();
310
+ if (oldest.done) break;
311
+ entry.hashes.delete(oldest.value);
312
+ }
313
+ }
314
+ entry.expiresAt = Date.now() + REJECTED_TTL_MS;
315
+ return added;
316
+ }
317
+
318
+ /** Number of rejected blobs remembered for a conversation (debug/test). */
319
+ export function rejectedReasoningCount(conversationKey: string): number {
320
+ const entry = rejectedByConversation.get(conversationKey);
321
+ if (!entry) return 0;
322
+ if (entry.expiresAt <= Date.now()) {
323
+ rejectedByConversation.delete(conversationKey);
324
+ return 0;
325
+ }
326
+ return entry.hashes.size;
327
+ }
328
+
329
+ /**
330
+ * Remember every reasoning id named by an expired-reference error, so the
331
+ * next turns of this conversation can drop those items plus the chain
332
+ * pointer. Returns the number remembered.
333
+ */
334
+ export function rememberRejectedReasoningIds(errorText: string, conversationKey: string): number {
335
+ const ids = extractExpiredReasoningIds(errorText);
336
+ if (ids.length === 0) return 0;
337
+ pruneReasoningState();
338
+ let entry = rejectedIdsByConversation.get(conversationKey);
339
+ if (!entry) {
340
+ if (rejectedIdsByConversation.size >= MAX_TRACKED_CONVERSATIONS) {
341
+ const oldest = rejectedIdsByConversation.keys().next();
342
+ if (!oldest.done) rejectedIdsByConversation.delete(oldest.value);
343
+ }
344
+ entry = { ids: new Set<string>(), expiresAt: 0 };
345
+ rejectedIdsByConversation.set(conversationKey, entry);
346
+ }
347
+ let added = 0;
348
+ for (const id of ids) {
349
+ entry.ids.add(id);
350
+ added += 1;
351
+ while (entry.ids.size > MAX_REJECTED_BLOBS_PER_CONVERSATION) {
352
+ const oldest = entry.ids.values().next();
353
+ if (oldest.done) break;
354
+ entry.ids.delete(oldest.value);
355
+ }
356
+ }
357
+ entry.expiresAt = Date.now() + REJECTED_TTL_MS;
358
+ return added;
359
+ }
360
+
361
+ /**
362
+ * Remove only the reasoning items this conversation already saw expire,
363
+ * plus the chain pointer that may reference them. Returns null when nothing
364
+ * was rejected yet or no rejected id is present on this request.
365
+ */
366
+ export function stripRejectedReasoningIds(raw: Buffer, conversationKey: string | null): Buffer | null {
367
+ if (conversationKey === null) return null;
368
+ const rejected = rejectedIdsByConversation.get(conversationKey);
369
+ if (!rejected || rejected.ids.size === 0) return null;
370
+ if (rejected.expiresAt <= Date.now()) {
371
+ rejectedIdsByConversation.delete(conversationKey);
372
+ return null;
373
+ }
374
+ const parsed = parseReasoningBody(raw);
375
+ if (!parsed) return null;
376
+ const kept: unknown[] = [];
377
+ let stripped = 0;
378
+ for (const item of parsed.input) {
379
+ if (typeof item === "object" && item !== null && (item as Record<string, unknown>).type === "reasoning") {
380
+ const id = (item as Record<string, unknown>).id;
381
+ if (typeof id === "string" && (rejected.ids.has(id) || (id.match(REASONING_ID_PATTERN) ?? []).some((t) => rejected.ids.has(t)))) {
382
+ stripped += 1;
383
+ continue;
384
+ }
385
+ }
386
+ kept.push(item);
387
+ }
388
+ if (stripped === 0) return null;
389
+ parsed.body.input = kept;
390
+ delete parsed.body.previous_response_id;
391
+ rejected.expiresAt = Date.now() + REJECTED_TTL_MS;
392
+ return Buffer.from(JSON.stringify(parsed.body), "utf8");
393
+ }
394
+
395
+ /** Number of rejected reasoning ids remembered for a conversation (debug/test). */
396
+ export function rejectedReasoningIdsCount(conversationKey: string): number {
397
+ const entry = rejectedIdsByConversation.get(conversationKey);
398
+ if (!entry) return 0;
399
+ if (entry.expiresAt <= Date.now()) {
400
+ rejectedIdsByConversation.delete(conversationKey);
401
+ return 0;
402
+ }
403
+ return entry.ids.size;
404
+ }
405
+
406
+ /** Drop conversations whose rejected-blob, rejected-id or issuer memory expired. */
407
+ function pruneReasoningState(): void {
408
+ const now = Date.now();
409
+ for (const [key, entry] of rejectedByConversation) {
410
+ if (entry.expiresAt <= now) rejectedByConversation.delete(key);
411
+ }
412
+ for (const [key, entry] of rejectedIdsByConversation) {
413
+ if (entry.expiresAt <= now) rejectedIdsByConversation.delete(key);
414
+ }
415
+ for (const [key, entry] of issuerByConversation) {
416
+ if (entry.expiresAt <= now) issuerByConversation.delete(key);
417
+ }
418
+ }
419
+
420
+ /** Test-only: clear tracked conversations. */
421
+ export function _resetReasoningStateForTest(): void {
422
+ rejectedByConversation.clear();
423
+ rejectedIdsByConversation.clear();
424
+ issuerByConversation.clear();
425
+ }