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/proxy.ts CHANGED
@@ -58,11 +58,17 @@ import {
58
58
  issuerRelayFor,
59
59
  prepareResponsesFailoverBody,
60
60
  rejectedReasoningCount,
61
+ rejectedReasoningIdsCount,
61
62
  rememberIssuerRelay,
62
63
  rememberRejectedReasoning,
64
+ rememberRejectedReasoningIds,
63
65
  responsesConversationKey,
66
+ stripPreviousResponseId,
64
67
  stripRejectedReasoning,
68
+ stripRejectedReasoningIds,
65
69
  stripReasoningEncryption,
70
+ stripUnresolvableReasoning,
71
+ isExpiredReasoningReference,
66
72
  isReasoningCallerMismatch,
67
73
  } from "./responses.ts";
68
74
  import { pipeUpstreamStream } from "./stream-pipe.ts";
@@ -730,7 +736,7 @@ function upstreamTimeoutError(): Error & { code: string } {
730
736
  }
731
737
 
732
738
  /**
733
- * Recover from upstream rejecting a replayed reasoning blob as foreign.
739
+ * Recover from upstream rejecting a replayed reasoning history.
734
740
  *
735
741
  * Upstream binds each reasoning `encrypted_content` blob to the service
736
742
  * instance that issued it. When the conversation is later served by a different
@@ -740,13 +746,16 @@ function upstreamTimeoutError(): Error & { code: string } {
740
746
  * second apart, the direct path returned the same 400, and a 45s burst hit ten
741
747
  * conversations.
742
748
  *
743
- * The rejected blob cannot be identified from the response, so this drops every
744
- * blob, retries once, and then remembers the hashes of the blobs that were in
745
- * the failing body: later turns drop only those and keep whatever the current
746
- * instance issued, so the conversation keeps its recent reasoning.
749
+ * Reasoning references also age out server-side (observed around 9.5h): the
750
+ * retry after a blob strip can come back as `referenced reasoning item
751
+ * 'rs_...' was not found or has expired`. That poisoned history cannot be
752
+ * resolved by any backend, so the chained second step drops every reasoning
753
+ * item plus `previous_response_id` and retries the same relay again — one
754
+ * turn of reasoning context is lost instead of the whole session.
747
755
  *
748
- * The 400 body is inspected through a clone: a successful streaming response
749
- * (the normal path) is never consumed here.
756
+ * Both 400s are request-scoped: the relay served correctly and is never
757
+ * marked failed for them. The 400 body is inspected through a clone: a
758
+ * successful streaming response (the normal path) is never consumed here.
750
759
  */
