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/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/commands.ts +76 -8
- package/src/deploy.ts +655 -576
- package/src/proxy.ts +113 -47
- package/src/responses.ts +425 -264
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
|
-
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
const
|
|
44
|
-
/**
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
export function
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
+
}
|