751
760
  async function retryWithoutReasoningEncryption(
752
761
  response: Response,
@@ -765,36 +774,81 @@ async function retryWithoutReasoningEncryption(
765
774
  } catch {
766
775
  return response;
767
776
  }
768
- if (!isReasoningCallerMismatch(errorText)) return response;
777
+ const callerMismatch = isReasoningCallerMismatch(errorText);
778
+ const expiredDirect = isExpiredReasoningReference(errorText);
779
+ if (!callerMismatch && !expiredDirect) return response;
769
780
 
770
- const portable = stripReasoningEncryption(sentBody);
771
- if (!portable) {
772
- log(
773
- "warn",
774
- "upstream rejected caller-bound reasoning but the request carries no encrypted_content to strip",
775
- { status: 400 },
776
- reqId,
777
- );
778
- return response;
781
+ let current = response;
782
+ let currentBody = sentBody;
783
+ let currentErrorText = errorText;
784
+
785
+ if (callerMismatch) {
786
+ const portable = stripReasoningEncryption(sentBody);
787
+ if (!portable) {
788
+ log(
789
+ "warn",
790
+ "upstream rejected caller-bound reasoning but the request carries no encrypted_content to strip",
791
+ { status: 400 },
792
+ reqId,
793
+ );
794
+ if (!expiredDirect) return response;
795
+ } else {
796
+ log(
797
+ "warn",
798
+ "upstream rejected caller-bound reasoning (blobs issued by another instance); retrying with encrypted_content stripped",
799
+ { status: 400, tracked: conversationKey !== null },
800
+ reqId,
801
+ );
802
+ const retried = await resend(portable);
803
+ if (conversationKey) {
804
+ const remembered = rememberRejectedReasoning(sentBody, conversationKey);
805
+ log(
806
+ "info",
807
+ `conversation will drop ${remembered} rejected reasoning blob(s) on later turns`,
808
+ undefined,
809
+ reqId,
810
+ );
811
+ }
812
+ if (retried.status !== 400) return retried;
813
+ if ((retried.headers.get("content-type") ?? "").includes("text/event-stream")) return retried;
814
+ let retryText: string;
815
+ try {
816
+ retryText = await retried.clone().text();
817
+ } catch {
818
+ return retried;
819
+ }
820
+ if (!isExpiredReasoningReference(retryText)) return retried;
821
+ current = retried;
822
+ currentBody = portable;
823
+ currentErrorText = retryText;
824
+ }
779
825
  }
780
826
 
827
+ if (!isExpiredReasoningReference(currentErrorText)) return current;
828
+ const unresolvable = stripUnresolvableReasoning(currentBody);
829
+ if (!unresolvable) {
830
+ if (conversationKey) {
831
+ const remembered = rememberRejectedReasoningIds(currentErrorText, conversationKey);
832
+ log(
833
+ "warn",
834
+ `upstream rejected expired reasoning reference (${remembered} id(s)); nothing strippable left in the request`,
835
+ { status: 400, tracked: true },
836
+ reqId,
837
+ );
838
+ }
839
+ return current;
840
+ }
841
+ let rememberedIds = 0;
842
+ if (conversationKey) {
843
+ rememberedIds = rememberRejectedReasoningIds(currentErrorText, conversationKey);
844
+ }
781
845
  log(
782
846
  "warn",
783
- "upstream rejected caller-bound reasoning (blobs issued by another instance); retrying with encrypted_content stripped",
847
+ `upstream rejected expired reasoning reference (${rememberedIds} id(s)); retrying without reasoning history`,
784
848
  { status: 400, tracked: conversationKey !== null },
785
849
  reqId,
786
850
  );
787
- const retried = await resend(portable);
788
- if (retried.ok && conversationKey) {
789
- const remembered = rememberRejectedReasoning(sentBody, conversationKey);
790
- log(
791
- "info",
792
- `conversation will drop ${remembered} rejected reasoning blob(s) on later turns`,
793
- undefined,
794
- reqId,
795
- );
796
- }
797
- return retried;
851
+ return await resend(unresolvable);
798
852
  }
799
853
 
800
854
  /**
@@ -1116,16 +1170,23 @@ export function startProxy(
1116
1170
  // cannot read this history, so send it portable instead of
1117
1171
  // letting upstream reject the whole request.
1118
1172
  const issuerChanged = issuer !== undefined && issuer !== servingRelay;
1119
- // Drop only the blobs upstream already rejected, so everything
1120
- // the current backend issued still flows verbatim.
1121
- const stripRejected = responsesRequest &&
1122
- conversationKey !== null &&
1123
- rejectedReasoningCount(conversationKey) > 0;
1124
- const bodyForUpstream = issuerChanged
1125
- ? (stripReasoningEncryption(requestBody) ?? requestBody)
1126
- : stripRejected
1127
- ? (stripRejectedReasoning(requestBody, conversationKey) ?? requestBody)
1128
- : requestBody;
1173
+ // Portable history on hops, selective memory on every turn: dead-id
1174
+ // stripping applies regardless of the hop guard, because a
1175
+ // relay-to-relay or relay-to-direct hop also changes the backend
1176
+ // that must resolve the ids.
1177
+ let bodyForUpstream: Buffer = requestBody;
1178
+ if (responsesRequest && conversationKey !== null) {
1179
+ if (issuerChanged) {
1180
+ bodyForUpstream = stripReasoningEncryption(bodyForUpstream) ?? bodyForUpstream;
1181
+ bodyForUpstream = stripPreviousResponseId(bodyForUpstream) ?? bodyForUpstream;
1182
+ }
1183
+ if (rejectedReasoningCount(conversationKey) > 0) {
1184
+ bodyForUpstream = stripRejectedReasoning(bodyForUpstream, conversationKey) ?? bodyForUpstream;
1185
+ }
1186
+ if (rejectedReasoningIdsCount(conversationKey) > 0) {
1187
+ bodyForUpstream = stripRejectedReasoningIds(bodyForUpstream, conversationKey) ?? bodyForUpstream;
1188
+ }
1189
+ }
1129
1190
  if (issuerChanged && conversationKey !== null) {
1130
1191
  // These blobs belong to a backend this conversation is
1131
1192
  // leaving, so never replay them again.
@@ -1280,14 +1341,19 @@ export function startProxy(
1280
1341
  ? issuerRelayFor(directConversationKey)
1281
1342
  : undefined;
1282
1343
  const directIssuerChanged = directIssuer !== undefined && directIssuer !== null;
1283
- const directStripRejected = directResponsesRequest &&
1284
- directConversationKey !== null &&
1285
- rejectedReasoningCount(directConversationKey) > 0;
1286
- const directBodyForUpstream = directIssuerChanged
1287
- ? (stripReasoningEncryption(directBody) ?? directBody)
1288
- : directStripRejected
1289
- ? (stripRejectedReasoning(directBody, directConversationKey) ?? directBody)
1290
- : directBody;
1344
+ let directBodyForUpstream: Buffer = directBody;
1345
+ if (directResponsesRequest && directConversationKey !== null) {
1346
+ if (directIssuerChanged) {
1347
+ directBodyForUpstream = stripReasoningEncryption(directBodyForUpstream) ?? directBodyForUpstream;
1348
+ directBodyForUpstream = stripPreviousResponseId(directBodyForUpstream) ?? directBodyForUpstream;
1349
+ }
1350
+ if (rejectedReasoningCount(directConversationKey) > 0) {
1351
+ directBodyForUpstream = stripRejectedReasoning(directBodyForUpstream, directConversationKey) ?? directBodyForUpstream;
1352
+ }
1353
+ if (rejectedReasoningIdsCount(directConversationKey) > 0) {
1354
+ directBodyForUpstream = stripRejectedReasoningIds(directBodyForUpstream, directConversationKey) ?? directBodyForUpstream;
1355
+ }
1356
+ }
1291
1357
  if (directIssuerChanged && directConversationKey !== null) {
1292
1358
  rememberRejectedReasoning(directBody, directConversationKey);
1293
1359
  log(